Compare commits

...

3 Commits

Author SHA1 Message Date
fb57698dc9 Add Menu Musik
Add News Reader for Difable
Add Running text news / announcement
2025-11-04 15:08:48 +08:00
d128313e71 Fix QC Keano FrontEnd
Fix QC Kak Ayu Admin 29 Okt
2025-11-03 17:36:00 +08:00
7b4bb1e58e QC Kak Inno FrontEnd Done
QC Kak Ayu FrontEnd Done
QC Keano 31 Okt
2025-11-03 10:28:03 +08:00
65 changed files with 2529 additions and 656 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -19,6 +19,7 @@
"@elysiajs/static": "^1.3.0",
"@elysiajs/stream": "^1.1.0",
"@elysiajs/swagger": "^1.2.0",
"@emotion/react": "^11.14.0",
"@mantine/carousel": "^7.16.2",
"@mantine/charts": "^7.17.1",
"@mantine/core": "^7.17.4",
@@ -26,6 +27,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 +57,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 +83,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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => {

View File

@@ -12,8 +12,7 @@ import {
Stack,
Text,
TextInput,
Title,
Tooltip,
Title
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
@@ -96,11 +95,9 @@ function EditMediaSosial() {
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={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Media Sosial
</Title>

View File

@@ -11,8 +11,7 @@ import {
Stack,
Text,
TextInput,
Title,
Tooltip,
Title
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
@@ -69,11 +68,9 @@ export default function CreateMediaSosial() {
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={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Tambah Media Sosial
</Title>

View File

@@ -1,6 +1,6 @@
'use client'
import colors from '@/con/colors';
import { Box, Button, Center, Group, Image, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title, Tooltip } from '@mantine/core';
import { Box, Button, Center, Group, Image, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
@@ -56,11 +56,9 @@ function ListMediaSosial({ search }: { search: string }) {
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
<Group justify="space-between" mb="md">
<Title order={4}>Daftar Media Sosial</Title>
<Tooltip label="Tambah Media Sosial" withArrow>
<Button leftSection={<IconPlus size={18} />} color="blue" variant="light" onClick={() => router.push('/admin/landing-page/profile/media-sosial/create')}>
Tambah Baru
</Button>
</Tooltip>
</Group>
<Box style={{ overflowX: "auto" }}>
<Table highlightOnHover>

View File

@@ -3,7 +3,7 @@
import colors from '@/con/colors';
import {
Alert, Box, Button, Center, Group, Image,
Paper, Stack, Text, TextInput, Title, Tooltip
Paper, Stack, Text, TextInput, Title
} from '@mantine/core';
import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils';
@@ -177,11 +177,9 @@ function EditPejabatDesa() {
<Box>
<Stack gap="xs">
<Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={handleBack} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Button variant="subtle" onClick={handleBack} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Pejabat Desa
</Title>

View File

@@ -13,8 +13,7 @@ import {
Stack,
Text,
TextInput,
Title,
Tooltip,
Title
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
@@ -104,11 +103,9 @@ function EditProgramInovasi() {
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={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Program Inovasi
</Title>

View File

@@ -12,8 +12,7 @@ import {
Stack,
Text,
TextInput,
Title,
Tooltip
Title
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
@@ -70,11 +69,9 @@ function CreateProgramInovasi() {
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={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Tambah Program Inovasi
</Title>

View File

@@ -51,8 +51,7 @@ function ListProgramInovasi({ search }: { search: string }) {
<Paper bg={colors['white-1']} withBorder p="lg" radius="md" shadow="sm">
<Group justify='space-between'>
<Title order={4}>Daftar Program Inovasi</Title>
<Tooltip label="Tambah Program Inovasi" withArrow>
<Button
<Button
color="blue"
leftSection={<IconPlus size={18} />}
variant="light"
@@ -61,7 +60,6 @@ function ListProgramInovasi({ search }: { search: string }) {
>
Tambah Program
</Button>
</Tooltip>
</Group>
<Box style={{ overflowX: 'auto' }}>
<Table highlightOnHover striped verticalSpacing="sm">

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,9 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita';
import NewsReader from '@/app/darmasaba/_com/NewsReader';
import colors from '@/con/colors';
import { Box, Center, Container, Image, Skeleton, Stack, Text } from '@mantine/core';
import { Box, Center, Container, Group, Image, Skeleton, Stack, Text } from '@mantine/core';
import { useParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils';
@@ -49,6 +50,9 @@ function Page() {
return (
<Stack pos={"relative"} bg={colors.Bg} py={"xl"} gap={"22"} px={{ base: "md", md: 0 }}>
<Group px={{ base: "md", md: 100 }}>
<NewsReader />
</Group>
<Container w={{ base: "100%", md: "50%" }} >
<Box pb={20}>
<Text ta={"center"} fz={"2.4rem"} c={colors["blue-button"]} fw={"bold"}>
@@ -67,6 +71,7 @@ function Page() {
<Box px={{ base: "md", md: 100 }}>
<Stack gap={"xs"}>
<Text
id='news-content'
py={20}
fz={{ base: "sm", md: "lg" }}
lh={{ base: 1.6, md: 1.8 }} // ✅ line-height lebih rapat dan responsif

View File

@@ -6,6 +6,7 @@ import { useShallowEffect } from '@mantine/hooks';
import { useParams } from 'next/navigation';
import { useProxy } from 'valtio/utils';
import BackButton from '../../../layanan/_com/BackButto';
import NewsReader from '@/app/darmasaba/_com/NewsReader';
function Page() {
const detail = useProxy(stateDesaPengumuman.pengumuman.findUnique)
@@ -30,6 +31,9 @@ function Page() {
<BackButton />
</Box>
<Container size="lg" px="md">
<Group>
<NewsReader />
</Group>
<Stack gap="xs" >
<Group justify={"space-between"} align={"center"}>
<Text fz={{ base: "2rem", md: "2rem" }} c={colors["blue-button"]} fw="bold" >
@@ -42,7 +46,7 @@ function Page() {
</Group>
</Group>
<Paper bg={colors["white-1"]} p="md">
<Text fz={"md"} style={{wordBreak: "break-word", whiteSpace: "normal"}} dangerouslySetInnerHTML={{ __html: detail.data?.content }} />
<Text id='news-content' fz={"md"} style={{wordBreak: "break-word", whiteSpace: "normal"}} dangerouslySetInnerHTML={{ __html: detail.data?.content }} />
<Text fz={"md"} c={colors["blue-button"]} fw="bold" >
{new Date(detail.data?.createdAt).toLocaleDateString('id-ID', {
weekday: 'long',

View File

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

View File

@@ -11,6 +11,7 @@ import ProfilPerbekel from './ui/profilPerbekel';
import MotoDesa from './ui/motoDesa';
import SemuaPerbekel from './ui/semuaPerbekel';
import ScrollToTopButton from '@/app/darmasaba/_com/scrollToTopButton';
import StrukturPerangkatDesa from './struktur-perangkat-desa/page';
function Page() {
return (
@@ -26,16 +27,17 @@ function Page() {
</Text>
</Stack>
</Container>
<Box px={{ base: "md", md: 100 }}>
<Stack px={{ base: "md", md: 100 }} gap={"xl"}>
<ProfileDesa />
<SejarahDesa />
<VisimisiDesa />
<LambangDesa />
<MaskotDesa />
<ProfilPerbekel />
<StrukturPerangkatDesa/>
<MotoDesa />
<SemuaPerbekel />
</Box>
</Stack>
</Stack>
{/* Tombol Scroll ke Atas */}
<ScrollToTopButton />

View File

@@ -0,0 +1,143 @@
'use client';
import stateStrukturPPID from '@/app/admin/(dashboard)/_state/ppid/struktur_ppid/struktur_PPID';
import colors from '@/con/colors';
import {
Box,
Divider,
Group,
Image,
Paper,
Skeleton,
Stack,
Text,
Title,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { useParams } from 'next/navigation';
import { useProxy } from 'valtio/utils';
import BackButton from '../../_com/BackButto';
function DetailPegawaiUser() {
const statePegawai = useProxy(stateStrukturPPID.pegawai);
const params = useParams();
useShallowEffect(() => {
stateStrukturPPID.posisiOrganisasi.findMany.load();
statePegawai.findUnique.load(params?.id as string);
}, []);
if (!statePegawai.findUnique.data) {
return (
<Stack py="lg">
<Skeleton height={500} radius="md" />
</Stack>
);
}
const data = statePegawai.findUnique.data;
return (
<Box px={{ base: 'md', md: 100 }} py="xl">
{/* Back button */}
<Group mb="lg" px={{ base: 'md', md: 100 }}>
<BackButton/>
</Group>
<Paper
w={{ base: '100%', md: '70%' }}
mx="auto"
p="xl"
radius="lg"
shadow="sm"
bg="white"
style={{
border: '1px solid #eaeaea',
}}
>
<Stack align="center" gap="md">
{/* Foto Profil */}
<Image
src={data.image?.link || '/placeholder-profile.png'}
alt={data.namaLengkap || 'Foto Profil'}
w={160}
h={160}
radius={100}
fit="cover"
style={{ border: `2px solid ${colors['blue-button']}` }}
loading="lazy"
/>
{/* Nama & Jabatan */}
<Stack align="center" gap={2}>
<Title order={3} fw={700} c={colors['blue-button']}>
{data.namaLengkap || '-'} {data.gelarAkademik || ''}
</Title>
<Text fz="sm" c="dimmed">
{data.posisi?.nama || 'Posisi tidak tersedia'}
</Text>
</Stack>
</Stack>
<Divider my="lg" />
{/* Informasi Detail */}
<Stack gap="md">
<InfoRow label="Email" value={data.email} />
<InfoRow label="Telepon" value={data.telepon} />
<InfoRow label="Alamat" value={data.alamat} multiline />
<InfoRow
label="Tanggal Masuk"
value={
data.tanggalMasuk
? new Date(data.tanggalMasuk).toLocaleDateString('id-ID', {
day: '2-digit',
month: 'long',
year: 'numeric',
})
: '-'
}
/>
<InfoRow
label="Status"
value={data.isActive ? 'Aktif' : 'Tidak Aktif'}
valueColor={data.isActive ? 'green' : 'red'}
/>
</Stack>
</Paper>
</Box>
);
}
/* Komponen kecil untuk menampilkan baris informasi */
function InfoRow({
label,
value,
valueColor,
multiline = false,
}: {
label: string;
value?: string | null;
valueColor?: string;
multiline?: boolean;
}) {
return (
<Box>
<Text fz="sm" fw={600} c="dark">
{label}
</Text>
<Text
fz="sm"
c={valueColor || 'dimmed'}
style={{
whiteSpace: multiline ? 'normal' : 'nowrap',
wordBreak: 'break-word',
}}
>
{value || '-'}
</Text>
</Box>
);
}
export default DetailPegawaiUser;

View File

@@ -0,0 +1,469 @@
/* eslint-disable react-hooks/exhaustive-deps */
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client'
import stateStrukturPPID from '@/app/admin/(dashboard)/_state/ppid/struktur_ppid/struktur_PPID'
import ScrollToTopButton from '@/app/darmasaba/_com/scrollToTopButton'
import colors from '@/con/colors'
import {
Box,
Button,
Card,
Center,
Group,
Image,
Loader,
Paper,
Stack,
Text,
TextInput,
Title,
Transition
} from '@mantine/core'
import {
IconArrowsMaximize,
IconArrowsMinimize,
IconRefresh,
IconSearch,
IconUsers,
IconZoomIn,
IconZoomOut,
} from '@tabler/icons-react'
import { debounce } from 'lodash'
import { useTransitionRouter } from 'next-view-transitions'
import { OrganizationChart } from 'primereact/organizationchart'
import { useEffect, useRef, useState } from 'react'
import { useProxy } from 'valtio/utils'
import './struktur.css'
import BackButton from '../_com/BackButto'
export default function StrukturPerangkatDesa() {
return (
<Box
style={{
minHeight: '100vh',
background: colors['Bg'],
color: '#E6F0FF',
paddingBottom: 48,
}}
>
<Box px={{ base: 'md', md: 100 }} py={"xl"}>
<BackButton />
<Stack align="center" gap="xl" mt="xl">
<Title
order={1}
ta="center"
c={colors['blue-button']}
fz={{ base: 28, md: 36, lg: 44 }}
>
Struktur Perangkat Desa
</Title>
<Text ta="center" c="black" maw={800}>
Gambaran visual peran dan pegawai yang ditugaskan. Arahkan kursor
untuk melihat detail atau klik node untuk fokus tampilan.
</Text>
</Stack>
<Box mt="lg">
<StrukturPerangkatDesaNode />
</Box>
</Box>
<ScrollToTopButton />
</Box>
)
}
function StrukturPerangkatDesaNode() {
const stateOrganisasi: any = useProxy(stateStrukturPPID.pegawai)
const router = useTransitionRouter()
const chartContainerRef = useRef<HTMLDivElement>(null)
const [scale, setScale] = useState(1)
const [isFullscreen, setFullscreen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
// debounce pencarian
const debouncedSearch = useRef(
debounce((value: string) => {
setSearchQuery(value)
}, 400)
).current
useEffect(() => {
void stateOrganisasi.findMany.load()
}, [])
const isLoading =
!stateOrganisasi.findMany.data && stateOrganisasi.findMany.loading !== false
if (isLoading) {
return (
<Center py={48}>
<Stack align="center" gap="sm">
<Loader size="lg" />
<Text fw={600}>Memuat struktur organisasi</Text>
<Text c="dimmed" size="sm">
Mengambil data pegawai dan posisi. Mohon tunggu sebentar.
</Text>
</Stack>
</Center>
)
}
const data = stateOrganisasi.findMany.data || []
if (data.length === 0) {
return (
<Center py={40}>
<Stack align="center" gap="md">
<Paper
radius="md"
p="xl"
style={{
width: 560,
background: 'rgba(28,110,164,0.2)',
border: `1px solid rgba(255,255,255,0.1)`,
textAlign: 'center',
}}
>
<Center>
<IconUsers size={56} />
</Center>
<Title order={3} mt="md">
Data pegawai belum tersedia
</Title>
<Text c="dimmed" mt="xs">
Belum ada data pegawai yang tercatat untuk PPID.
</Text>
<Group justify="center" mt="lg">
<Button
leftSection={<IconRefresh size={16} />}
variant="gradient"
gradient={{ from: 'indigo', to: 'cyan' }}
onClick={() => stateOrganisasi.findMany.load()}
>
Muat Ulang
</Button>
</Group>
</Paper>
</Stack>
</Center>
)
}
// 🧩 buat struktur organisasi
const posisiMap = new Map<string, any>()
const aktifPegawai = data.filter((p: any) => p.isActive)
for (const pegawai of aktifPegawai) {
const posisiId = pegawai.posisi.id
if (!posisiMap.has(posisiId)) {
posisiMap.set(posisiId, {
...pegawai.posisi,
pegawaiList: [],
children: [],
})
}
posisiMap.get(posisiId)!.pegawaiList.push(pegawai)
}
const root: any[] = []
posisiMap.forEach((posisi) => {
if (posisi.parentId) {
const parent = posisiMap.get(posisi.parentId)
if (parent) parent.children.push(posisi)
else root.push(posisi)
} else root.push(posisi)
})
const toOrgChartFormat = (node: any): any => {
const pegawai = node.pegawaiList?.[0]
return {
expanded: true,
data: {
id: pegawai?.id,
name: pegawai?.namaLengkap || 'Belum Ditugaskan',
title: node.nama || 'Tanpa Jabatan',
image: pegawai?.image?.link || '/img/default.png',
},
children: node.children?.map(toOrgChartFormat) || [],
}
}
let chartData = root.map(toOrgChartFormat)
// 🔍 filter by search
if (searchQuery) {
const filterNodes = (nodes: any[]): any[] =>
nodes
.map((n) => ({
...n,
children: filterNodes(n.children || []),
}))
.filter(
(n) =>
n.data.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
n.data.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
n.children.length > 0
)
chartData = filterNodes(chartData)
}
// 🎬 fullscreen & zoom control
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
chartContainerRef.current?.requestFullscreen()
setFullscreen(true)
} else {
document.exitFullscreen()
setFullscreen(false)
}
}
const handleZoomIn = () => setScale((s) => Math.min(s + 0.1, 2))
const handleZoomOut = () => setScale((s) => Math.max(s - 0.1, 0.5))
const resetZoom = () => setScale(1)
return (
<Stack align="center" mt="xl">
{/* 🔍 Controls */}
<Paper
shadow="xs"
p="md"
radius="md"
style={{
background: colors['blue-button']
}}
>
<Group gap="sm" wrap="wrap" justify="center">
<TextInput
placeholder="Cari nama atau jabatan..."
leftSection={<IconSearch size={16} />}
onChange={(e) => debouncedSearch(e.target.value)}
styles={{
input: {
minWidth: 250,
},
}}
/>
<Group gap="xs">
<Button
variant="light"
bg={colors['blue-button-2']}
size="sm"
onClick={handleZoomOut}
leftSection={<IconZoomOut size={16} />}
c={colors['blue-button']}
>
Zoom Out
</Button>
<Box
bg={colors['blue-button-2']}
c={colors['blue-button']}
px={16}
py={8}
style={{
fontSize: 14,
fontWeight: 700,
borderRadius: '8px',
minWidth: 70,
textAlign: 'center',
}}
>
{Math.round(scale * 100)}%
</Box>
<Button
bg={colors['blue-button-2']}
c={colors['blue-button']}
variant="light"
size="sm"
onClick={handleZoomIn}
leftSection={<IconZoomIn size={16} />}
>
Zoom In
</Button>
<Button
bg={colors['blue-button-2']}
c={colors['blue-button']}
variant="light"
size="sm"
onClick={resetZoom}
>
Reset
</Button>
<Button
bg={colors['blue-button-2']}
c={colors['blue-button']}
size="sm"
onClick={toggleFullscreen}
leftSection={
isFullscreen ? (
<IconArrowsMinimize size={16} />
) : (
<IconArrowsMaximize size={16} />
)
}
>
Fullscreen
</Button>
</Group>
</Group>
</Paper>
{/* 🧩 Chart Container */}
<Center style={{ width: '100%' }}>
<Box
ref={chartContainerRef}
style={{
overflowX: 'auto',
overflowY: 'auto',
width: '100%',
maxWidth: '100%',
padding: '32px 16px',
transition: 'transform 0.2s ease',
transform: `scale(${scale})`,
transformOrigin: 'center top',
}}
>
<OrganizationChart
value={chartData}
nodeTemplate={(node) => <NodeCard node={node} router={router} />}
className="p-organizationchart p-organizationchart-horizontal"
/>
</Box>
</Center>
</Stack>
)
}
function NodeCard({ node, router }: any) {
const imageSrc = node?.data?.image || '/img/default.png'
const name = node?.data?.name || 'Tanpa Nama'
const title = node?.data?.title || 'Tanpa Jabatan'
const hasId = Boolean(node?.data?.id)
return (
<Transition mounted transition="pop" duration={300}>
{(styles) => (
<Card
shadow="md"
radius="xl"
withBorder
style={{
...styles,
width: 240,
minHeight: 280,
padding: 20,
background: 'linear-gradient(135deg, rgba(28,110,164,0.15) 0%, rgba(255,255,255,0.95) 100%)',
borderColor: 'rgba(28, 110, 164, 0.3)',
borderWidth: 2,
transition: 'all 0.3s ease',
cursor: hasId ? 'pointer' : 'default',
}}
onMouseEnter={(e) => {
if (hasId) {
e.currentTarget.style.transform = 'translateY(-4px)'
e.currentTarget.style.boxShadow = '0 8px 24px rgba(28, 110, 164, 0.25)'
}
}}
onMouseLeave={(e) => {
if (hasId) {
e.currentTarget.style.transform = 'translateY(0)'
e.currentTarget.style.boxShadow = ''
}
}}
>
<Stack align="center" gap={12}>
{/* Photo */}
<Box
style={{
width: 96,
height: 96,
borderRadius: '50%',
overflow: 'hidden',
border: '3px solid rgba(28, 110, 164, 0.4)',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
background: 'white',
}}
>
<Image
src={imageSrc}
alt={name}
width={96}
height={96}
fit="cover"
loading="lazy"
style={{
objectFit: 'cover',
}}
/>
</Box>
{/* Name */}
<Text
fw={700}
size="sm"
ta="center"
c={colors['blue-button']}
lineClamp={2}
style={{
minHeight: 40,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
wordBreak: 'break-word',
lineHeight: 1.3,
}}
>
{name}
</Text>
{/* Title/Position */}
<Text
size="xs"
c="dimmed"
ta="center"
fw={500}
lineClamp={2}
style={{
minHeight: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
wordBreak: 'break-word',
lineHeight: 1.2,
}}
>
{title}
</Text>
{/* Detail Button */}
{hasId && (
<Button
variant="gradient"
gradient={{ from: 'blue', to: 'cyan' }}
size="xs"
fullWidth
mt={8}
radius="md"
onClick={() =>
router.push(`/darmasaba/desa/profile/struktur-perangkat-desa/${node.data.id}`)
}
style={{
height: 32,
fontWeight: 600,
}}
>
Lihat Detail
</Button>
)}
</Stack>
</Card>
)}
</Transition>
)
}

View File

@@ -0,0 +1,68 @@
/* ============================================
STRUKTUR ORGANISASI PPID - STYLING
============================================ */
/* Tabel chart selalu center */
.p-organizationchart-table {
margin: 0 auto !important;
}
/* Jarak vertikal antar level - lebih lega */
.p-organizationchart-line-down {
height: 32px !important;
}
/* Padding di dalam node - lebih rapi */
.p-organizationchart-node-content {
padding: 0 !important;
background: transparent !important;
border: none !important;
}
/* Garis connector antar node - lebih tebal dan jelas */
.p-organizationchart-line-down,
.p-organizationchart-line-left,
.p-organizationchart-line-right,
.p-organizationchart-line-top {
border-color: rgba(28, 110, 164, 0.4) !important;
border-width: 2px !important;
}
/* Garis horizontal */
.p-organizationchart-line-left,
.p-organizationchart-line-right {
border-top-width: 2px !important;
}
/* Jarak horizontal antar node - lebih proporsional */
.p-organizationchart-table > tbody > tr > td {
padding: 0 24px !important;
vertical-align: top !important;
}
/* Node container spacing */
.p-organizationchart-node {
padding: 8px !important;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.p-organizationchart-table > tbody > tr > td {
padding: 0 12px !important;
}
.p-organizationchart-line-down {
height: 24px !important;
}
}
/* Smooth transitions untuk zoom */
.p-organizationchart {
transition: transform 0.2s ease;
}
/* Fullscreen mode adjustments */
.p-organizationchart-table:fullscreen {
background: rgba(230, 240, 255, 0.98);
padding: 40px;
}

View File

@@ -24,7 +24,7 @@ function LambangDesa() {
}
return (
<Box pb={90}>
<Box>
<Stack align="center" gap="lg">
<Box pb="lg">
<Center>

View File

@@ -28,7 +28,7 @@ function MaskotDesa() {
}
return (
<Box pb={80}>
<Box>
<Stack align="center" gap="xl">
<Stack align="center" gap={10}>
<Image src="/pudak-icon.png" alt="Ikon Desa" w={{ base: 160, md: 240 }} loading="lazy"/>

View File

@@ -36,7 +36,7 @@ const letters = ["S", "I", "G", "A", "P"];
function MotoDesa() {
return (
<Box pb={80} px={{ base: "md", md: "xl" }}>
<Box px={{ base: "md", md: "xl" }}>
<Stack align="center" gap="lg">
<Box>
<Text

View File

@@ -25,7 +25,7 @@ function ProfilPerbekel() {
}
return (
<Box pb={80} px="md">
<Box px="md">
<Stack align="center" gap={0} mb={40}>
<Text
c={colors['blue-button']}
@@ -116,7 +116,7 @@ function ProfilPerbekel() {
</Stack>
<Text
fz={{ base: "1rem", md: "1.2rem" }}
ta="justify"
ta="left"
lh={1.6}
dangerouslySetInnerHTML={{ __html: data.pengalaman }}
style={{ wordBreak: "break-word", whiteSpace: "normal" }}

View File

@@ -3,7 +3,7 @@ import { Box, Center, Paper } from '@mantine/core';
function ProfileDesa() {
return (
<Box pb={90}>
<Box>
<Center>
<Paper p={"xl"} bg={colors['white-trans-1']} w={{ base: "100%", md: "100%" }}>
<Center>

View File

@@ -24,7 +24,7 @@ function SejarahDesa() {
}
return (
<Box py="xl">
<Box>
<Stack align="center" gap="xl">
<Stack align="center" gap="sm">
<Center>

View File

@@ -36,7 +36,7 @@ function SemuaPerbekel() {
}
return (
<Box pb={80}>
<Box>
<Stack align="center" gap="lg">
<Box>
<Text

View File

@@ -24,7 +24,7 @@ function VisiMisiDesa() {
}
return (
<Box py="xl">
<Box>
<Stack align="center" gap="xl">
<Image
src="/darmasaba-icon.png"

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'
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>
)
})}

View File

@@ -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()}
>
&larr; Kembali
</Text>
<BackButton/>
</Box>
{/* Konten Utama */}

View File

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

View File

@@ -1,27 +1,26 @@
'use client'
import {
Box,
Button,
Card,
Center,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Text,
Title,
Tooltip,
Button,
Paper,
Title
} from '@mantine/core';
import { IconSearch, IconArrowRight } from '@tabler/icons-react';
import { IconArrowRight, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '@/app/admin/(dashboard)/_com/header';
import pencegahanKriminalitasState from '@/app/admin/(dashboard)/_state/keamanan/pencegahan-kriminalitas';
import { useShallowEffect } from '@mantine/hooks';
import { useState } from 'react';
import HeaderSearch from '@/app/admin/(dashboard)/_com/header';
import { IconArrowLeft } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
function PencegahanKriminalitas() {
const [search, setSearch] = useState("");
@@ -96,7 +95,6 @@ function ListPencegahanKriminalitas({ search }: { search: string }) {
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
/>
<Group justify="flex-end" mt="sm">
<Tooltip label="Lihat detail program" withArrow>
<Button
size="sm"
variant="gradient"
@@ -106,7 +104,6 @@ function ListPencegahanKriminalitas({ search }: { search: string }) {
>
Lihat Detail
</Button>
</Tooltip>
</Group>
</Stack>
</Card>

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

View File

@@ -0,0 +1,248 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client'
import { ActionIcon, Avatar, Badge, Box, Card, Flex, Grid, Group, Paper, Slider, Stack, Text, TextInput } from '@mantine/core';
import { IconArrowsShuffle, IconPlayerPauseFilled, IconPlayerPlayFilled, IconPlayerSkipBackFilled, IconPlayerSkipForwardFilled, IconRepeat, IconRepeatOff, IconSearch, IconVolume, IconVolumeOff, IconX } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import BackButton from '../../desa/layanan/_com/BackButto';
const MusicPlayer = () => {
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(245);
const [volume, setVolume] = useState(70);
const [isMuted, setIsMuted] = useState(false);
const [isRepeat, setIsRepeat] = useState(false);
const [isShuffle, setIsShuffle] = useState(false);
const songs = [
{ id: 1, title: 'Midnight Dreams', artist: 'The Wanderers', duration: '4:05', cover: 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop' },
{ id: 2, title: 'Summer Breeze', artist: 'Coastal Vibes', duration: '3:42', cover: 'https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=400&h=400&fit=crop' },
{ id: 3, title: 'City Lights', artist: 'Urban Echo', duration: '4:18', cover: 'https://images.unsplash.com/photo-1514320291840-2e0a9bf2a9ae?w=400&h=400&fit=crop' },
{ id: 4, title: 'Ocean Waves', artist: 'Serenity Sound', duration: '5:20', cover: 'https://images.unsplash.com/photo-1459749411175-04bf5292ceea?w=400&h=400&fit=crop' },
{ id: 5, title: 'Neon Nights', artist: 'Electric Dreams', duration: '3:55', cover: 'https://images.unsplash.com/photo-1487180144351-b8472da7d491?w=400&h=400&fit=crop' },
{ id: 6, title: 'Mountain High', artist: 'Peak Performers', duration: '4:32', cover: 'https://images.unsplash.com/photo-1511671782779-c97d3d27a1d4?w=400&h=400&fit=crop' }
];
const [currentSong, setCurrentSong] = useState(songs[0]);
useEffect(() => {
let interval: any;
if (isPlaying) {
interval = setInterval(() => {
setCurrentTime(prev => {
if (prev >= duration) {
setIsPlaying(false);
return 0;
}
return prev + 1;
});
}, 1000);
}
return () => clearInterval(interval);
}, [isPlaying, duration]);
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const playSong = (song: any) => {
setCurrentSong(song);
setCurrentTime(0);
setIsPlaying(true);
const durationInSeconds = parseInt(song.duration.split(':')[0]) * 60 + parseInt(song.duration.split(':')[1]);
setDuration(durationInSeconds);
};
const toggleMute = () => {
setIsMuted(!isMuted);
};
return (
<Box px={{ base: 'md', md: 100 }} py="xl">
<Paper
mx="auto"
p="xl"
radius="lg"
shadow="sm"
bg="white"
style={{
border: '1px solid #eaeaea',
}}
>
<Stack gap="md">
<BackButton />
<Group justify="space-between" mb="xl" mt={"md"}>
<div>
<Text size="32px" fw={700} c="#0B4F78">Selamat Datang Kembali</Text>
<Text size="md" c="#5A6C7D">Temukan musik favorit Anda hari ini</Text>
</div>
<Group gap="md">
<TextInput
placeholder="Cari lagu..."
leftSection={<IconSearch size={18} />}
radius="xl"
w={280}
styles={{ input: { backgroundColor: '#fff' } }}
/>
</Group>
</Group>
<Stack gap="xl">
<div>
<Text size="xl" fw={700} c="#0B4F78" mb="md">Sedang Diputar</Text>
<Card radius="md" p="xl" shadow="md">
<Group align="center" gap="xl">
<Avatar src={currentSong.cover} size={180} radius="md" />
<Stack gap="md" style={{ flex: 1 }}>
<div>
<Text size="28px" fw={700} c="#0B4F78">{currentSong.title}</Text>
<Text size="lg" c="#5A6C7D">{currentSong.artist}</Text>
</div>
<Group gap="xs" align="center">
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(currentTime)}</Text>
<Slider
value={currentTime}
max={duration}
onChange={setCurrentTime}
color="#0B4F78"
size="sm"
style={{ flex: 1 }}
styles={{ thumb: { borderWidth: 2 } }}
/>
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(duration)}</Text>
</Group>
</Stack>
</Group>
</Card>
</div>
<div>
<Text size="xl" fw={700} c="#0B4F78" mb="md">Daftar Putar</Text>
<Grid gutter="md">
{songs.map(song => (
<Grid.Col span={{ base: 12, sm: 6, lg: 4 }} key={song.id}>
<Card
radius="md"
p="md"
shadow="sm"
style={{
cursor: 'pointer',
border: currentSong.id === song.id ? '2px solid #0B4F78' : '2px solid transparent',
transition: 'all 0.2s'
}}
onClick={() => playSong(song)}
>
<Group gap="md" align="center">
<Avatar src={song.cover} size={64} radius="md" />
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} c="#0B4F78" truncate>{song.title}</Text>
<Text size="xs" c="#5A6C7D">{song.artist}</Text>
<Text size="xs" c="#8A9BA8">{song.duration}</Text>
</Stack>
{currentSong.id === song.id && isPlaying && (
<Badge color="#0B4F78" variant="filled">Memutar</Badge>
)}
</Group>
</Card>
</Grid.Col>
))}
</Grid>
</div>
</Stack>
</Stack>
</Paper>
<Paper
mt="xl"
mx="auto"
p="xl"
radius="lg"
shadow="sm"
bg="white"
style={{
border: '1px solid #eaeaea',
}}
>
<Flex align="center" justify="space-between" gap="xl" h="100%">
<Group gap="md" style={{ flex: 1 }}>
<Avatar src={currentSong.cover} size={56} radius="md" />
<div style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} c="#0B4F78" truncate>{currentSong.title}</Text>
<Text size="xs" c="#5A6C7D">{currentSong.artist}</Text>
</div>
</Group>
<Stack gap="xs" style={{ flex: 1 }} align="center">
<Group gap="md">
<ActionIcon
variant={isShuffle ? 'filled' : 'subtle'}
color="#0B4F78"
onClick={() => setIsShuffle(!isShuffle)}
radius="xl"
>
{isShuffle ? <IconArrowsShuffle size={18} /> : <IconX size={18} />}
</ActionIcon>
<ActionIcon variant="light" color="#0B4F78" size={40} radius="xl">
<IconPlayerSkipBackFilled size={20} />
</ActionIcon>
<ActionIcon
variant="filled"
color="#0B4F78"
size={56}
radius="xl"
onClick={() => setIsPlaying(!isPlaying)}
>
{isPlaying ? <IconPlayerPauseFilled size={26} /> : <IconPlayerPlayFilled size={26} />}
</ActionIcon>
<ActionIcon variant="light" color="#0B4F78" size={40} radius="xl">
<IconPlayerSkipForwardFilled size={20} />
</ActionIcon>
<ActionIcon
variant={isRepeat ? 'filled' : 'subtle'}
color="#0B4F78"
onClick={() => setIsRepeat(!isRepeat)}
radius="xl"
>
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
</ActionIcon>
</Group>
<Group gap="xs" style={{ width: '100%', maxWidth: 500 }}>
<Text size="xs" c="#5A6C7D" w={40} ta="right">{formatTime(currentTime)}</Text>
<Slider
value={currentTime}
max={duration}
onChange={setCurrentTime}
color="#0B4F78"
size="xs"
style={{ flex: 1 }}
/>
<Text size="xs" c="#5A6C7D" w={40}>{formatTime(duration)}</Text>
</Group>
</Stack>
<Group gap="xs" style={{ flex: 1 }} justify="flex-end">
<ActionIcon variant="subtle" color="gray" onClick={toggleMute}>
{isMuted || volume === 0 ? <IconVolumeOff size={20} /> : <IconVolume size={20} />}
</ActionIcon>
<Slider
value={isMuted ? 0 : volume}
onChange={(val) => {
setVolume(val);
if (val > 0) setIsMuted(false);
}}
color="#0B4F78"
size="xs"
w={100}
/>
<Text size="xs" c="#5A6C7D" w={32}>{isMuted ? 0 : volume}%</Text>
</Group>
</Flex>
</Paper>
</Box>
);
};
export default MusicPlayer;

View File

@@ -57,7 +57,7 @@ function Page() {
<Title order={1} fw={700} ta="center" c={colors['blue-button']}>
Statistik Data Pendidikan
</Title>
<Text c="dimmed" size="sm" ta="center">
<Text fz="md" ta="center">
Visualisasi jumlah pendidikan berdasarkan kategori yang tersedia
</Text>
</Stack>

View File

@@ -55,7 +55,7 @@ function Page({ params }: PageProps) {
<Group justify="space-between" align="center" mb="md">
<Group gap="sm">
<IconChalkboard size={28} stroke={1.5} color={colors['blue-button']} />
<Title order={2} fz="xl">Daftar Lembaga Pendidikan</Title>
<Title order={2} fz="xl" c={colors['blue-button']}>Daftar Lembaga Pendidikan</Title>
</Group>
<TextInput
placeholder='pencarian'

View File

@@ -55,7 +55,7 @@ function Page({ params }: PageProps) {
<Group justify="space-between" align="center" mb="md">
<Group gap="sm">
<IconMicroscope size={28} stroke={1.5} color={colors['blue-button']} />
<Title order={2} fz="xl">Daftar Pengajar</Title>
<Title order={2} fz="xl" c={colors['blue-button']}>Daftar Pengajar</Title>
</Group>
<TextInput
placeholder='pencarian'

View File

@@ -55,7 +55,7 @@ function Page({ params }: PageProps) {
<Group justify="space-between" align="center" mb="md">
<Group gap="sm">
<IconSchool size={28} stroke={1.5} color={colors['blue-button']} />
<Title order={2} fz="xl">Daftar Siswa</Title>
<Title order={2} fz="xl" c={colors['blue-button']}>Daftar Siswa</Title>
</Group>
<TextInput
placeholder='pencarian'

View File

@@ -96,23 +96,21 @@
'use client'
import colors from '@/con/colors';
// pastikan path benar
import infoSekolahPaud from '@/app/admin/(dashboard)/_state/pendidikan/info-sekolah-paud';
import {
ActionIcon,
Box,
Button,
Container,
Group,
Loader,
Paper,
Stack,
Text,
VisuallyHidden,
Loader,
Text
} from '@mantine/core';
import { IconArrowLeft } from '@tabler/icons-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useSnapshot } from 'valtio';
import React, { useEffect, useState } from 'react';
import infoSekolahPaud from '@/app/admin/(dashboard)/_state/pendidikan/info-sekolah-paud';
import { useSnapshot } from 'valtio';
import BackButton from '../../../desa/layanan/_com/BackButto';
type LayoutSekolahProps = {
title?: string;
@@ -153,10 +151,7 @@ export default function LayoutSekolah({
<Container size="xl" py={{ base: 'md', md: 'xl' }}>
<Stack gap="lg">
{/* Back Button */}
<ActionIcon onClick={() => window.history.back()} variant="light" radius="md" size="lg">
<IconArrowLeft size={20} />
<VisuallyHidden>Kembali</VisuallyHidden>
</ActionIcon>
<BackButton/>
{/* Search & Filter */}
<Paper radius="lg" p="xl" withBorder>
@@ -185,8 +180,8 @@ export default function LayoutSekolah({
radius="xl"
size="sm"
variant={aktif ? 'filled' : 'light'}
bg={colors['blue-button']}
c={aktif ? colors['white-1'] : 'gray'}
bg={aktif? colors['blue-button'] : '#BDCADE'}
c={aktif ? colors['white-1'] : colors['blue-button']}
>
{k}
</Button>

View File

@@ -47,7 +47,7 @@ function Page() {
<Group justify="space-between" align="center" mb="md">
<Group gap="sm">
<IconChalkboard size={28} stroke={1.5} color={colors['blue-button']} />
<Title order={2} fz="xl">Daftar Lembaga Pendidikan</Title>
<Title order={2} fz="xl" c={colors['blue-button']}>Daftar Lembaga Pendidikan</Title>
</Group>
<TextInput
placeholder='pencarian'

View File

@@ -46,7 +46,7 @@ function Page() {
<Group justify="space-between" align="center" mb="md">
<Group gap="sm">
<IconMicroscope size={28} stroke={1.5} color={colors['blue-button']} />
<Title order={2} fz="xl">Daftar Pengajar</Title>
<Title order={2} fz="xl" c={colors['blue-button']}>Daftar Pengajar</Title>
</Group>
<TextInput
placeholder='pencarian'

View File

@@ -47,7 +47,7 @@ function Page() {
<Group justify="space-between" align="center" mb="md">
<Group gap="sm">
<IconSchool size={28} stroke={1.5} color={colors['blue-button']} />
<Title order={2} fz="xl">Daftar Siswa</Title>
<Title order={2} fz="xl" c={colors['blue-button']}>Daftar Siswa</Title>
</Group>
<TextInput
placeholder='pencarian'

View File

@@ -37,13 +37,15 @@ function Page() {
<Box px={{ base: 'md', md: 100 }} pb={50}>
<Box mb="xl">
<Title ta="center" order={1} fw="bold" c={colors['blue-button']} mb="sm">
<Title ta="center" order={1} fw="bold" c={colors['blue-button']}>
Program Pendidikan Anak
</Title>
<Box my={"sm"}>
<Divider size="sm" color={colors['blue-button']} mx="auto" maw={550} />
</Box>
<Text ta="center" fz="lg" c="black" mb="lg" maw={800} mx="auto">
Desa Darmasaba berkomitmen mencetak generasi muda yang cerdas, berkarakter, dan siap bersaing melalui program pendidikan yang inklusif dan berkelanjutan.
</Text>
<Divider size="sm" color={colors['blue-button']} mx="auto" maw={120} />
</Box>
<SimpleGrid
@@ -66,7 +68,7 @@ function Page() {
</Title>
</Group>
<Tooltip label="Detail tujuan program pendidikan anak" position="top-start" withArrow>
<Text fz="lg" lh={1.6} c="dark" style={{wordBreak: "break-word", whiteSpace: "normal"}} dangerouslySetInnerHTML={{ __html: stateTujuan.findById.data?.deskripsi }} />
<Text fz="lg" lh={1.6} c="dark" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: stateTujuan.findById.data?.deskripsi }} />
</Tooltip>
</Stack>
</Paper>
@@ -87,7 +89,7 @@ function Page() {
</Title>
</Group>
<Tooltip label="Detail program unggulan yang sedang berjalan" position="top-start" withArrow>
<Text fz="lg" lh={1.6} c="dark" style={{wordBreak: "break-word", whiteSpace: "normal"}} dangerouslySetInnerHTML={{ __html: stateUnggulan.findById.data?.deskripsi }} />
<Text fz="lg" lh={1.6} c="dark" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: stateUnggulan.findById.data?.deskripsi }} />
</Tooltip>
</Stack>
</Paper>

View File

@@ -7,6 +7,7 @@ import {
Box,
Button,
Center,
Group,
Image,
Pagination,
Paper,
@@ -178,12 +179,18 @@ function Page() {
<Paper p="lg" radius="xl" shadow="xs" withBorder>
<Stack gap="xs">
<Text fz="lg" fw="bold" c={colors["blue-button"]}>Kontak PPID</Text>
<Text fz="sm" c="dimmed" lh={1.6}>
<IconMail size={16} style={{ marginRight: 6 }} /> Email: <Text span fw="500">ppid@desadarmasaba.id</Text>
</Text>
<Text fz="sm" c="dimmed" lh={1.6}>
<IconBrandWhatsapp size={16} style={{ marginRight: 6 }} /> WhatsApp: <Text span fw="500">081-xxx-xxx-xxx</Text>
</Text>
<Group>
<IconMail color='gray' size={16} style={{ marginRight: 6 }} />
<Text c={"dimmed"} fz="sm" lh={1.6}>
Email: <Text c={"dimmed"} span fw="500">ppid@desadarmasaba.id</Text>
</Text>
</Group>
<Group>
<IconBrandWhatsapp color='gray' size={16} style={{ marginRight: 6 }} />
<Text c={"dimmed"} fz="sm" lh={1.6}>
WhatsApp: <Text c={"dimmed"} span fw="500">081-xxx-xxx-xxx</Text>
</Text>
</Group>
</Stack>
</Paper>
</Stack>

View File

@@ -63,7 +63,7 @@ function Page() {
<SimpleGrid px={{ base: 'md', md: 100 }} cols={{ base: 1, sm: 2, md: 3 }} spacing="xl">
{data.map((v: any, k: number) => (
<BackgroundImage key={k} src={v.image?.link || ''} h={360} radius="xl" pos="relative">
<Box pos="absolute" inset={0} bg="rgba(0,0,0,0.45)" style={{ borderRadius: 16 }} />
<Box pos="absolute" inset={0} bg="rgba(0,0,0,0.45)" style={{ borderRadius: 27 }} />
<Stack justify="space-between" h="100%" p="lg" pos="relative">
<Box>
<Text fz="lg" fw={600} c="white" ta="center">

View File

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

View File

@@ -1,10 +1,10 @@
'use client'
import { Box, Center, Container, Image, LoadingOverlay, Paper, SimpleGrid, Stack, Text, Title, Tooltip } from '@mantine/core';
import { Prisma } from '@prisma/client';
import { useEffect, useState } from 'react';
import { IconMoodSad } from '@tabler/icons-react';
import BackButton from '../../(pages)/desa/layanan/_com/BackButto';
import colors from '@/con/colors';
import { Box, Center, Container, Image, LoadingOverlay, Paper, SimpleGrid, Stack, Text, Title } from '@mantine/core';
import { Prisma } from '@prisma/client';
import { IconMoodSad } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import BackButton from '../../(pages)/desa/layanan/_com/BackButto';
function Page() {
const [sdgsDesa, setSdgsDesa] = useState<Prisma.SdgsDesaGetPayload<{ include: { image: true } }>[]>([]);
@@ -114,11 +114,9 @@ function Page() {
/>
</Box>
<Stack gap="xs" align="center" style={{ width: '100%' }}>
<Tooltip label={item.name} position="top" withArrow>
<Title order={4} ta="center" c="dark" fw={600} lineClamp={2} style={{ minHeight: '3rem' }}>
{item.name}
</Title>
</Tooltip>
<Text
ta="center"
fw={700}

View File

@@ -0,0 +1,96 @@
'use client';
import { Button } from '@mantine/core';
import { useEffect, useRef, useState } from 'react';
const NewsReader = () => {
const [isSpeaking, setIsSpeaking] = useState(false);
const [isAllowed, setIsAllowed] = useState(false);
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null);
// Fungsi untuk membaca teks
const speakText = () => {
if (typeof window === 'undefined' || !window.speechSynthesis) {
console.warn('Browser tidak mendukung SpeechSynthesis.');
return;
}
const contentElement = document.getElementById('news-content');
const rawText = contentElement?.innerText || '';
if (!rawText.trim()) return;
// Hentikan semua suara sebelumnya
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(rawText);
utterance.lang = 'id-ID';
utterance.rate = 1;
utterance.pitch = 1;
utterance.onstart = () => setIsSpeaking(true);
utterance.onend = () => setIsSpeaking(false);
utteranceRef.current = utterance;
try {
window.speechSynthesis.speak(utterance);
} catch (err) {
console.warn('Autoplay gagal karena kebijakan browser:', err);
}
};
// Auto play jika sudah pernah diizinkan
useEffect(() => {
const hasPermission = localStorage.getItem('ttsAllowed') === 'true';
setIsAllowed(hasPermission);
if (hasPermission) {
const trySpeak = setInterval(() => {
const contentElement = document.getElementById('news-content');
if (contentElement && contentElement.innerText.trim()) {
speakText();
clearInterval(trySpeak);
}
}, 1000);
return () => clearInterval(trySpeak);
}
}, []);
// Hentikan suara saat user keluar halaman / komponen unmount
useEffect(() => {
return () => {
if (typeof window !== 'undefined' && window.speechSynthesis) {
window.speechSynthesis.cancel();
setIsSpeaking(false);
}
};
}, []);
// Handle tombol manual
const handleToggle = () => {
if (isSpeaking) {
window.speechSynthesis.cancel();
setIsSpeaking(false);
} else {
if (!isAllowed) {
localStorage.setItem('ttsAllowed', 'true');
setIsAllowed(true);
}
speakText();
}
};
return (
<Button
onClick={handleToggle}
color="#0B4F78"
variant="filled"
radius="xl"
size="md"
mt="md"
>
{isSpeaking ? '🔇 Hentikan Suara' : '🔊 Dengarkan Berita'}
</Button>
);
};
export default NewsReader;

View File

@@ -0,0 +1,185 @@
"use client";
import { Box } from "@mantine/core";
import { IconBell } from "@tabler/icons-react";
import { useMemo, useState, useEffect } from "react";
interface RunningTextProps {
news?: string[];
speed?: number; // dalam detik (jika mau manual)
autoSpeed?: boolean; // otomatis sesuaikan speed dengan panjang text
bgColor?: string;
textColor?: string;
maxLength?: number; // max karakter per item
}
// Utility function untuk strip HTML (works on both server and client)
function stripHtmlTags(html: string): string {
const text = html
.replace(/<style[^>]*>.*?<\/style>/gi, '')
.replace(/<script[^>]*>.*?<\/script>/gi, '')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#039;/gi, "'")
.replace(/&#8217;/gi, "'")
.replace(/&mdash;/gi, '—')
.replace(/&ndash;/gi, '')
.replace(/\s+/g, ' ')
.trim();
return text;
}
export default function RunningText({
news = [
"Selamat datang di Portal Desa Darmasaba",
"Jam operasional kantor: Senin - Jumat 08:00 - 17:00",
],
speed = 20,
autoSpeed = true,
bgColor = "#1e5a7e",
textColor = "white",
maxLength = 200 // default max 200 karakter per item
}: RunningTextProps) {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
// Process news data
const processedNews = useMemo(() => {
return news
.filter(item => item && item.trim() !== "")
.map(item => {
let text = stripHtmlTags(item);
// Limit panjang per item
if (text.length > maxLength) {
text = text.substring(0, maxLength) + "...";
}
return text;
})
.filter(item => item.length > 0);
}, [news, maxLength]);
const allNews = processedNews.length > 0
? processedNews.join(" • ")
: "Tidak ada pengumuman";
// Hitung speed berdasarkan mode
const calculatedSpeed = useMemo(() => {
if (!autoSpeed) {
return speed; // Gunakan speed manual
}
// Auto speed: berdasarkan panjang text
const textLength = allNews.length;
// Formula yang lebih natural:
// - Text pendek (< 100 char): 15 detik
// - Text sedang (100-300 char): 20-30 detik
// - Text panjang (> 300 char): 30-45 detik
let calculatedTime;
if (textLength < 100) {
calculatedTime = 15;
} else if (textLength < 300) {
calculatedTime = 15 + ((textLength - 100) / 200) * 15; // 15-30 detik
} else {
calculatedTime = 30 + Math.min(((textLength - 300) / 500) * 15, 15); // 30-45 detik max
}
return Math.round(calculatedTime);
}, [allNews, speed, autoSpeed]);
// Prevent hydration mismatch
if (!isMounted) {
return (
<Box
style={{
backgroundColor: bgColor,
overflow: "hidden",
position: "relative",
width: "100%",
padding: "12px 0",
borderBottom: "2px solid rgba(255, 255, 255, 0.1)"
}}
>
<div style={{
display: "inline-flex",
alignItems: "center",
gap: "8px",
whiteSpace: "nowrap"
}}>
<IconBell size={20} color={textColor} style={{ flexShrink: 0 }} />
<span style={{
color: textColor,
fontSize: "15px",
fontWeight: 500,
whiteSpace: "nowrap"
}}>
Memuat pengumuman...
</span>
</div>
</Box>
);
}
return (
<Box
style={{
backgroundColor: bgColor,
overflow: "hidden",
position: "relative",
width: "100%",
padding: "12px 0",
borderBottom: "2px solid rgba(255, 255, 255, 0.1)"
}}
>
<style dangerouslySetInnerHTML={{
__html: `
@keyframes scrollText {
0% {
transform: translateX(100%);
}
100% {
transform: translateX(-100%);
}
}
.running-text-wrapper {
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
animation: scrollText ${calculatedSpeed}s linear infinite;
}
.running-text-wrapper:hover {
animation-play-state: paused;
cursor: pointer;
}
.running-text-content {
color: ${textColor};
font-size: 18px;
font-weight: 500;
white-space: nowrap;
}
`
}} />
<div className="running-text-wrapper">
<IconBell size={20} color={textColor} style={{ flexShrink: 0 }} />
<span className="running-text-content">
{allNews}
</span>
</div>
</Box>
);
}

View File

@@ -35,7 +35,7 @@ function Apbdes() {
return (
<Stack p="sm" gap="xl" bg={colors.Bg}>
<Box>
<Box mt={"xl"}>
<Stack gap="sm">
<Text c={colors["blue-button"]} ta={"center"} fw={"bold"} fz={{ base: "1.8rem", md: "3.4rem" }}>
{textHeading.title}
@@ -72,12 +72,7 @@ function Apbdes() {
pos="relative"
style={{ overflow: 'hidden' }}
>
<Box
pos="absolute"
inset={0}
bg="rgba(0,0,0,0.55)"
style={{ backdropFilter: 'blur(4px)' }}
/>
<Box pos="absolute" inset={0} bg="rgba(0,0,0,0.45)" style={{ borderRadius: 16 }} />
<Stack justify="space-between" h="100%" p="xl" pos="relative">
<Text
c="white"
@@ -117,7 +112,7 @@ function Apbdes() {
)}
</SimpleGrid>
<Group justify="center" pb={10}>
<Group justify="center" pb={"xl"}>
<Button
component={Link}
href="/darmasaba/apbdes"

View File

@@ -29,7 +29,7 @@ function DesaAntiKorupsi() {
const data = (state.desaAntikorupsi.findMany.data || []).slice(0, 6);
return (
<Stack gap={"0"} bg={colors.Bg} p={"sm"}>
<Stack gap={"0"} bg={colors.Bg} p={"sm"} my={"xs"}>
<Container w={{ base: "100%", md: "80%" }} p={"md"} >
<Center>
<Text fw={"bold"} c={colors["blue-button"]} fz={{ base: "1.8rem", md: "3.4rem" }}>Desa Anti Korupsi</Text>

View File

@@ -153,7 +153,7 @@ function Kepuasan() {
if (data.length === 0) {
return (
<Stack p="sm">
<Stack p="sm" my={"xs"}>
<Container w={{ base: "100%", md: "80%" }} p={"sm"}>
<Center>
<Text
@@ -421,7 +421,7 @@ function Kepuasan() {
);
}
return (
<Stack p={"sm"}>
<Stack p={"sm"} my={"xs"}>
<Container size="lg" px="sm">
<Center>
<Text

View File

@@ -124,7 +124,7 @@ function LandingPage() {
<Stack bg={colors.Bg} p="md" gap="lg">
<Flex gap="lg" wrap={{ base: "wrap", md: "nowrap" }}>
<Stack w={{ base: "100%", md: "65%" }} gap="lg">
<Card radius="xl" bg={colors.grey[1]} p="lg" shadow="xl">
<Card radius="xl" bg={colors.grey[1]} p="lg" mt={10} shadow="xl">
<Stack gap="xl">
<Flex gap="md" wrap="wrap">
<Group>

View File

@@ -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: 16,
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;

View File

@@ -3,11 +3,10 @@
import prestasiState from "@/app/admin/(dashboard)/_state/landing-page/prestasi-desa";
import colors from "@/con/colors";
import { BackgroundImage, Box, Button, Center, Container, Group, Loader, SimpleGrid, Stack, Text } from "@mantine/core";
import { useProxy } from "valtio/utils";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { IconTrophy } from "@tabler/icons-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useProxy } from "valtio/utils";
function Prestasi() {
const state = useProxy(prestasiState.prestasiDesa);
@@ -33,12 +32,9 @@ function Prestasi() {
<Stack p="sm" bg="linear-gradient(180deg, #ffffff 0%, #f8fbff 100%)">
<Container w={{ base: "100%", md: "80%" }} p="xl">
<Stack align="center" gap="sm">
<Group gap="xs">
<IconTrophy size={36} color={colors["blue-button"]} />
<Text c={colors["blue-button"]} ta="center" fz={{ base: "2rem", md: "3.4rem" }} fw={700}>
Prestasi Desa
</Text>
</Group>
<Text fz={{ base: "1rem", md: "1.3rem" }} ta="center" c="dimmed" maw={700}>
Kami bangga dengan pencapaian desa hingga saat ini. Semoga prestasi ini menjadi inspirasi untuk terus berkarya dan berinovasi demi kemajuan bersama.
</Text>
@@ -63,14 +59,13 @@ function Prestasi() {
) : data.length === 0 ? (
<Center mih={200}>
<Stack align="center" gap="xs">
<IconTrophy size={48} color="gray" />
<Text fz="1.2rem" fw={500} c="dimmed">
Belum ada prestasi yang ditampilkan
</Text>
</Stack>
</Center>
) : (
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="lg">
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="lg" mb={"xl"}>
{data.map((v, k) => (
<BackgroundImage
key={k}
@@ -82,7 +77,7 @@ function Prestasi() {
pos="absolute"
inset={0}
bg="linear-gradient(180deg, rgba(0,0,0,0.4) 0%, rgba(0,0,0,0.7) 100%)"
style={{ borderRadius: "1rem" }}
style={{ borderRadius: 27 }}
/>
<Stack justify="space-between" h="100%" pos="relative" p="lg">
<Box>

View File

@@ -1,11 +1,12 @@
'use client'
import { useEffect, useState } from "react"
import { Box, Button, Center, Container, Image, Paper, SimpleGrid, Stack, Text, Title, useMantineTheme, Tooltip } from "@mantine/core"
import colors from "@/con/colors"
import { Box, Button, Center, Container, Image, Paper, SimpleGrid, Stack, Text, Title, useMantineTheme } from "@mantine/core"
import { useMediaQuery } from "@mantine/hooks"
import { Prisma } from "@prisma/client"
import Link from "next/link"
import { IconMoodSad } from "@tabler/icons-react"
import colors from "@/con/colors"
import Link from "next/link"
import { useEffect, useState } from "react"
import { motion } from "framer-motion";
export default function SDGS() {
const theme = useMantineTheme()
@@ -25,8 +26,8 @@ export default function SDGS() {
setSdgsDesa([])
return
}
const top3Sdgs = [...data].sort((a, b) => parseInt(b.jumlah) - parseInt(a.jumlah)).slice(0, 3)
setSdgsDesa(top3Sdgs)
const top4Sdgs = [...data].sort((a, b) => parseInt(b.jumlah) - parseInt(a.jumlah)).slice(0, 4)
setSdgsDesa(top4Sdgs)
} catch {
setSdgsDesa([])
}
@@ -35,7 +36,7 @@ export default function SDGS() {
}, [])
return (
<Stack p="sm">
<Stack p="sm" my={"xs"}>
<Container w={{ base: "100%", md: "80%" }} p="xl">
<Center>
<Title
@@ -52,63 +53,56 @@ export default function SDGS() {
</Text>
<Box py="lg">
<Paper
p={{ base: "md", md: "xl" }}
radius="2xl"
withBorder
shadow="lg"
style={{
background: "linear-gradient(145deg, #FFFFFF, #F9FAFB)",
border: "1px solid rgba(0,0,0,0.06)",
}}
>
{sdgsDesa && sdgsDesa.length > 0 ? (
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="xl" verticalSpacing="xl">
{sdgsDesa && sdgsDesa.length > 0 ? (
<SimpleGrid cols={{ base: 1, sm: 4 }} spacing="xl" verticalSpacing="xl" pb={30}>
{sdgsDesa.map((item) => (
<Paper
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
key={item.id}
p="lg"
radius="xl"
shadow="sm"
withBorder
style={{
background: "linear-gradient(180deg, #FFFFFF, #F6F8FA)",
border: "1px solid rgba(0,0,0,0.05)",
transition: "all 0.3s ease",
height: "100%", // biar tinggi antar card konsisten
display: "flex",
flexDirection: "column",
}}
>
<Center mb="lg">
<Box
p="md"
style={{
background: "rgba(240, 249, 255, 0.8)",
backdropFilter: "blur(6px)",
width: mobile ? 140 : 160,
height: mobile ? 140 : 160,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "1rem",
boxShadow: "0 6px 16px rgba(0,0,0,0.06)",
}}
>
<Image
src={item.image?.link ? item.image.link : "/placeholder-sdgs.png"}
alt={item.name}
w={mobile ? 90 : 110}
h={mobile ? 90 : 110}
fit="contain"
loading="lazy"
/>
</Box>
</Center>
{/* Stack isi teks & angka */}
<Stack justify="space-between" align="center" gap="xs" h="100%">
<Tooltip label="Nama tujuan SDGs Desa" position="top" withArrow>
<Paper
p="lg"
radius="xl"
shadow="sm"
withBorder
style={{
background: "linear-gradient(180deg, #FFFFFF, #F6F8FA)",
border: "1px solid rgba(0,0,0,0.05)",
transition: "all 0.3s ease",
height: "100%", // biar tinggi antar card konsisten
display: "flex",
flexDirection: "column",
}}
>
<Center mb="lg">
<Box
p="md"
style={{
background: "rgba(240, 249, 255, 0.8)",
backdropFilter: "blur(6px)",
width: mobile ? 140 : 160,
height: mobile ? 140 : 160,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "1rem",
boxShadow: "0 6px 16px rgba(0,0,0,0.06)",
}}
>
<Image
src={item.image?.link ? item.image.link : "/placeholder-sdgs.png"}
alt={item.name}
w={mobile ? 90 : 110}
h={mobile ? 90 : 110}
fit="contain"
loading="lazy"
/>
</Box>
</Center>
{/* Stack isi teks & angka */}
<Stack justify="space-between" align="center" gap="xs" h="100%">
<Text
ta="center"
fz={{ base: "lg", md: "xl" }}
@@ -118,34 +112,33 @@ export default function SDGS() {
>
{item.name}
</Text>
</Tooltip>
<Title
order={2}
ta="center"
style={{
fontSize: mobile ? "2.4rem" : "3.2rem",
fontWeight: 900,
letterSpacing: "-0.5px",
color: "#124170",
}}
>
{item.jumlah}
</Title>
</Stack>
</Paper>
<Title
order={2}
ta="center"
style={{
fontSize: mobile ? "2.4rem" : "3.2rem",
fontWeight: 900,
letterSpacing: "-0.5px",
color: "#124170",
}}
>
{item.jumlah}
</Title>
</Stack>
</Paper>
</motion.div>
))}
</SimpleGrid>
) : (
<Center mih={200} style={{ flexDirection: "column" }}>
<IconMoodSad size={48} stroke={1.5} style={{ marginBottom: "1rem" }} />
<Text fz="lg" c="dimmed">
Data SDGs Desa belum tersedia
</Text>
</Center>
)}
</Paper>
) : (
<Center mih={200} style={{ flexDirection: "column" }}>
<IconMoodSad size={48} stroke={1.5} style={{ marginBottom: "1rem" }} />
<Text fz="lg" c="dimmed">
Data SDGs Desa belum tersedia
</Text>
</Center>
)}
<Center>
<Button
@@ -156,7 +149,19 @@ export default function SDGS() {
mt="md"
variant="gradient"
gradient={{ from: "#26667F", to: "#124170" }}
style={{ boxShadow: "0 6px 14px rgba(18,65,112,0.25)"}}
style={{
boxShadow: "0 6px 14px rgba(18,65,112,0.25)",
transition: "all 0.3s ease",
transform: "translateY(0)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = "translateY(-4px)";
e.currentTarget.style.boxShadow = "0 10px 20px rgba(18,65,112,0.35)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = "translateY(0)";
e.currentTarget.style.boxShadow = "0 6px 14px rgba(18,65,112,0.25)";
}}
>
<Text c="white" fz={{ base: "md", md: "lg" }} fw="bold">Jelajahi Semua Tujuan SDGs Desa</Text>
</Button>

View File

@@ -1,3 +1,4 @@
'use client'
import DesaAntiKorupsi from "@/app/darmasaba/_com/main-page/desaantikorupsi";
import Kepuasan from "@/app/darmasaba/_com/main-page/kepuasan";
import LandingPage from "@/app/darmasaba/_com/main-page/landing-page";
@@ -6,21 +7,85 @@ import Penghargaan from "@/app/darmasaba/_com/main-page/penghargaan";
import Potensi from "@/app/darmasaba/_com/main-page/potensi";
import colors from "@/con/colors";
import SDGS from "./_com/main-page/sdgs";
// import ApiFetch from "@/lib/api-fetch";
import { Box, Stack } from "@mantine/core";
import Apbdes from "./_com/main-page/apbdes";
import Prestasi from "./_com/main-page/prestasi";
import ScrollToTopButton from "./_com/scrollToTopButton";
import RunningText from "./_com/RunningText";
import { useProxy } from "valtio/utils";
import stateDashboardBerita from "../admin/(dashboard)/_state/desa/berita";
import { useEffect, useMemo } from "react";
import stateDesaPengumuman from "../admin/(dashboard)/_state/desa/pengumuman";
export default function Page() {
const featured = useProxy(stateDashboardBerita.berita.findFirst);
const loadingFeatured = featured.loading;
const pengumuman = useProxy(stateDesaPengumuman.pengumuman.findFirst);
const loadingPengumuman = pengumuman.loading;
useEffect(() => {
if (!featured.data && !loadingFeatured) {
stateDashboardBerita.berita.findFirst.load();
}
}, [featured.data, loadingFeatured]);
useEffect(() => {
if (!pengumuman.data && !loadingPengumuman) {
stateDesaPengumuman.pengumuman.findFirst.load();
}
}, [pengumuman.data, loadingPengumuman]);
// Memoize news data untuk performa lebih baik
const newsData = useMemo(() => {
const items = [];
// Tambahkan judul berita jika ada
if (featured.data?.judul) {
items.push(`📰 BERITA: ${featured.data.judul}`);
}
// Tambahkan content berita (akan di-strip HTML di component)
if (featured.data?.content) {
items.push(featured.data.content);
}
// Tambahkan judul pengumuman jika ada
if (pengumuman.data?.judul) {
items.push(`📢 PENGUMUMAN: ${pengumuman.data.judul}`);
}
// Tambahkan content pengumuman
if (pengumuman.data?.content) {
items.push(pengumuman.data.content);
}
// Jika tidak ada data, return default message
if (items.length === 0) {
return [
"Selamat datang di Portal Desa Darmasaba",
"Jam operasional kantor: Senin - Jumat 08:00 - 17:00"
];
}
return items;
}, [featured.data, pengumuman.data]);
return (
<Box>
<Stack
bg={colors.grey[1]}
gap={"1.5rem"}
bg={colors.grey[1]}
gap={0}
>
<RunningText
news={newsData}
autoSpeed={false}
maxLength={150} // Potong text panjang
speed={100} // Base speed (tidak dipakai jika autoSpeed=true)
bgColor="#1e5a7e"
textColor="white"
/>
<LandingPage />
<Penghargaan />
<Layanan />
@@ -35,4 +100,4 @@ export default function Page() {
<ScrollToTopButton />
</Box>
);
}
}

View File

@@ -292,7 +292,8 @@ const navbarListMenu = [
href: "/darmasaba/lingkungan/konservasi-adat-bali"
}
]
}, {
},
{
id: "8",
name: "Pendidikan",
children: [
@@ -332,6 +333,17 @@ const navbarListMenu = [
href: "/darmasaba/pendidikan/data-pendidikan"
}
]
},
{
id: "9",
name: "Musik",
children: [
{
id: "9.1",
name: "Musik Desa",
href: "/darmasaba/musik/musik-desa"
}
]
}
]

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