Fix QC Keano FrontEnd #6

Merged
nicoarya20 merged 1 commits from nico/3-nov-25 into staging 2025-11-03 17:36:48 +08:00
20 changed files with 1038 additions and 439 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -26,6 +26,7 @@
"@mantine/dropzone": "^8.1.1", "@mantine/dropzone": "^8.1.1",
"@mantine/form": "^8.1.0", "@mantine/form": "^8.1.0",
"@mantine/hooks": "^7.17.4", "@mantine/hooks": "^7.17.4",
"@mantine/modals": "^8.3.6",
"@mantine/tiptap": "^7.17.4", "@mantine/tiptap": "^7.17.4",
"@paljs/types": "^8.1.0", "@paljs/types": "^8.1.0",
"@prisma/client": "^6.3.1", "@prisma/client": "^6.3.1",
@@ -55,8 +56,9 @@
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"elysia": "^1.3.5", "elysia": "^1.3.5",
"embla-carousel-autoplay": "^8.5.2", "embla-carousel": "^8.6.0",
"embla-carousel-react": "^7.1.0", "embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"extract-zip": "^2.0.1", "extract-zip": "^2.0.1",
"form-data": "^4.0.2", "form-data": "^4.0.2",
"framer-motion": "^12.23.5", "framer-motion": "^12.23.5",
@@ -80,6 +82,7 @@
"prisma": "^6.3.1", "prisma": "^6.3.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-exif-orientation-img": "^0.1.5",
"react-international-phone": "^4.6.0", "react-international-phone": "^4.6.0",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"react-simple-toasts": "^6.1.0", "react-simple-toasts": "^6.1.0",

View File

@@ -6,9 +6,9 @@ import { proxy } from "valtio";
import { z } from "zod"; import { z } from "zod";
const templateForm = z.object({ const templateForm = z.object({
name: z.string().min(1, "Nama minimal 1 karakter"), name: z.string().min(5, "Nama minimal 5 karakter"),
deskripsi: z.string().min(1, "Deskripsi minimal 1 karakter"), deskripsi: z.string().min(5, "Deskripsi minimal 5 karakter"),
slug: z.string().min(1, "Deskripsi singkat minimal 1 karakter"), slug: z.string().min(5, "Deskripsi singkat minimal 5 karakter"),
icon: z.string().min(1, "Icon minimal 1 karakter"), icon: z.string().min(1, "Icon minimal 1 karakter"),
}); });
@@ -29,7 +29,8 @@ const programKreatifState = proxy({
const err = `[${cek.error.issues const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`) .map((v) => `${v.path.join(".")}`)
.join("\n")}] required`; .join("\n")}] required`;
return toast.error(err); toast.error(err);
return false; // ⬅️ ini penting
} }
try { try {
@@ -37,18 +38,24 @@ const programKreatifState = proxy({
const res = await ApiFetch.api.inovasi.programkreatif["create"].post( const res = await ApiFetch.api.inovasi.programkreatif["create"].post(
programKreatifState.create.form programKreatifState.create.form
); );
if (res.status === 200) { if (res.status === 200) {
programKreatifState.findMany.load(); 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) { } catch (error) {
console.log((error as Error).message); console.error((error as Error).message);
toast.error("Terjadi kesalahan saat create");
return false;
} finally { } finally {
programKreatifState.create.loading = false; programKreatifState.create.loading = false;
} }
}, }
}, },
findMany: { findMany: {
data: null as any[] | null, data: null as any[] | null,

View File

@@ -5,7 +5,6 @@ import {
Box, Box,
Button, Button,
Group, Group,
Image,
Paper, Paper,
Stack, Stack,
Text, Text,
@@ -21,6 +20,7 @@ import { useProxy } from 'valtio/utils';
import CreateEditor from '../../../_com/createEditor'; import CreateEditor from '../../../_com/createEditor';
import desaDigitalState from '../../../_state/inovasi/desa-digital'; import desaDigitalState from '../../../_state/inovasi/desa-digital';
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import ExifOrientationImg from 'react-exif-orientation-img';
export default function CreateDesaDigital() { export default function CreateDesaDigital() {
const stateDesaDigital = useProxy(desaDigitalState); const stateDesaDigital = useProxy(desaDigitalState);
@@ -173,16 +173,15 @@ export default function CreateDesaDigital() {
overflow: 'hidden', overflow: 'hidden',
}} }}
> >
<Image <ExifOrientationImg
src={previewImage} src={previewImage}
alt="Preview" alt="Preview"
radius="md"
style={{ style={{
maxHeight: 220, maxHeight: 220,
objectFit: 'cover', objectFit: 'cover',
border: '1px solid #e0e0e0', border: '1px solid #e0e0e0',
borderRadius: 12,
}} }}
loading="lazy"
/> />
</Box> </Box>
)} )}

View File

@@ -54,8 +54,8 @@ function DetailInfoTeknologiTepatGuna() {
{/* Card Utama */} {/* Card Utama */}
<Paper <Paper
withBorder withBorder
w={{ base: "100%", md: "70%", lg: "60%" }} w={{ base: "100%", md: "50%" }}
bg="#ECEEF8" bg={colors['white-1']}
p="lg" p="lg"
radius="md" radius="md"
shadow="sm" shadow="sm"
@@ -65,7 +65,7 @@ function DetailInfoTeknologiTepatGuna() {
Detail Info Teknologi Tepat Guna Detail Info Teknologi Tepat Guna
</Text> </Text>
<Paper bg={colors['BG-trans']} p="md" radius="md" shadow="xs"> <Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="sm"> <Stack gap="sm">
<Box> <Box>
<Text fz="lg" fw="bold">Judul</Text> <Text fz="lg" fw="bold">Judul</Text>

View File

@@ -1,6 +1,7 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor'; 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 programKreatifState from '@/app/admin/(dashboard)/_state/inovasi/program-kreatif';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { import {
@@ -11,8 +12,7 @@ import {
Stack, Stack,
Text, Text,
TextInput, TextInput,
Title, Title
Tooltip,
} from '@mantine/core'; } from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react'; import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
@@ -20,7 +20,6 @@ import { useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import SelectIconProgramEdit from '../../../../_com/selectIconEdit'; import SelectIconProgramEdit from '../../../../_com/selectIconEdit';
import { IconKey } from '@/app/admin/(dashboard)/_com/iconMap';
interface FormProgramKreatif { interface FormProgramKreatif {
name: string; name: string;
@@ -41,6 +40,15 @@ function EditProgramKreatifDesa() {
icon: '', icon: '',
}); });
const [originalData, setOriginalData] = useState<FormProgramKreatif>({
name: '',
deskripsi: '',
slug: '',
icon: '',
});
const [isDataChanged, setIsDataChanged] = useState(false);
// Load data hanya sekali berdasarkan params.id // Load data hanya sekali berdasarkan params.id
useEffect(() => { useEffect(() => {
const loadProgramKreatif = async () => { const loadProgramKreatif = async () => {
@@ -51,12 +59,14 @@ function EditProgramKreatifDesa() {
const data = await stateProgramKreatif.update.load(id); const data = await stateProgramKreatif.update.load(id);
if (data) { if (data) {
stateProgramKreatif.update.id = id; stateProgramKreatif.update.id = id;
setFormData({ const loadedData = {
name: data.name || '', name: data.name || '',
slug: data.slug || '', slug: data.slug || '',
deskripsi: data.deskripsi || '', deskripsi: data.deskripsi || '',
icon: data.icon || '', icon: data.icon || '',
}); };
setFormData(loadedData);
setOriginalData(loadedData);
} }
} catch (error) { } catch (error) {
console.error('Error loading program kreatif:', error); console.error('Error loading program kreatif:', error);
@@ -67,12 +77,49 @@ function EditProgramKreatifDesa() {
loadProgramKreatif(); loadProgramKreatif();
}, [params?.id]); }, [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 = const handleChange =
(field: keyof FormProgramKreatif) => (field: keyof FormProgramKreatif) =>
(value: string) => { (value: string) => {
setFormData((prev) => ({ ...prev, [field]: value })); 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 () => { const handleSubmit = async () => {
try { try {
stateProgramKreatif.update.form = { stateProgramKreatif.update.form = {
@@ -82,6 +129,11 @@ function EditProgramKreatifDesa() {
icon: formData.icon.trim(), icon: formData.icon.trim(),
}; };
await stateProgramKreatif.update.submit(); await stateProgramKreatif.update.submit();
// Reset isDataChanged agar tidak muncul konfirmasi setelah save
setOriginalData(formData);
setIsDataChanged(false);
router.push('/admin/inovasi/program-kreatif-desa'); router.push('/admin/inovasi/program-kreatif-desa');
} catch (error) { } catch (error) {
console.error('Error updating program kreatif:', error); console.error('Error updating program kreatif:', error);
@@ -92,16 +144,14 @@ function EditProgramKreatifDesa() {
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
<Group mb="md"> <Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button <Button
variant="subtle" variant="subtle"
onClick={() => router.back()} onClick={handleBackClick}
p="xs" p="xs"
radius="md" radius="md"
> >
<IconArrowBack color={colors['blue-button']} size={24} /> <IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Tooltip>
<Title order={4} ml="sm" c="dark"> <Title order={4} ml="sm" c="dark">
Edit Program Kreatif Desa Edit Program Kreatif Desa
</Title> </Title>

View File

@@ -32,11 +32,15 @@ function CreateProgramKreatifDesa() {
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
await stateCreate.create.create(); const success = await stateCreate.create.create();
if (success) {
resetForm(); resetForm();
router.push("/admin/inovasi/program-kreatif-desa"); router.push("/admin/inovasi/program-kreatif-desa");
}
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Tombol kembali */} {/* Tombol kembali */}

View File

@@ -1,3 +1,4 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor'; import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import laporanPublikState from '@/app/admin/(dashboard)/_state/keamanan/laporan-publik'; import laporanPublikState from '@/app/admin/(dashboard)/_state/keamanan/laporan-publik';
@@ -52,15 +53,14 @@ function EditLaporanPublik() {
try { try {
const data = await stateLaporan.edit.load(id); const data = await stateLaporan.edit.load(id);
if (data) { if (data) {
setFormData((prev) => ({ setFormData({
...prev, judul: data.judul ?? '',
judul: data.judul ?? prev.judul, lokasi: data.lokasi ?? '',
lokasi: data.lokasi ?? prev.lokasi, tanggalWaktu: data.tanggalWaktu ?? '',
tanggalWaktu: data.tanggalWaktu ?? prev.tanggalWaktu, status: (data.status as Status) ?? 'Proses',
status: (data.status as Status) ?? prev.status, penanganan: data.penanganan?.[0]?.deskripsi ?? '',
penanganan: data.penanganan?.[0]?.deskripsi ?? prev.penanganan, kronologi: data.kronologi ?? '',
kronologi: data.kronologi ?? prev.kronologi, });
}));
} }
} catch (error) { } catch (error) {
console.error("Error loading laporan publik:", error); console.error("Error loading laporan publik:", error);
@@ -69,7 +69,8 @@ function EditLaporanPublik() {
}; };
loadLaporanPublik(); loadLaporanPublik();
}, [params?.id, stateLaporan.edit]); }, [params?.id]);
const handleChange = (field: string, value: string | Status) => { const handleChange = (field: string, value: string | Status) => {

View File

@@ -1,5 +1,4 @@
'use client' 'use client'
import colors from '@/con/colors';
import { Box, Button, Grid, GridCol, Paper, Skeleton, Stack, Text, Title } from '@mantine/core'; import { Box, Button, Grid, GridCol, Paper, Skeleton, Stack, Text, Title } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconEdit } from '@tabler/icons-react'; import { IconEdit } from '@tabler/icons-react';
@@ -26,10 +25,10 @@ function Page() {
} }
return ( return (
<Box p={{ base: 'md', md: 'xl' }}> <Box p="md">
<Paper withBorder radius="md" p={{ base: 'md', md: 'lg' }} bg={colors['white-1']}> <Paper withBorder p={{ base: 'md', md: 'lg' }} radius="md">
{/* Header */} {/* Header */}
<Grid align="center" mb="lg"> <Grid align="center" mb={{ base: 'md', md: 'lg' }}>
<GridCol span={{ base: 12, md: 11 }}> <GridCol span={{ base: 12, md: 11 }}>
<Title order={3} fw={600} c="dark"> <Title order={3} fw={600} c="dark">
Preview Bentuk Konservasi Berdasarkan Adat Preview Bentuk Konservasi Berdasarkan Adat
@@ -55,8 +54,8 @@ function Page() {
{/* Konten */} {/* Konten */}
<Stack gap="md"> <Stack gap="md">
<Paper radius="md" p={{ base: 'md', md: 'xl' }} bg={colors['BG-trans']} shadow="sm"> <Paper p={{ base: 'md', md: 'xl' }} bg="#ECEEF8" radius="md">
<Box mb="md"> <Box mb="md" px={{ base: 0, md: 20 }}>
<Text <Text
fz={{ base: 'xl', md: '2xl' }} fz={{ base: 'xl', md: '2xl' }}
fw={600} fw={600}
@@ -67,7 +66,7 @@ function Page() {
style={{ wordBreak: "break-word", whiteSpace: "normal" }} style={{ wordBreak: "break-word", whiteSpace: "normal" }}
/> />
</Box> </Box>
<Box> <Box px={{ base: 0, md: 20 }}>
<Text <Text
fz={{ base: 'md', md: 'lg' }} fz={{ base: 'md', md: 'lg' }}
ta="justify" ta="justify"

View File

@@ -1,10 +1,10 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' '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 { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react'; 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 }) { function LayoutTabsPengelolaanSampahBankSampah({ children }: { children: React.ReactNode }) {
const router = useRouter(); const router = useRouter();
@@ -16,14 +16,12 @@ function LayoutTabsPengelolaanSampahBankSampah({ children }: { children: React.R
value: "listpengelolaansampahbanksampah", value: "listpengelolaansampahbanksampah",
href: "/admin/lingkungan/pengelolaan-sampah-bank-sampah/list-pengelolaan-sampah-bank-sampah", href: "/admin/lingkungan/pengelolaan-sampah-bank-sampah/list-pengelolaan-sampah-bank-sampah",
icon: <IconTrash size={18} stroke={1.8} />, icon: <IconTrash size={18} stroke={1.8} />,
tooltip: "Kelola data pengelolaan sampah bank sampah",
}, },
{ {
label: "Keterangan Bank Sampah Terdekat", label: "Keterangan Bank Sampah Terdekat",
value: "keteranganbanksampahterdekat", value: "keteranganbanksampahterdekat",
href: "/admin/lingkungan/pengelolaan-sampah-bank-sampah/keterangan-bank-sampah-terdekat", href: "/admin/lingkungan/pengelolaan-sampah-bank-sampah/keterangan-bank-sampah-terdekat",
icon: <IconRecycle size={18} stroke={1.8} />, 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) => ( {tabs.map((tab, i) => (
<Tooltip
key={i}
label={tab.tooltip}
position="bottom"
withArrow
transitionProps={{ transition: 'pop', duration: 200 }}
>
<TabsTab <TabsTab
key={i}
value={tab.value} value={tab.value}
leftSection={tab.icon} leftSection={tab.icon}
style={{ style={{
@@ -92,7 +84,6 @@ function LayoutTabsPengelolaanSampahBankSampah({ children }: { children: React.R
> >
{tab.label} {tab.label}
</TabsTab> </TabsTab>
</Tooltip>
))} ))}
</TabsList> </TabsList>
</ScrollArea> </ScrollArea>

View File

@@ -65,26 +65,32 @@ function ListDataPerpustakaan({ search }: { search: string }) {
<Table striped highlightOnHover withRowBorders style={{ minWidth: '700px' }}> <Table striped highlightOnHover withRowBorders style={{ minWidth: '700px' }}>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh>No</TableTh> <TableTh style={{ width: '5%' }}>No</TableTh>
<TableTh>Judul</TableTh> <TableTh style={{ width: '25%' }}>Judul</TableTh>
<TableTh>Kategori</TableTh> <TableTh style={{ width: '25%' }}>Kategori</TableTh>
<TableTh>Detail</TableTh> <TableTh style={{ width: '23%' }}>Deskripsi</TableTh>
<TableTh style={{ width: '22%' }}>Detail</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{filteredData.length > 0 ? ( {filteredData.length > 0 ? (
filteredData.map((item, index) => ( filteredData.map((item, index) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd> <TableTd style={{ width: '5%' }}>
<Text truncate fz="sm">{index + 1}</Text> <Text truncate fz="sm">{index + 1}</Text>
</TableTd> </TableTd>
<TableTd> <TableTd style={{ width: '20%' }}>
<Text truncate fz="sm">{item.judul}</Text> <Text truncate fz="sm">{item.judul}</Text>
</TableTd> </TableTd>
<TableTd> <TableTd style={{ width: '20%' }}>
<Text truncate fz="sm">{item.kategori.name}</Text> <Text truncate fz="sm">{item.kategori.name}</Text>
</TableTd> </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 <Button
variant="light" variant="light"
color="blue" color="blue"

View File

@@ -12,6 +12,7 @@ import BackButton from '../layanan/_com/BackButto';
function Page() { function Page() {
const router = useTransitionRouter() const router = useTransitionRouter()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [hoveredId, setHoveredId] = useState<string | null>(null)
const state = useProxy(potensiDesaState) const state = useProxy(potensiDesaState)
useEffect(() => { useEffect(() => {
@@ -88,20 +89,55 @@ function Page() {
src={v.image?.link || ''} src={v.image?.link || ''}
h={360} h={360}
radius="xl" 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 <Box
pos="absolute" pos="absolute"
inset={0} 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"> <Stack justify="space-between" h="100%" gap="md" p="lg" pos="relative">
{/* Kategori badge - always visible */}
<Group> <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> <Text fz="sm" fw={600}>{v.kategori?.nama}</Text>
</Paper> </Paper>
</Group> </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 <Text
fw={800} fw={800}
c="white" c="white"
@@ -113,7 +149,17 @@ function Page() {
{v.name} {v.name}
</Text> </Text>
</Box> </Box>
<Group justify="center">
{/* 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 <Button
radius="xl" radius="xl"
size="md" size="md"

View File

@@ -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;

View File

@@ -1,17 +1,19 @@
'use client' 'use client'
import colors from '@/con/colors'; 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 React, { useState } from 'react';
import BackButton from '../../desa/layanan/_com/BackButto'; import BackButton from '../../desa/layanan/_com/BackButto';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import desaDigitalState from '@/app/admin/(dashboard)/_state/inovasi/desa-digital'; import desaDigitalState from '@/app/admin/(dashboard)/_state/inovasi/desa-digital';
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks'; import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { IconSearch } from '@tabler/icons-react'; import { IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
function Page() { function Page() {
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [debouncedSearch] = useDebouncedValue(search, 500); // 500ms delay const [debouncedSearch] = useDebouncedValue(search, 500); // 500ms delay
const state = useProxy(desaDigitalState) const state = useProxy(desaDigitalState)
const router = useRouter()
const { const {
data, data,
page, page,
@@ -70,12 +72,62 @@ function Page() {
> >
{filteredData.map((v, k) => { {filteredData.map((v, k) => {
return ( return (
<Paper p={'xl'} key={k}> <Paper
<Image src={v.image.link? v.image.link : ''} pb={10} radius={10} alt='' loading="lazy"/> 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> <Text fz={'h3'} fw={'bold'} c={colors['blue-button']}>{v.name}</Text>
<Box> <Box>
<Text fz={"md"} style={{wordBreak: "break-word", whiteSpace: "normal"}} dangerouslySetInnerHTML={{ __html: v.deskripsi }} /> <Text lineClamp={3} truncate="end" fz={"md"} style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: v.deskripsi }} />
</Box> </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> </Paper>
) )
})} })}

View File

@@ -1,16 +1,16 @@
'use client' '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 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() { function Page() {
const stateProgramKreatif = useProxy(programKreatifState); const stateProgramKreatif = useProxy(programKreatifState);
const router = useRouter();
const params = useParams(); const params = useParams();
useShallowEffect(() => { useShallowEffect(() => {
@@ -31,14 +31,7 @@ function Page() {
<Box px={{ base: 'md', md: 100 }} py="md"> <Box px={{ base: 'md', md: 100 }} py="md">
{/* Tombol Kembali */} {/* Tombol Kembali */}
<Box mb="md"> <Box mb="md">
<Text <BackButton/>
c={colors['blue-button']}
fw="bold"
style={{ cursor: 'pointer' }}
onClick={() => router.back()}
>
&larr; Kembali
</Text>
</Box> </Box>
{/* Konten Utama */} {/* Konten Utama */}

View File

@@ -117,7 +117,7 @@ function Page() {
)} )}
</Center> </Center>
<Text ta={'center'} fz={'h3'} fw={'bold'} c={colors['blue-button']}>{v.name}</Text> <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> <Center>
<Button onClick={() => router.push(`/darmasaba/inovasi/program-kreatif-desa/${v.id}`)} bg={colors['blue-button']}>Selengkapnya</Button> <Button onClick={() => router.push(`/darmasaba/inovasi/program-kreatif-desa/${v.id}`)} bg={colors['blue-button']}>Selengkapnya</Button>
</Center> </Center>

View File

@@ -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 { 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() { if (!data) {
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) {
return ( return (
<Center> <Stack py={20}>
<Skeleton height={500} /> <Skeleton height={400} radius="md" />
</Center> <Skeleton height={20} width="70%" />
<Skeleton height={15} width="50%" />
<Skeleton height={300} radius="md" />
</Stack>
); );
} }
if (!state.findUnique.data) {
return ( return (
<Center> <Box py={30}>
<Text>Data tidak ditemukan</Text> {/* Header Gambar */}
</Center>
);
}
{/* 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>
return ( {/* Meta Info */}
<Stack pos={"relative"} bg={colors.Bg} py={"xl"} gap={"22"} px={{ base: "md", md: 0 }}> <Group gap="xl" c="dimmed">
<Box px={{ base: "md", md: 100 }}><BackButton /></Box> <Group gap={6}>
<Container w={{ base: "100%", md: "50%" }} > <IconCalendar size={18} />
<Box pb={20}> <Text fz="sm">
<Text ta={"center"} fz={"2.4rem"} c={colors["blue-button"]} fw={"bold"}> {data.tanggal ? new Date(data.tanggal).toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' }) : '-'}
{state.findUnique.data?.judul} </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>
<Text <Text
ta={"center"} fz="md"
fw={"bold"} c="dimmed"
fz={"1.5rem"} dangerouslySetInnerHTML={{ __html: data.deskripsiSingkat }}
> />
Informasi Kegiatan Gotong Royong </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> </Text>
</Box> </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 || '' }} />
</Stack> </Stack>
</Paper>
</Box> </Box>
</Stack>
); );
} }
export default Page; export default DetailKegiatanDesaUser;

View File

@@ -2,11 +2,9 @@
'use client'; 'use client';
import penghargaanState from "@/app/admin/(dashboard)/_state/desa/penghargaan"; import penghargaanState from "@/app/admin/(dashboard)/_state/desa/penghargaan";
import colors from "@/con/colors"; import colors from "@/con/colors";
import { Carousel } from "@mantine/carousel"; import { Box, Button, Container, Group, Paper, Skeleton, Stack, Text } from "@mantine/core";
import { Box, Button, Container, Group, Paper, Skeleton, Stack, Text, useMantineTheme } from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks"; import { useMediaQuery } from "@mantine/hooks";
import { IconArrowRight, IconAward } from "@tabler/icons-react"; import { IconArrowRight, IconAward } from "@tabler/icons-react";
import Autoplay from "embla-carousel-autoplay";
import { useTransitionRouter } from "next-view-transitions"; import { useTransitionRouter } from "next-view-transitions";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useProxy } from "valtio/utils"; import { useProxy } from "valtio/utils";
@@ -19,7 +17,6 @@ export default function Page() {
<BackButton /> <BackButton />
</Box> </Box>
<Container w={{ base: "100%", md: "90%", lg: "60%" }}> <Container w={{ base: "100%", md: "90%", lg: "60%" }}>
<Stack align="center" gap="sm"> <Stack align="center" gap="sm">
<Group gap="xs"> <Group gap="xs">
<IconAward size={40} color={colors["blue-button"]} /> <IconAward size={40} color={colors["blue-button"]} />
@@ -30,21 +27,35 @@ export default function Page() {
<Text fz="lg" c="dimmed" ta="center"> <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. Desa Darmasaba berhasil meraih beragam penghargaan bergengsi yang mencerminkan dedikasi dan kerja keras masyarakat dalam membangun desa yang maju dan berkelanjutan.
</Text> </Text>
<Slider />
</Stack> </Stack>
</Container> </Container>
<Slider />
</Stack> </Stack>
); );
} }
function Slider() { function Slider() {
const theme = useMantineTheme(); const mobile = useMediaQuery("(max-width: 768px)", false);
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 state = useProxy(penghargaanState); const state = useProxy(penghargaanState);
const router = useTransitionRouter(); 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(() => { useEffect(() => {
state.findMany.load(); state.findMany.load();
}, []); }, []);
@@ -52,12 +63,128 @@ function Slider() {
const data = state.findMany.data || []; const data = state.findMany.data || [];
const loading = state.findMany.loading; 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) { if (loading) {
return ( return (
<Group justify="center" py="xl" gap="md"> <Group justify="center" py="xl" gap="md">
<Skeleton w={300} h={200} radius="lg" /> <Skeleton w={300} h={400} radius="lg" />
<Skeleton w={300} h={200} radius="lg" visibleFrom="sm" /> <Skeleton w={300} h={400} radius="lg" visibleFrom="sm" />
<Skeleton w={300} h={200} radius="lg" visibleFrom="md" /> <Skeleton w={300} h={400} radius="lg" visibleFrom="md" />
</Group> </Group>
); );
} }
@@ -73,138 +200,129 @@ function Slider() {
); );
} }
const slides = data.map((item) => ( return (
<Carousel.Slide key={item.id}> <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",
}}
>
{/* 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",
}}
>
{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 <Paper
radius="lg" radius="lg"
shadow="md" shadow="md"
pos="relative" pos="relative"
style={{ style={{
height: "100%", height: mobile ? "380px" : "450px",
backgroundImage: `url(${item.image?.link})`, backgroundImage: `url(${item.image?.link})`,
backgroundSize: "cover", backgroundSize: "cover",
backgroundPosition: "center", backgroundPosition: "center",
transition: "transform 0.3s ease, box-shadow 0.3s ease", transition: "transform 0.3s ease, box-shadow 0.3s ease",
overflow: "hidden",
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
e.currentTarget.style.transform = "translateY(-4px)"; e.currentTarget.style.transform = "translateY(-8px) scale(1.02)";
e.currentTarget.style.boxShadow = "0 8px 20px rgba(0,0,0,0.2)"; e.currentTarget.style.boxShadow = "0 12px 28px rgba(0,0,0,0.25)";
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
e.currentTarget.style.transform = "translateY(0)"; e.currentTarget.style.transform = "translateY(0) scale(1)";
e.currentTarget.style.boxShadow = "none"; e.currentTarget.style.boxShadow = "0 4px 12px rgba(0,0,0,0.15)";
}} }}
> >
<Box <Box
pos="absolute" pos="absolute"
inset={0} inset={0}
bg="linear-gradient(to top, rgba(0,0,0,0.8), rgba(0,0,0,0.2))" bg="linear-gradient(to top, rgba(0,0,0,0.85), rgba(0,0,0,0.3))"
style={{ borderRadius: 16 }} style={{ borderRadius: 16 }}
/> />
<Stack justify="flex-end" h="100%" gap="sm" p="lg" pos="relative"> <Stack justify="flex-end" h="100%" gap="sm" p="lg" pos="relative">
<Text <Text
fz={{ base: "md", sm: "lg", md: "xl" }} fz={{ base: "lg", sm: "xl", md: "1.5rem" }}
fw={700} fw={700}
ta="center" ta="center"
c="white" c="white"
lineClamp={3} lineClamp={3}
style={{ textShadow: "0 2px 4px rgba(0,0,0,0.6)" }} style={{ textShadow: "0 2px 8px rgba(0,0,0,0.8)" }}
> >
{item.name} {item.name}
</Text> </Text>
<Group justify="center"> <Group justify="center">
<Button <Button
onClick={() => onClick={() => router.push(`/darmasaba/penghargaan/${item.id}`)}
router.push(`/darmasaba/penghargaan/${item.id}`)
}
size="md" size="md"
radius="xl" radius="xl"
rightSection={<IconArrowRight size={18} />} rightSection={<IconArrowRight size={18} />}
variant="gradient" variant="gradient"
gradient={{ from: "#1C6EA4", to: "#69BFF8" }} 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 Lihat Detail
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>
</Carousel.Slide> </Box>
)); ))}
</Box>
return (
<Box
pos="relative"
w="100%"
mx="auto"
px={{ base: "md", sm: "xl", md: "2rem", lg: "3rem" }}
style={{
maxWidth: 1300,
}}
>
<Carousel
py="xl"
w="100%"
h={{ base: 320, sm: 380, md: 420, lg: 450 }}
slideSize={{
base: "100%", // Mobile: 1
sm: "50%", // Tablet kecil (≥768): 2
md: "50%", // 1024px: tetap 2
lg: "33.333%", // Desktop besar: 3
}}
slideGap={{ base: "md", sm: "md", md: "lg" }}
loop
align="start"
slidesToScroll={mobile ? 1 : tablet ? 2 : 3}
plugins={[autoplay.current]}
onMouseEnter={autoplay.current.stop}
onMouseLeave={autoplay.current.reset}
withControls={data.length > 3}
draggable={data.length > 1}
styles={{
root: {
position: "relative",
},
viewport: {
overflow: "hidden",
},
container: {
alignItems: "stretch",
},
control: {
zIndex: 20,
backgroundColor: "rgba(255,255,255,0.95)",
color: colors["blue-button"],
border: `2px solid ${colors["blue-button"]}`,
width: 46,
height: 46,
borderRadius: "50%",
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
transition: "all 0.2s ease",
'&:hover': {
backgroundColor: colors["blue-button"],
color: "white",
transform: "scale(1.1)",
},
'&[data-inactive]': {
opacity: 0,
cursor: 'default',
},
},
controls: {
position: "absolute",
top: mobile ? "70%" : tablet ? "65%" : "60%",
transform: "translateY(-50%)",
width: mobile ? "100%" : tablet ? "calc(100% + 60px)" : "calc(100% + 100px)",
left: mobile ? "0" : tablet ? "-30px" : "-50px",
right: mobile ? "0" : tablet ? "-30px" : "-50px",
padding: "0",
justifyContent: "space-between",
zIndex: 30,
},
}}
>
{slides}
</Carousel>
</Box> </Box>
); );
} }

View File

@@ -2,7 +2,6 @@
"use client"; "use client";
import stateLayananDesa from "@/app/admin/(dashboard)/_state/desa/layananDesa"; import stateLayananDesa from "@/app/admin/(dashboard)/_state/desa/layananDesa";
import colors from "@/con/colors"; import colors from "@/con/colors";
import { Carousel } from "@mantine/carousel";
import { import {
Box, Box,
Button, Button,
@@ -13,10 +12,8 @@ import {
Skeleton, Skeleton,
Stack, Stack,
Text, Text,
useMantineTheme
} from "@mantine/core"; } from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks"; import { useMediaQuery } from "@mantine/hooks";
import Autoplay from "embla-carousel-autoplay";
import _ from "lodash"; import _ from "lodash";
import { useTransitionRouter } from "next-view-transitions"; import { useTransitionRouter } from "next-view-transitions";
import Link from "next/link"; import Link from "next/link";
@@ -27,20 +24,30 @@ const textHeading = {
title: "Layanan", 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!", 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() {
function Layanan() {
return ( return (
<Stack pos={"relative"} bg={colors.grey[1]} gap={"xl"} py={"md"}> <Stack pos={"relative"} bg={colors.grey[1]} gap={"xl"} py={"md"}>
<Container w={{ base: "100%", md: "80%" }} p={"md"} > <Container w={{ base: "100%", md: "80%" }} p={"md"}>
<Stack align="center" gap={"0"}> <Stack align="center" gap={"0"}>
<Text fw={"bold"} c={colors["blue-button"]} fz={{ base: "1.8rem", md: "3.4rem" }}> <Text
fw={"bold"}
c={colors["blue-button"]}
fz={{ base: "1.8rem", md: "3.4rem" }}
>
{textHeading.title} {textHeading.title}
</Text> </Text>
<Text ta={"center"} fz={{ base: "1rem", md: "1.3rem" }}> <Text ta={"center"} fz={{ base: "1rem", md: "1.3rem" }}>
{textHeading.des} {textHeading.des}
</Text> </Text>
<Box p={"md"}> <Box p={"md"}>
<Button component={Link} href={"/darmasaba/desa/layanan"} variant="filled" bg={colors["blue-button"]} radius={100}> <Button
component={Link}
href={"/darmasaba/desa/layanan"}
variant="filled"
bg={colors["blue-button"]}
radius={100}
>
Detail Detail
</Button> </Button>
</Box> </Box>
@@ -53,38 +60,221 @@ function Layanan() {
} }
const height = 720; 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()
useEffect(()=> { function Slider() {
const state = useProxy(stateLayananDesa);
const [loading, setLoading] = useState(false);
const mobile = useMediaQuery("(max-width: 768px)", false);
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.3; // slower speed on hover
useEffect(() => {
const loadData = async () => { const loadData = async () => {
try { try {
setLoading(true); setLoading(true);
await state.suratKeterangan.findMany.load() await state.suratKeterangan.findMany.load();
} catch (error) { } catch (error) {
console.error('Error loading data:', error); console.error("Error loading data:", error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
};
loadData();
}, []);
const data = state.suratKeterangan.findMany.data || [];
// Duplicate slides for seamless infinite loop
// We need at least 3x the data for smooth infinite scrolling
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;
} }
loadData()
}, [])
const data = (state.suratKeterangan.findMany.data || []).slice(0, 8); if (scrollPositionRef.current <= 0) {
scrollPositionRef.current += slideWidth * originalDataLength;
}
const slides = data.map((item) => ( container.scrollLeft = scrollPositionRef.current;
<Carousel.Slide key={item.id} > } else {
<Paper h={height} pos={"relative"} style={{ // 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",
}}
>
<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})`, backgroundImage: `url(${item.image?.link})`,
backgroundSize: "cover", backgroundSize: "cover",
backgroundPosition: "center", backgroundPosition: "center",
backgroundRepeat: "no-repeat", backgroundRepeat: "no-repeat",
}}> borderRadius: 8,
overflow: "hidden",
}}
>
<Box <Box
style={{ style={{
borderRadius: 8, borderRadius: 8,
@@ -95,10 +285,15 @@ function Slider() {
h={"100%"} h={"100%"}
bg={colors.trans.dark[2]} bg={colors.trans.dark[2]}
/> />
<Stack justify="space-between" h={"100%"} gap={0} p={"lg"} pos={"relative"} > <Stack
justify="space-between"
h={"100%"}
gap={0}
p={"lg"}
pos={"relative"}
>
<Box p={"lg"}> <Box p={"lg"}>
<Text <Text
fw={"bold"} fw={"bold"}
c={"white"} c={"white"}
size={"3.5rem"} size={"3.5rem"}
@@ -110,37 +305,25 @@ function Slider() {
</Text> </Text>
</Box> </Box>
<Group justify="center"> <Group justify="center">
<Button onClick={()=> router.push(`/darmasaba/desa/layanan/${item.id}`)} px={46} radius={"100"} size="md" bg={colors["blue-button"]}> <Button
onClick={() =>
router.push(`/darmasaba/desa/layanan/${item.id}`)
}
px={46}
radius={"100"}
size="md"
bg={colors["blue-button"]}
>
Detail Detail
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>
</Carousel.Slide> </Box>
)); ))}
</Box>
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}
>
{slides}
</Carousel>
)}
</Box> </Box>
); );
} }
export default Layanan; export default Layanan;

View 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;
}