Compare commits

...

5 Commits

Author SHA1 Message Date
9dbe172165 Fix QC Kak Inno Tgl 4 & 5 Desember
Fix QC Kak Ayu Tgl 4 & 5 Desember
Fix QC Pak Jun Tgl 5 Desember
2025-12-09 12:00:27 +08:00
cc318d4d54 Fix QC Kak Inno Tgl 4 & 5 Desember
Fix QC Kak Ayu Tgl 4 & 5 Desember
Fix QC Pak Jun Tgl 5 Desember
2025-12-09 10:28:17 +08:00
dcb8017594 Fix undefined ke detail berita terbaru 2025-12-05 17:42:04 +08:00
ec3ad12531 Fix Notifikasi saat ada berita atau pengumuman baru, notifikasi baru muncul. Ga setiap masuk landing page ada notifikasi 2025-12-05 14:30:53 +08:00
dad44c0537 Fix Menu Gallery : Gallery Foto
Fix detail berita
2025-12-05 10:56:03 +08:00
43 changed files with 2385 additions and 1582 deletions

View File

@@ -828,11 +828,11 @@ model DokterdanTenagaMedis {
name String name String
specialist String specialist String
jadwal String jadwal String
jadwalLibur String jadwalLibur String?
jamBukaOperasional String jamBukaOperasional String?
jamTutupOperasional String jamTutupOperasional String?
jamBukaLibur String jamBukaLibur String?
jamTutupLibur String jamTutupLibur String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime @default(now()) deletedAt DateTime @default(now())

BIN
public/mangupuraaward.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

View File

@@ -6,145 +6,176 @@ import { z } from "zod";
const templateForm = z.object({ const templateForm = z.object({
name: z.string().min(3, "Nama minimal 3 karakter"), name: z.string().min(3, "Nama minimal 3 karakter"),
nik: z.string().min(3, "NIK minimal 3 karakter"), nik: z
notelp: z.string().min(3, "Nomor Telepon minimal 3 karakter"), .string()
.min(3, "NIK minimal 3 karakter")
.max(16, "NIK maksimal 16 angka"),
notelp: z
.string()
.min(3, "Nomor Telepon minimal 3 karakter")
.max(15, "Nomor Telepon maksimal 15 angka"),
alamat: z.string().min(3, "Alamat minimal 3 karakter"), alamat: z.string().min(3, "Alamat minimal 3 karakter"),
email: z.string().min(3, "Email minimal 3 karakter"), email: z.string().min(3, "Email minimal 3 karakter"),
jenisInformasiDimintaId: z.string().nonempty(), jenisInformasiDimintaId: z.string().nonempty(),
caraMemperolehInformasiId: z.string().nonempty(), caraMemperolehInformasiId: z.string().nonempty(),
caraMemperolehSalinanInformasiId: z.string().nonempty(), caraMemperolehSalinanInformasiId: z.string().nonempty(),
}) });
const jenisInformasiDiminta = proxy({ const jenisInformasiDiminta = proxy({
findMany: { findMany: {
data: null as data: null as
| null | null
| Prisma.JenisInformasiDimintaGetPayload<{ omit: { isActive: true } }>[], | Prisma.JenisInformasiDimintaGetPayload<{ omit: { isActive: true } }>[],
async load(){ async load() {
const res = await ApiFetch.api.ppid.permohonaninformasipublik.jenisInformasi["find-many"].get(); const res =
if (res.status === 200) { await ApiFetch.api.ppid.permohonaninformasipublik.jenisInformasi[
jenisInformasiDiminta.findMany.data = res.data?.data ?? []; "find-many"
} ].get();
} if (res.status === 200) {
} jenisInformasiDiminta.findMany.data = res.data?.data ?? [];
}) }
},
},
});
const caraMemperolehInformasi = proxy({ const caraMemperolehInformasi = proxy({
findMany: { findMany: {
data: null as data: null as
| null | null
| Prisma.CaraMemperolehInformasiGetPayload<{ omit: { isActive: true } }>[], | Prisma.CaraMemperolehInformasiGetPayload<{
async load() { omit: { isActive: true };
const res = await ApiFetch.api.ppid.permohonaninformasipublik.memperolehInformasi["find-many"].get(); }>[],
if (res.status === 200) { async load() {
caraMemperolehInformasi.findMany.data = res.data?.data ?? []; const res =
} await ApiFetch.api.ppid.permohonaninformasipublik.memperolehInformasi[
} "find-many"
} ].get();
}) if (res.status === 200) {
caraMemperolehInformasi.findMany.data = res.data?.data ?? [];
}
},
},
});
const caraMemperolehSalinanInformasi = proxy({ const caraMemperolehSalinanInformasi = proxy({
findMany: { findMany: {
data: null as data: null as
| null | null
| Prisma.CaraMemperolehSalinanInformasiGetPayload<{ omit: { isActive: true } }>[], | Prisma.CaraMemperolehSalinanInformasiGetPayload<{
async load() { omit: { isActive: true };
const res = await ApiFetch.api.ppid.permohonaninformasipublik.salinanInformasi["find-many"].get(); }>[],
if (res.status === 200) { async load() {
caraMemperolehSalinanInformasi.findMany.data = res.data?.data ?? []; const res =
} await ApiFetch.api.ppid.permohonaninformasipublik.salinanInformasi[
} "find-many"
} ].get();
}) if (res.status === 200) {
console.log(caraMemperolehSalinanInformasi) caraMemperolehSalinanInformasi.findMany.data = res.data?.data ?? [];
}
},
},
});
console.log(caraMemperolehSalinanInformasi);
type PermohonanInformasiPublikForm = Prisma.PermohonanInformasiPublikGetPayload<{ type PermohonanInformasiPublikForm =
Prisma.PermohonanInformasiPublikGetPayload<{
select: { select: {
name: true; name: true;
nik: true; nik: true;
notelp: true; notelp: true;
alamat: true; alamat: true;
email: true; email: true;
jenisInformasiDimintaId: true; jenisInformasiDimintaId: true;
caraMemperolehInformasiId: true; caraMemperolehInformasiId: true;
caraMemperolehSalinanInformasiId: true; caraMemperolehSalinanInformasiId: true;
}; };
}>; }>;
const statepermohonanInformasiPublik = proxy({ const statepermohonanInformasiPublik = proxy({
create: { create: {
form: {} as PermohonanInformasiPublikForm, form: {} as PermohonanInformasiPublikForm,
loading: false, loading: false,
async create(){ async create() {
const cek = templateForm.safeParse(statepermohonanInformasiPublik.create.form); const cek = templateForm.safeParse(
if(!cek.success) { statepermohonanInformasiPublik.create.form
const err = `[${cek.error.issues );
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`; if (!cek.success) {
return toast.error(err); toast.error(cek.error.issues.map((i) => i.message).join("\n"));
} return false; // ⬅️ tambahkan return false
try { }
statepermohonanInformasiPublik.create.loading = true;
const res = await ApiFetch.api.ppid.permohonaninformasipublik["create"].post(statepermohonanInformasiPublik.create.form); try {
if (res.status === 200) { statepermohonanInformasiPublik.create.loading = true;
statepermohonanInformasiPublik.findMany.load(); const res = await ApiFetch.api.ppid.permohonaninformasipublik[
return toast.success("Sukses menambahkan"); "create"
} ].post(statepermohonanInformasiPublik.create.form);
return toast.error("failed create");
} catch (error) { if (res.data?.success === false) {
console.log((error as Error).message); toast.error(res.data?.message);
} finally { return false; // ⬅️ gagal
statepermohonanInformasiPublik.create.loading = false;
}
} }
toast.success("Sukses menambahkan");
return true; // ⬅️ sukses
} catch {
toast.error("Terjadi kesalahan server");
return false;
} finally {
statepermohonanInformasiPublik.create.loading = false;
}
}, },
findMany: { },
data: null as findMany: {
| Prisma.PermohonanInformasiPublikGetPayload<{ include: { data: null as
caraMemperolehSalinanInformasi: true, | Prisma.PermohonanInformasiPublikGetPayload<{
jenisInformasiDiminta: true,
caraMemperolehInformasi: true,
} }>[]
| null,
async load() {
const res = await ApiFetch.api.ppid.permohonaninformasipublik["find-many"].get();
if (res.status === 200) {
statepermohonanInformasiPublik.findMany.data = res.data?.data ?? [];
}
}
},
findUnique: {
data: null as Prisma.PermohonanInformasiPublikGetPayload<{
include: { include: {
jenisInformasiDiminta: true, caraMemperolehSalinanInformasi: true;
caraMemperolehInformasi: true, jenisInformasiDiminta: true;
caraMemperolehSalinanInformasi: true, caraMemperolehInformasi: true;
}; };
}> | null, }>[]
async load(id: string) { | null,
try { async load() {
const res = await fetch(`/api/ppid/permohonaninformasipublik/${id}`); const res = await ApiFetch.api.ppid.permohonaninformasipublik[
if (res.ok) { "find-many"
const data = await res.json(); ].get();
statepermohonanInformasiPublik.findUnique.data = data.data ?? null; if (res.status === 200) {
} else { statepermohonanInformasiPublik.findMany.data = res.data?.data ?? [];
console.error("Failed to fetch program inovasi:", res.statusText); }
statepermohonanInformasiPublik.findUnique.data = null; },
} },
} catch (error) { findUnique: {
console.error("Error fetching program inovasi:", error); data: null as Prisma.PermohonanInformasiPublikGetPayload<{
statepermohonanInformasiPublik.findUnique.data = null; include: {
} jenisInformasiDiminta: true;
}, caraMemperolehInformasi: true;
}, caraMemperolehSalinanInformasi: true;
};
}) }> | null,
async load(id: string) {
try {
const res = await fetch(`/api/ppid/permohonaninformasipublik/${id}`);
if (res.ok) {
const data = await res.json();
statepermohonanInformasiPublik.findUnique.data = data.data ?? null;
} else {
console.error("Failed to fetch program inovasi:", res.statusText);
statepermohonanInformasiPublik.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching program inovasi:", error);
statepermohonanInformasiPublik.findUnique.data = null;
}
},
},
});
const statepermohonanInformasiPublikForm = proxy({ const statepermohonanInformasiPublikForm = proxy({
statepermohonanInformasiPublik, statepermohonanInformasiPublik,
jenisInformasiDiminta, jenisInformasiDiminta,
caraMemperolehInformasi, caraMemperolehInformasi,
caraMemperolehSalinanInformasi, caraMemperolehSalinanInformasi,
}) });
export default statepermohonanInformasiPublikForm; export default statepermohonanInformasiPublikForm;

View File

@@ -5,82 +5,99 @@ import { proxy } from "valtio";
import { z } from "zod"; import { z } from "zod";
const templateForm = z.object({ const templateForm = z.object({
name: z.string().min(3, "Nama minimal 3 karakter"), name: z.string().min(3, "Nama minimal 3 karakter"),
email: z.string().min(3, "Email minimal 3 karakter"), email: z.string().min(3, "Email minimal 3 karakter"),
notelp: z.string().min(3, "Nomor Telepon minimal 3 karakter"), notelp: z
alasan: z.string().min(3, "Alasan minimal 3 karakter"), .string()
}) .min(3, "Nomor Telepon minimal 3 karakter")
.max(15, "Nomor Telepon maksimal 15 angka"),
alasan: z.string().min(3, "Alasan minimal 3 karakter"),
});
type PermohonanKeberatanInformasiForm = Prisma.FormulirPermohonanKeberatanGetPayload<{ type PermohonanKeberatanInformasiForm =
Prisma.FormulirPermohonanKeberatanGetPayload<{
select: { select: {
name: true; name: true;
email: true; email: true;
notelp: true; notelp: true;
alasan: true; alasan: true;
}; };
}>; }>;
const permohonanKeberatanInformasi = proxy({ const permohonanKeberatanInformasi = proxy({
create: { create: {
form: {} as PermohonanKeberatanInformasiForm, form: {} as PermohonanKeberatanInformasiForm,
loading: false, loading: false,
async create(){ async create() {
const cek = templateForm.safeParse(permohonanKeberatanInformasi.create.form); const cek = templateForm.safeParse(
if(!cek.success) { permohonanKeberatanInformasi.create.form
const err = `[${cek.error.issues );
.map((v) => `${v.path.join(".")}`) if (!cek.success) {
.join("\n")}] required`; toast.error(cek.error.issues.map((i) => i.message).join("\n"));
return toast.error(err); return false; // ⬅️ tambahkan return false
}
try {
permohonanKeberatanInformasi.create.loading = true;
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik["create"].post(permohonanKeberatanInformasi.create.form);
if (res.status === 200) {
permohonanKeberatanInformasi.findMany.load();
return toast.success("Sukses menambahkan");
}
return toast.error("failed create");
} catch (error) {
console.log((error as Error).message);
} finally {
permohonanKeberatanInformasi.create.loading = false;
}
},
},
findMany: {
data: null as
| Prisma.FormulirPermohonanKeberatanGetPayload<{omit: {isActive: true}}>[]
| null,
async load() {
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik["find-many"].get();
if (res.status === 200) {
permohonanKeberatanInformasi.findMany.data = res.data?.data ?? [];
}
}
},
findUnique: {
data: null as Prisma.FormulirPermohonanKeberatanGetPayload<{
omit: {
isActive: true;
};
}> | null,
async load(id: string) {
try {
const res = await fetch(`/api/ppid/permohonankeberataninformasipublik/${id}`);
if (res.ok) {
const data = await res.json();
permohonanKeberatanInformasi.findUnique.data = data.data ?? null;
} else {
console.error("Failed to fetch permohonan keberatan informasi:", res.statusText);
permohonanKeberatanInformasi.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching permohonan keberatan informasi:", error);
permohonanKeberatanInformasi.findUnique.data = null;
}
},
} }
try {
permohonanKeberatanInformasi.create.loading = true;
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik[
"create"
].post(permohonanKeberatanInformasi.create.form);
if (res.data?.success === false) {
toast.error(res.data?.message);
return false; // ⬅️ gagal
}
toast.success("Sukses menambahkan");
return true; // ⬅️ sukses
} catch {
toast.error("Terjadi kesalahan server");
return false;
} finally {
permohonanKeberatanInformasi.create.loading = false;
}
},
},
findMany: {
data: null as
| Prisma.FormulirPermohonanKeberatanGetPayload<{
omit: { isActive: true };
}>[]
| null,
async load() {
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik[
"find-many"
].get();
if (res.status === 200) {
permohonanKeberatanInformasi.findMany.data = res.data?.data ?? [];
}
},
},
findUnique: {
data: null as Prisma.FormulirPermohonanKeberatanGetPayload<{
omit: {
isActive: true;
};
}> | null,
async load(id: string) {
try {
const res = await fetch(
`/api/ppid/permohonankeberataninformasipublik/${id}`
);
if (res.ok) {
const data = await res.json();
permohonanKeberatanInformasi.findUnique.data = data.data ?? null;
} else {
console.error(
"Failed to fetch permohonan keberatan informasi:",
res.statusText
);
permohonanKeberatanInformasi.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching permohonan keberatan informasi:", error);
permohonanKeberatanInformasi.findUnique.data = null;
}
},
},
}); });
export default permohonanKeberatanInformasi; export default permohonanKeberatanInformasi;

View File

@@ -0,0 +1,303 @@
/* eslint-disable react-hooks/exhaustive-deps */
"use client";
import EditEditor from "@/app/admin/(dashboard)/_com/editEditor";
import stateGallery from "@/app/admin/(dashboard)/_state/desa/gallery";
import colors from "@/con/colors";
import ApiFetch from "@/lib/api-fetch";
import {
ActionIcon,
Box,
Button,
Group,
Image,
Loader,
Paper,
Stack,
Text,
TextInput,
Title
} from "@mantine/core";
import { Dropzone } from "@mantine/dropzone";
import {
IconArrowBack,
IconPhoto,
IconUpload,
IconX,
} from "@tabler/icons-react";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
import { useProxy } from "valtio/utils";
function EditFoto() {
const FotoState = useProxy(stateGallery.foto);
const router = useRouter();
const params = useParams();
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [formData, setFormData] = useState({
name: "",
deskripsi: "",
imagesId: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
name: "",
deskripsi: "",
imagesId: "",
imageUrl: "",
});
// Load kategori + Foto
useEffect(() => {
FotoState.findMany.load();
const loadFoto = async () => {
const id = params?.id as string;
if (!id) return;
try {
const data = await FotoState.update.load(id);
if (data) {
setFormData({
name: data.name || "",
deskripsi: data.deskripsi || "",
imagesId: data.imagesId || "",
});
setOriginalData({
name: data.name || "",
deskripsi: data.deskripsi || "",
imagesId: data.imagesId || "",
imageUrl: data.imageGalleryFoto?.link || ""
});
if (data?.imageGalleryFoto?.link) {
setPreviewImage(data.imageGalleryFoto.link);
}
}
} catch (error) {
console.error("Error loading Foto:", error);
toast.error("Gagal memuat data Foto");
}
};
loadFoto();
}, [params?.id]);
const handleChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
// Update global state hanya sekali di sini
FotoState.update.form = {
...FotoState.update.form,
...formData,
};
if (file) {
const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
});
const uploaded = res.data?.data;
if (!uploaded?.id) {
return toast.error("Gagal upload gambar");
}
FotoState.update.form.imagesId = uploaded.id;
}
await FotoState.update.update();
toast.success("Foto berhasil diperbarui!");
router.push("/admin/desa/gallery/foto");
} catch (error) {
console.error("Error updating foto:", error);
toast.error("Terjadi kesalahan saat memperbarui foto");
} finally {
setIsSubmitting(false);
}
};
const handleResetForm = () => {
setFormData({
name: originalData.name,
deskripsi: originalData.deskripsi,
imagesId: originalData.imagesId,
});
setPreviewImage(originalData.imageUrl || null);
setFile(null);
toast.info("Form dikembalikan ke data awal");
};
return (
<Box px={{ base: "sm", md: "lg" }} py="md">
{/* Header */}
<Group mb="md">
<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 Foto
</Title>
</Group>
{/* Form */}
<Paper
w={{ base: "100%", md: "50%" }}
bg={colors["white-1"]}
p="lg"
radius="md"
shadow="sm"
style={{ border: "1px solid #e0e0e0" }}
>
<Stack gap="md">
<TextInput
label="Judul Foto"
placeholder="Masukkan judul foto"
value={formData.name}
onChange={(e) => handleChange("name", e.target.value)}
required
/>
{/* Upload Gambar */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar Foto
</Text>
<Dropzone
onDrop={(files) => {
const selectedFile = files[0];
if (selectedFile) {
setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile));
}
}}
onReject={() =>
toast.error("File tidak valid, gunakan format gambar")
}
maxSize={5 * 1024 ** 2}
accept={{ "image/*": [] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept>
<IconUpload
size={48}
color={colors["blue-button"]}
stroke={1.5}
/>
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="red" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle>
<Stack gap="xs" align="center">
<Text size="md" fw={500}>
Seret gambar atau klik untuk memilih file
</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
</Text>
</Stack>
</Group>
</Dropzone>
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
style={{
maxHeight: 220,
objectFit: "contain",
border: `1px solid ${colors["blue-button"]}`,
}}
loading="lazy"
/>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
{/* Deskripsi */}
<Box>
<Text fz="sm" fw="bold">
Deskripsi Foto
</Text>
<EditEditor
value={formData.deskripsi}
onChange={(htmlContent) =>
setFormData((prev) => ({ ...prev, deskripsi: htmlContent }))
}
/>
</Box>
{/* Action */}
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default EditFoto;

View File

@@ -0,0 +1,175 @@
'use client';
import { Box, Button, Group, Paper, Skeleton, Stack, Text, Alert } from '@mantine/core';
import Image from 'next/image';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconTrash, IconPhoto } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import colors from '@/con/colors';
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
function DetailFoto() {
const FotoState = useProxy(stateGallery.foto);
const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [imageError, setImageError] = useState(false);
const params = useParams();
const router = useRouter();
useShallowEffect(() => {
FotoState.findUnique.load(params?.id as string);
}, []);
const handleHapus = () => {
if (selectedId) {
FotoState.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
router.push("/admin/desa/gallery/foto");
}
};
if (!FotoState.findUnique.data) {
return (
<Stack py={10}>
<Skeleton height={500} radius="md" />
</Stack>
);
}
const data = FotoState.findUnique.data;
const imageUrl = data.imageGalleryFoto?.link;
return (
<Box py={10}>
<Button
variant="subtle"
onClick={() => router.back()}
leftSection={<IconArrowBack size={24} color={colors['blue-button']} />}
mb={15}
>
Kembali
</Button>
<Paper
withBorder
// Gunakan max-width agar tidak terlalu lebar di desktop
maw={800}
w="100%"
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
>
<Stack gap="md">
<Text fz={{ base: 'xl', md: '2xl' }} fw="bold" c={colors['blue-button']}>
Detail Foto
</Text>
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="sm">
<Box>
<Text fz="lg" fw="bold">Judul Foto</Text>
<Text fz="md" c="dimmed">{data.name || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Deskripsi</Text>
<Text
fz="md"
c="dimmed"
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }}
/>
</Box>
<Box>
<Text fz="lg" fw="bold">Gambar</Text>
{imageUrl ? (
<Box
pos="relative"
style={{
width: '100%',
maxWidth: '600px', // Set a maximum width
margin: '0 auto', // Center the container
aspectRatio: '16/9', // Use 16:9 aspect ratio
borderRadius: 8,
overflow: 'hidden',
position: 'relative'
}}
>
<Image
src={imageUrl}
alt={data.name || 'Gambar Foto'}
fill
style={{
objectFit: 'contain', // Changed from 'cover' to 'contain' to show full image
width: '100%',
height: '100%',
position: 'absolute',
top: 0,
left: 0
}}
loading="lazy"
onError={() => setImageError(true)}
/>
</Box>
) : imageError ? (
<Alert
color="orange"
icon={<IconPhoto size={16} />}
title="Gagal memuat gambar"
radius="md"
>
Gambar tidak dapat ditampilkan.
</Alert>
) : (
<Text fz="sm" c="dimmed">Tidak ada gambar</Text>
)}
</Box>
{/* Action Buttons */}
<Group gap="sm" justify="flex-start">
<Button
color="red"
onClick={() => {
setSelectedId(data.id);
setModalHapus(true);
}}
variant="light"
radius="md"
size="md"
>
<IconTrash size={20} />
</Button>
<Button
color="green"
onClick={() => router.push(`/admin/desa/gallery/foto/${data.id}/edit`)}
variant="light"
radius="md"
size="md"
>
<IconEdit size={20} />
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Paper>
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleHapus}
text="Apakah Anda yakin ingin menghapus foto ini?"
/>
</Box>
);
}
export default DetailFoto;

View File

@@ -0,0 +1,228 @@
'use client';
import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch';
import {
ActionIcon,
Box,
Button,
Group,
Paper,
Stack,
Text,
TextInput,
Title,
Loader,
Image
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
function CreateFoto() {
const FotoState = useProxy(stateGallery.foto);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const resetForm = () => {
FotoState.create.form = {
name: '',
deskripsi: '',
imagesId: '',
};
setPreviewImage(null);
setFile(null);
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
if (!file) {
return toast.warn('Silakan pilih file gambar terlebih dahulu');
}
const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
});
const uploaded = res.data?.data;
if (!uploaded?.id) {
return toast.error('Gagal mengunggah gambar, silakan coba lagi');
}
FotoState.create.form.imagesId = uploaded.id;
await FotoState.create.create();
resetForm();
router.push('/admin/desa/gallery/foto');
} catch (error) {
console.error('Error creating foto:', error);
toast.error('Terjadi kesalahan saat membuat foto');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header Back Button + Title */}
<Group mb="md">
<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 Foto
</Title>
</Group>
{/* Card Form */}
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
{/* Judul */}
<TextInput
label="Judul Foto"
placeholder="Masukkan judul Foto"
value={FotoState.create.form.name}
onChange={(e) => {
FotoState.create.form.name = e.currentTarget.value;
}}
required
/>
<Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar Berita
</Text>
<Dropzone
onDrop={(files) => {
const selectedFile = files[0];
if (selectedFile) {
setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile));
}
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept>
<IconUpload size={48} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={48} color="var(--mantine-color-dimmed)" stroke={1.5} />
</Dropzone.Idle>
</Group>
<Text ta="center" mt="sm" size="sm" color="dimmed">
Seret gambar atau klik untuk memilih file (maks 5MB)
</Text>
</Dropzone>
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
loading="lazy"
/>
{/* Tombol hapus (pojok kanan atas) */}
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
{/* Deskripsi */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Deskripsi Foto
</Text>
<CreateEditor
value={FotoState.create.form.deskripsi}
onChange={(val) => {
FotoState.create.form.deskripsi = val;
}}
/>
</Box>
{/* Button Submit */}
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default CreateFoto;

View File

@@ -1,157 +1,163 @@
"use client"; 'use client'
import stateFileStorage from "@/state/state-list-image"; import colors from '@/con/colors';
import { import {
ActionIcon,
Box, Box,
Card, Button,
Flex, Center,
Group, Group,
Image,
Pagination, Pagination,
Paper, Paper,
SimpleGrid, Skeleton,
Stack, Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text, Text,
TextInput,
Title Title
} from "@mantine/core"; } from '@mantine/core';
import { useShallowEffect } from "@mantine/hooks"; import { useShallowEffect } from '@mantine/hooks';
import { IconSearch, IconTrash, IconX } from "@tabler/icons-react"; import { IconDeviceImac, IconPlus, IconSearch } from '@tabler/icons-react';
import { motion } from "framer-motion"; import { useRouter } from 'next/navigation';
import toast from "react-simple-toasts"; import { useState } from 'react';
import { useSnapshot } from "valtio"; import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header';
export default function ListImage() { import stateGallery from '../../../_state/desa/gallery';
const { list, total } = useSnapshot(stateFileStorage);
useShallowEffect(() => {
stateFileStorage.load();
}, []);
let timeOut: NodeJS.Timer;
function Foto() {
const [search, setSearch] = useState("");
return ( return (
<Stack p="lg" gap="lg"> <Box>
<Flex justify="space-between" align="center" wrap="wrap" gap="md"> <HeaderSearch
<Title order={2} fw={700}> title='Foto'
Galeri Foto placeholder='Cari judul atau deskripsi foto...'
</Title> searchIcon={<IconSearch size={20} />}
<TextInput value={search}
radius="xl" onChange={(e) => setSearch(e.currentTarget.value)}
size="md" />
placeholder="Cari foto berdasarkan nama..." <ListFoto search={search} />
leftSection={<IconSearch size={18} />} </Box>
rightSection={
<ActionIcon
variant="light"
color="gray"
radius="xl"
onClick={() => stateFileStorage.load()}
>
<IconX size={18} />
</ActionIcon>
}
onChange={(e) => {
if (timeOut) clearTimeout(timeOut);
timeOut = setTimeout(() => {
stateFileStorage.load({ search: e.target.value });
}, 300);
}}
/>
</Flex>
<Paper withBorder radius="lg" p="md" shadow="sm">
{list && list.length > 0 ? (
<SimpleGrid
cols={{ base: 2, sm: 3, md: 5, lg: 8 }}
spacing="md"
verticalSpacing="md"
>
{list.map((v, k) => (
<Card
key={k}
withBorder
radius="md"
shadow="sm"
className="hover:shadow-md transition-all duration-200"
>
<Stack gap="xs">
<motion.div
onClick={() => {
navigator.clipboard.writeText(v.url);
toast("Tautan foto berhasil disalin");
}}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
style={{ cursor: "pointer" }}
>
<Image
src={`${v.url}?size=200`}
alt={v.name}
radius="md"
h={120}
fit="cover"
loading="lazy"
/>
</motion.div>
<Box>
<Text size="sm" fw={500} lineClamp={2}>
{v.name}
</Text>
</Box>
<Group justify="space-between" align="center" pt="xs">
<ActionIcon
variant="subtle"
color="red"
radius="md"
onClick={() => {
stateFileStorage
.del({ id: v.id })
.finally(() => toast("Foto berhasil dihapus"));
}}
>
<IconTrash size={18} />
</ActionIcon>
</Group>
</Stack>
</Card>
))}
</SimpleGrid>
) : (
<Stack align="center" justify="center" py="xl" gap="sm">
<Image
src="https://cdn-icons-png.flaticon.com/512/4076/4076549.png"
alt="Kosong"
w={120}
h={120}
fit="contain"
opacity={0.7}
loading="lazy"
/>
<Text c="dimmed" ta="center">
Belum ada foto yang tersedia
</Text>
</Stack>
)}
</Paper>
{total && total > 1 && (
<Flex justify="center">
<Pagination
total={total}
value={stateFileStorage.page} // Changed from page to value
size="md"
radius="md"
withEdges
onChange={(page) => {
stateFileStorage.load({ page });
}}
/>
</Flex>
)}
</Stack>
); );
} }
function ListFoto({ search }: { search: string }) {
const FotoState = useProxy(stateGallery.foto)
const router = useRouter();
const {
data,
page,
totalPages,
loading,
load,
} = FotoState.findMany;
useShallowEffect(() => {
load(page, 10, search)
}, [page, search])
const filteredData = data || []
if (loading || !data) {
return (
<Stack py={10}>
<Skeleton height={600} radius="md" />
</Stack>
)
}
return (
<Box py={10}>
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
<Group justify="space-between" mb="md">
<Title order={4}>Daftar Foto</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/desa/gallery/foto/create')}
>
Tambah Baru
</Button>
</Group>
<Box style={{ overflowX: "auto" }}>
<Table highlightOnHover>
<TableThead>
<TableTr>
<TableTh style={{ width: '25%' }}>Judul Foto</TableTh>
<TableTh style={{ width: '20%' }}>Tanggal</TableTh>
<TableTh style={{ width: '30%' }}>Deskripsi</TableTh>
<TableTh style={{ width: '15%' }}>Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd style={{ width: '25%' }}>
<Box w={200}>
<Text fw={500} truncate="end" lineClamp={1}>{item.name}</Text>
</Box>
</TableTd>
<TableTd style={{ width: '20%' }}>
<Box w={200}>
<Text fz="sm" c="dimmed">
{new Date(item.createdAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</Text>
</Box>
</TableTd>
<TableTd style={{ width: '30%' }}>
<Box w={200}>
<Text fz="sm" truncate="end" lineClamp={1} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
</Box>
</TableTd>
<TableTd style={{ width: '15%' }}>
<Button
variant="light"
color="blue"
onClick={() => router.push(`/admin/desa/gallery/foto/${item.id}`)}
>
<IconDeviceImac size={20} />
<Text ml={5}>Detail</Text>
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={4}>
<Center py={20}>
<Text c="dimmed">Tidak ada foto yang cocok</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
</Paper>
<Center>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10)
window.scrollTo({ top: 0, behavior: 'smooth' })
}}
total={totalPages}
mt="md"
mb="md"
color="blue"
radius="md"
/>
</Center>
</Box>
);
}
export default Foto;

View File

@@ -30,12 +30,13 @@ function Page() {
return ( return (
<Paper bg={colors['white-1']} p="lg" radius="md" shadow="sm"> <Paper bg={colors['white-1']} p="lg" radius="md" shadow="sm">
<Stack gap="md"> <Stack gap="md">
<Grid align="center"> <Grid>
<GridCol span={{ base: 12, md: 11 }}> <GridCol span={{ base: 12, md: 11 }}>
<Title order={3} c={colors['blue-button']}>Preview Profil PPID</Title> <Title order={3} c={colors['blue-button']}>Preview Profil PPID</Title>
</GridCol> </GridCol>
<GridCol span={{ base: 12, md: 1 }}> <GridCol span={{ base: 12, md: 1 }}>
<Button <Button
w={{base: '100%', md: "110%"}}
c="green" c="green"
variant="light" variant="light"
leftSection={<IconEdit size={18} stroke={2} />} leftSection={<IconEdit size={18} stroke={2} />}

View File

@@ -6,33 +6,24 @@ import path from "path";
const beritaDelete = async (context: Context) => { const beritaDelete = async (context: Context) => {
const id = context.params?.id as string; const id = context.params?.id as string;
if (!id) { if (!id) return { status: 400, body: "ID tidak diberikan" };
return {
status: 400,
body: "ID tidak diberikan",
};
}
const berita = await prisma.berita.findUnique({ const berita = await prisma.berita.findUnique({
where: { id }, where: { id },
include: { include: { image: true, kategoriBerita: true },
image: true,
kategoriBerita: true, // pastikan relasi image sudah ada di prisma schema
},
}); });
if (!berita) { if (!berita) return { status: 404, body: "Berita tidak ditemukan" };
return {
status: 404,
body: "Berita tidak ditemukan",
};
}
// Hapus file gambar dari filesystem jika ada // 1. HAPUS BERITA DULU
await prisma.berita.delete({ where: { id } });
// 2. BARU HAPUS FILE
if (berita.image) { if (berita.image) {
try { try {
const filePath = path.join(berita.image.path, berita.image.name); const filePath = path.join(berita.image.path, berita.image.name);
await fs.unlink(filePath); await fs.unlink(filePath);
await prisma.fileStorage.delete({ await prisma.fileStorage.delete({
where: { id: berita.image.id }, where: { id: berita.image.id },
}); });
@@ -41,15 +32,11 @@ const beritaDelete = async (context: Context) => {
} }
} }
// Hapus berita dari DB
await prisma.berita.delete({
where: { id },
});
return { return {
success: true, success: true,
message: "Berita dan file terkait berhasil dihapus", message: "Berita dan file terkait berhasil dihapus",
}; };
}; };
export default beritaDelete; export default beritaDelete;

View File

@@ -3,39 +3,55 @@ import { Prisma } from "@prisma/client";
import { Context } from "elysia"; import { Context } from "elysia";
type FormCreate = Prisma.PermohonanInformasiPublikGetPayload<{ type FormCreate = Prisma.PermohonanInformasiPublikGetPayload<{
select: { select: {
name: true; name: true;
nik: true; nik: true;
email: true; email: true;
notelp: true; notelp: true;
alamat: true; alamat: true;
jenisInformasiDimintaId: true; jenisInformasiDimintaId: true;
caraMemperolehInformasiId: true; caraMemperolehInformasiId: true;
caraMemperolehSalinanInformasiId: true; caraMemperolehSalinanInformasiId: true;
} };
}> }>;
export default async function permohonanInformasiPublikCreate(context: Context) {
const body = context.body as FormCreate;
await prisma.permohonanInformasiPublik.create({
data: {
name: body.name,
nik: body.nik,
email: body.email,
notelp: body.notelp,
alamat: body.alamat,
jenisInformasiDimintaId: body.jenisInformasiDimintaId,
caraMemperolehInformasiId: body.caraMemperolehInformasiId,
caraMemperolehSalinanInformasiId: body.caraMemperolehSalinanInformasiId,
}
})
export default async function permohonanInformasiPublikCreate(context: Context) {
const body = context.body as FormCreate;
// ========== VALIDASI NIK ==========
if (body.nik && body.nik.length > 16) {
return { return {
success: true, success: false,
message: "Permohonan Informasi Publik Berhasil Dibuat", status: 400,
data: { message: "Maksimal NIK adalah 16 angka",
...body, };
} }
}
// ========== VALIDASI NOMOR TELEPON ==========
if (body.notelp && body.notelp.length > 15) {
return {
success: false,
status: 400,
message: "Maksimal nomor telepon adalah 15 angka",
};
}
await prisma.permohonanInformasiPublik.create({
data: {
name: body.name,
nik: body.nik,
email: body.email,
notelp: body.notelp,
alamat: body.alamat,
jenisInformasiDimintaId: body.jenisInformasiDimintaId,
caraMemperolehInformasiId: body.caraMemperolehInformasiId,
caraMemperolehSalinanInformasiId: body.caraMemperolehSalinanInformasiId,
},
});
return {
success: true,
message: "Permohonan Informasi Publik Berhasil Dibuat",
data: { ...body },
};
} }

View File

@@ -3,31 +3,42 @@ import { Prisma } from "@prisma/client";
import { Context } from "elysia"; import { Context } from "elysia";
type FormCreate = Prisma.FormulirPermohonanKeberatanGetPayload<{ type FormCreate = Prisma.FormulirPermohonanKeberatanGetPayload<{
select: { select: {
name: true; name: true;
email: true; email: true;
notelp: true; notelp: true;
alasan: true; alasan: true;
} };
}> }>;
export default async function permohonanKeberatanInformasiPublikCreate(context: Context) { export default async function permohonanKeberatanInformasiPublikCreate(
const body = context.body as FormCreate; context: Context
) {
await prisma.formulirPermohonanKeberatan.create({ const body = context.body as FormCreate;
data: {
name: body.name,
email: body.email,
notelp: body.notelp,
alasan: body.alasan,
}
})
// ========== VALIDASI NOMOR TELEPON ==========
if (body.notelp && body.notelp.length > 15) {
return { return {
success: true, success: false,
message: "Permohonan Keberatan Informasi Publik Berhasil Dibuat", status: 400,
data: { message: "Maksimal nomor telepon adalah 15 angka",
...body, };
} }
}
} await prisma.formulirPermohonanKeberatan.create({
data: {
name: body.name,
email: body.email,
notelp: body.notelp,
alasan: body.alasan,
},
});
return {
success: true,
message: "Permohonan Keberatan Informasi Publik Berhasil Dibuat",
data: {
...body,
},
};
}

View File

@@ -0,0 +1,43 @@
// app/api/news/latest/route.ts
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
export async function GET() {
try {
const berita = await prisma.berita.findMany({
take: 3,
orderBy: { createdAt: "desc" },
include: { kategoriBerita: true },
});
const pengumuman = await prisma.pengumuman.findMany({
take: 3,
orderBy: { createdAt: "desc" },
include: { CategoryPengumuman: true },
});
const news = [
...berita.map((b) => ({
id: b.id,
type: "berita" as const,
title: b.judul,
content: b.content,
timestamp: b.createdAt,
kategoriBerita: b.kategoriBerita || undefined,
})),
...pengumuman.map((p) => ({
id: p.id,
type: "pengumuman" as const,
title: p.judul,
content: p.content,
timestamp: p.createdAt,
kategoriPengumuman: p.CategoryPengumuman || undefined,
})),
];
return NextResponse.json({ success: true, news }); // ✅ ganti 'data' jadi 'news'
} catch (error) {
console.error("API Error:", error);
return NextResponse.json({ success: false, error: "Gagal memuat data" }, { status: 500 });
}
}

View File

@@ -49,7 +49,7 @@ function Page() {
return ( return (
<Stack pos={"relative"} bg={colors.Bg} py={"xl"} gap={"22"} px={{ base: "md", md: 0 }}> <Stack pos={"relative"} bg={colors.Bg} pb={"xl"} gap={"xs"} px={{ base: "md", md: 0 }}>
<Group px={{ base: "md", md: 100 }}> <Group px={{ base: "md", md: 100 }}>
<NewsReader /> <NewsReader />
</Group> </Group>

View File

@@ -1,12 +1,44 @@
// app/desa/berita/BeritaLayoutClient.tsx // app/darmasaba/(pages)/desa/berita/layout.tsx
'use client' 'use client';
import dynamic from 'next/dynamic';
import { usePathname } from 'next/navigation';
import { ReactNode } from 'react';
import dynamic from 'next/dynamic';
import { Box } from '@mantine/core';
import BackButton from '../layanan/_com/BackButto';
import colors from '@/con/colors';
const LayoutTabsBerita = dynamic( const LayoutTabsBerita = dynamic(
() => import('./_lib/layoutTabs'), () => import('./_lib/layoutTabs'),
{ ssr: false } { ssr: false }
); );
export default function BeritaLayoutClient({ children }: { children: React.ReactNode }) { export default function BeritaLayoutClient({
children,
}: {
children: ReactNode;
}) {
const pathname = usePathname();
// Contoh path:
// - /darmasaba/desa/berita/semua → panjang 5 → list
// - /darmasaba/desa/berita/Pemerintahan → panjang 5 → list
// - /darmasaba/desa/berita/Pemerintahan/123 → panjang 6 → detail
const segments = pathname.split('/').filter(Boolean);
const isDetailPage = segments.length === 5; // [darmasaba, desa, berita, kategori, id]
if (isDetailPage) {
// Tampilkan tanpa tab menu
return (
<Box bg={colors.Bg}>
<Box pt={33} px={{ base: 'md', md: 100 }}>
<BackButton />
</Box>
{children}
</Box>
);
}
// Tampilkan dengan tab menu (untuk /semua atau /kategori)
return <LayoutTabsBerita>{children}</LayoutTabsBerita>; return <LayoutTabsBerita>{children}</LayoutTabsBerita>;
} }

View File

@@ -1,150 +0,0 @@
'use client';
import colors from '@/con/colors';
import { Box, Center, Image, Pagination, Paper, SimpleGrid, Stack, Text } from '@mantine/core';
import { useCallback, useEffect, useState } from 'react';
import ApiFetch from '@/lib/api-fetch';
interface FileItem {
id: string;
name: string;
link: string;
realName: string;
createdAt: string | Date;
category: string;
path: string;
mimeType: string;
}
export default function FotoContent() {
const [files, setFiles] = useState<FileItem[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const limit = 9; // ✅ ambil 12 data per page
const loadData = useCallback(async (pageNum: number, searchTerm: string) => {
setLoading(true);
try {
const query: Record<string, string> = {
category: 'image',
page: pageNum.toString(),
limit: limit.toString(),
};
if (searchTerm) query.search = searchTerm;
const response = await ApiFetch.api.fileStorage.findMany.get({ query });
if (response.status === 200 && response.data) {
setFiles(response.data.data || []);
setTotalPages(response.data.meta?.totalPages || 1);
} else {
setFiles([]);
}
} catch (err) {
console.error('Load error:', err);
setFiles([]);
} finally {
setLoading(false);
}
}, []);
// ✅ Initial load + update when URL/search changes
useEffect(() => {
const handleRouteChange = () => {
const urlParams = new URLSearchParams(window.location.search);
const urlSearch = urlParams.get('search') || '';
const urlPage = parseInt(urlParams.get('page') || '1');
setSearch(urlSearch);
setPage(urlPage);
loadData(urlPage, urlSearch);
};
const handleSearchUpdate = (e: Event) => {
const { search } = (e as CustomEvent).detail;
setSearch(search);
setPage(1);
loadData(1, search);
};
handleRouteChange();
window.addEventListener('popstate', handleRouteChange);
window.addEventListener('searchUpdate', handleSearchUpdate as EventListener);
return () => {
window.removeEventListener('popstate', handleRouteChange);
window.removeEventListener('searchUpdate', handleSearchUpdate as EventListener);
};
}, [loadData]);
// ✅ Update when page/search changes
useEffect(() => {
loadData(page, search);
}, [page, search, loadData]);
const updateURL = (newSearch: string, newPage: number) => {
const url = new URL(window.location.href);
if (newSearch) url.searchParams.set('search', newSearch);
else url.searchParams.delete('search');
if (newPage > 1) url.searchParams.set('page', newPage.toString());
else url.searchParams.delete('page');
window.history.pushState({}, '', url);
};
const handlePageChange = (newPage: number) => {
setPage(newPage);
updateURL(search, newPage);
};
if (loading && files.length === 0) {
return <Center>Memuat data...</Center>;
}
if (files.length === 0) {
return <Center>Tidak ada foto ditemukan</Center>;
}
return (
<Box pt={20} px={{ base: 'md', md: 100 }}>
<SimpleGrid cols={{ base: 1, md: 3 }}>
{files.map((file) => (
<Paper
key={file.id}
mb={50}
p="md"
radius={26}
bg={colors['white-trans-1']}
style={{ height: '100%' }}
>
<Box style={{ height: '250px', overflow: 'hidden', borderRadius: '12px' }}>
<Image
src={file.link}
alt={file.realName || file.name}
height={250}
width="100%"
style={{ objectFit: 'cover', height: '100%', width: '100%' }}
loading="lazy"
/>
</Box>
<Stack gap="sm" py={10}>
<Text fw="bold" fz={{ base: 'h4', md: 'h3' }}>
{file.realName || file.name}
</Text>
<Text fz="sm" c="dimmed">
{new Date(file.createdAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</Text>
</Stack>
</Paper>
))}
</SimpleGrid>
<Center mt="xl">
<Pagination total={totalPages} value={page} onChange={handlePageChange} />
</Center>
</Box>
);
}

View File

@@ -1,25 +1,168 @@
'use client' /* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import dynamic from 'next/dynamic'; import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
import { Suspense } from 'react'; import colors from '@/con/colors';
import {
Box,
Center,
Grid,
Image,
Pagination,
Paper,
Skeleton,
Stack,
Text,
Title,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconPhoto } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
// ✅ Load komponen tanpa SSR // Komponen kartu foto
const FotoContent = dynamic( function FotoCard({ item }: { item: any }) {
() => import('./Content'), const router = useRouter();
{
ssr: false, const handleClick = () => {
loading: () => <div>Memuat konten...</div> router.push(`/darmasaba/galeri/foto/${item.id}`);
} };
);
function PageContent() {
return ( return (
<Suspense fallback={<div>Memuat...</div>}> <Grid.Col span={{ base: 12, xs: 6, md: 4 }}>
<FotoContent /> <Paper
</Suspense> shadow="sm"
radius="md"
p={0}
onClick={handleClick}
style={{ cursor: 'pointer', transition: 'transform 0.2s' }}
onMouseEnter={(e) => (e.currentTarget.style.transform = 'scale(1.02)')}
onMouseLeave={(e) => (e.currentTarget.style.transform = 'scale(1)')}
>
{item.imageGalleryFoto?.link ? (
<Box
pos="relative"
style={{
paddingBottom: '100%', // ✅ Ubah ke 1:1 (square) — atau sesuaikan
overflow: 'hidden',
borderRadius: '4px 4px 0 0',
backgroundColor: '#f9f9f9', // ✅ background netral
}}
>
<Image
radius="lg"
src={item.imageGalleryFoto.link}
alt={item.name || 'Foto Galeri'}
p={10}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'contain', // ✅ Tampilkan utuh, jangan crop
objectPosition: 'center', // rata tengah
}}
loading="lazy"
/>
</Box>
) : (
<Center h={180} bg="gray.1">
<IconPhoto size={40} color="gray" />
</Center>
)}
<Stack p="md" gap={4}>
<Text fw={600} lineClamp={1}>
{item.name || 'Tanpa Judul'}
</Text>
{item.deskripsi && (
<Text fz="sm" c="dimmed" lineClamp={2} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
)}
<Text fz="xs" c="dimmed">
{new Date(item.createdAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</Text>
</Stack>
</Paper>
</Grid.Col>
); );
} }
export default function Page() { // Komponen utama
return <PageContent />; export default function GaleriFotoUser() {
const [search] = useState('');
return (
<Box py="xl" px={{ base: 'md', md: 'lg' }}>
{/* Header */}
<Title order={2} c={colors['blue-button']} mb="lg">
Galeri Foto Desa Darmasaba
</Title>
{/* Daftar Foto */}
<FotoList search={search} />
</Box>
);
}
function FotoList({ search }: { search: string }) {
const FotoState = useProxy(stateGallery.foto);
const { data, page, totalPages, loading, load } = FotoState.findMany;
useShallowEffect(() => {
load(page, 3, search); // ✅ 9 item per halaman
}, [page, search]);
if (loading) {
return (
<Grid mt="md">
{Array.from({ length: 3 }).map((_, i) => (
<Grid.Col key={i} span={{ base: 12, xs: 6, md: 4 }}>
<Skeleton height={280} radius="md" />
</Grid.Col>
))}
</Grid>
);
}
if (!data || data.length === 0) {
return (
<Center py="xl">
<Stack align="center" c="dimmed">
<IconPhoto size={48} />
<Text>Tidak ada foto ditemukan</Text>
</Stack>
</Center>
);
}
return (
<Stack mt="md" gap="xl">
<Grid>
{data.map((item) => (
<FotoCard key={item.id} item={item} />
))}
</Grid>
{/* Pagination */}
<Center>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 3, search);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
color="blue"
radius="md"
/>
</Center>
</Stack>
);
} }

View File

@@ -1,9 +1,36 @@
'use client'
import { usePathname } from "next/navigation";
import { ReactNode } from "react";
import LayoutTabsGalery from "./_lib/layoutTabs"; import LayoutTabsGalery from "./_lib/layoutTabs";
export default function LayoutGalery({ children }: { children: React.ReactNode }) { // export default function LayoutGalery({ children }: { children: React.ReactNode }) {
return ( // return (
<LayoutTabsGalery> // <LayoutTabsGalery>
{children} // {children}
</LayoutTabsGalery> // </LayoutTabsGalery>
) // )
// }
export default function BeritaLayoutClient({
children,
}: {
children: ReactNode;
}) {
const pathname = usePathname();
// Contoh path:
// - /darmasaba/desa/berita/semua → panjang 5 → list
// - /darmasaba/desa/berita/Pemerintahan → panjang 5 → list
// - /darmasaba/desa/berita/Pemerintahan/123 → panjang 6 → detail
const segments = pathname.split('/').filter(Boolean);
const isDetailPage = segments.length === 5; // [darmasaba, desa, berita, kategori, id]
if (isDetailPage) {
// Tampilkan tanpa tab menu
return <>{children}</>
}
// Tampilkan dengan tab menu (untuk /semua atau /kategori)
return <LayoutTabsGalery>{children}</LayoutTabsGalery>;
} }

View File

@@ -4,26 +4,27 @@ import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { import {
Box, Box,
Button,
Center, Center,
Group,
Pagination, Pagination,
Paper, Paper,
SimpleGrid, SimpleGrid,
Spoiler,
Stack, Stack,
Text, Text
} from '@mantine/core'; } from '@mantine/core';
import { useCallback, useEffect, useState } from 'react'; import { useTransitionRouter } from 'next-view-transitions';
import { useCallback, useEffect } from 'react';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
export default function VideoContent() { export default function VideoContent() {
// ✅ expanded state per index
const [expandedMap, setExpandedMap] = useState<Record<number, boolean>>({});
const videoState = useSnapshot(stateGallery.video); const videoState = useSnapshot(stateGallery.video);
const router = useTransitionRouter()
const { data, page, totalPages, loading } = videoState.findMany; const { data, page, totalPages, loading } = videoState.findMany;
// Handle search and pagination changes // Handle search and pagination changes
const loadData = useCallback((pageNum: number, searchTerm: string) => { const loadData = useCallback((pageNum: number, searchTerm: string) => {
stateGallery.video.findMany.load(pageNum, 10, searchTerm.trim()); stateGallery.video.findMany.load(pageNum, 3, searchTerm.trim());
}, []); }, []);
// Initial load and URL change handler // Initial load and URL change handler
@@ -56,12 +57,6 @@ export default function VideoContent() {
loadData(newPage, search); loadData(newPage, search);
}; };
const toggleExpanded = (index: number, value: boolean) => {
setExpandedMap((prev) => ({
...prev,
[index]: value,
}));
};
const dataVideo = data || []; const dataVideo = data || [];
@@ -110,27 +105,22 @@ export default function VideoContent() {
<Text fw="bold" fz="sm" lineClamp={1}> <Text fw="bold" fz="sm" lineClamp={1}>
{v.name} {v.name}
</Text> </Text>
<Spoiler <Text
showLabel={ ta="justify"
<Text fw="bold" fz="sm" c={colors['blue-button']}> fz="sm"
Show more dangerouslySetInnerHTML={{ __html: v.deskripsi }}
</Text> style={{ wordBreak: "break-word", whiteSpace: "normal" }}
} lineClamp={3}
hideLabel={ truncate="end"
<Text fw="bold" fz="sm" c={colors['blue-button']}> />
Hide details <Group justify={"right"}>
</Text> <Button
} onClick={() => router.push(`/darmasaba/desa/galery/video/${v.id}`)}
expanded={expandedMap[k] || false} bg={colors['blue-button']}
onExpandedChange={(val) => toggleExpanded(k, val)}
> >
<Text Detail
ta="justify" </Button>
fz="sm" </Group>
dangerouslySetInnerHTML={{ __html: v.deskripsi }}
style={{wordBreak: "break-word", whiteSpace: "normal"}}
/>
</Spoiler>
</Stack> </Stack>
</Box> </Box>
</Paper> </Paper>

View File

@@ -0,0 +1,197 @@
'use client';
import colors from '@/con/colors';
import {
Alert,
Box,
Button,
Card,
Group,
Paper,
Skeleton,
Stack,
Text,
ThemeIcon,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconInfoCircle, IconVideo } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery'; // pastikan state bisa dipakai di publik
import BackButton from '../../../layanan/_com/BackButto';
// Fungsi helper: aman dan tanpa spasi
function convertToEmbedUrl(youtubeUrl: string): string {
try {
const url = new URL(youtubeUrl);
let videoId = '';
if (url.hostname === 'youtu.be') {
videoId = url.pathname.slice(1);
} else if (url.hostname.includes('youtube.com')) {
videoId = url.searchParams.get('v') || '';
}
return videoId ? `https://www.youtube.com/embed/${videoId}` : youtubeUrl;
} catch {
return youtubeUrl;
}
}
export default function DetailVideoUser() {
const params = useParams<{ id: string }>();
const router = useRouter();
const videoState = useProxy(stateGallery.video);
const [videoError, setVideoError] = useState(false);
const id = Array.isArray(params.id) ? params.id[0] : params.id;
useShallowEffect(() => {
if (id) {
videoState.findUnique.load(id);
}
}, [id]);
const data = videoState.findUnique.data;
if (!videoState.findUnique && !id) {
return (
<Box py="xl" px={{ base: 'md', md: 'lg' }}>
<Skeleton height={400} radius="md" />
</Box>
);
}
if (!data) {
return (
<Box py="xl" px={{ base: 'md', md: 'lg' }}>
<Alert
icon={<IconInfoCircle size={20} />}
title="Video tidak ditemukan"
color="red"
radius="md"
>
Video yang Anda cari tidak tersedia.
</Alert>
<Button
leftSection={<IconArrowBack size={16} />}
mt="md"
onClick={() => router.push('/darmasaba/galeri/video')}
variant="outline"
>
Kembali ke Galeri
</Button>
</Box>
);
}
const embedUrl = data.linkVideo ? convertToEmbedUrl(data.linkVideo) : null;
return (
<Box py="xl" px={{ base: 'md', md: 100 }}>
{/* Tombol Kembali */}
<Box >
<BackButton />
</Box>
{/* Header */}
<Text
ta="center"
fz={{ base: 'xl', md: '2xl' }}
fw={700}
c={colors['blue-button']}
mb="lg"
>
{data.name || 'Video Galeri Desa'}
</Text>
{/* Konten Utama */}
<Card
shadow="sm"
radius="md"
p={{ base: 'md', md: 'xl' }}
bg={colors['white-1']}
>
<Stack gap="lg">
{/* Video */}
{embedUrl ? (
<Box
pos="relative"
style={{ paddingBottom: '56.25%', height: 0, overflow: 'hidden' }} // 16:9 aspect ratio
>
<iframe
src={embedUrl}
title={data.name}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
borderRadius: 8,
border: 'none',
}}
onError={() => setVideoError(true)}
/>
</Box>
) : videoError ? (
<Alert
color="orange"
icon={<IconVideo size={20} />}
title="Gagal memuat video"
radius="md"
>
Mohon maaf, video tidak dapat diputar.
</Alert>
) : (
<Alert
color="gray"
icon={<IconInfoCircle size={20} />}
title="Tidak ada video"
radius="md"
>
Konten video belum tersedia.
</Alert>
)}
{/* Informasi Tambahan */}
{data.createdAt && (
<Group gap="xs" justify="center" wrap="nowrap">
<ThemeIcon variant="light" size="sm" radius="xl">
<IconInfoCircle size={14} />
</ThemeIcon>
<Text fz="sm" c="dimmed">
Diunggah pada{' '}
{new Date(data.createdAt).toLocaleDateString('id-ID', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</Text>
</Group>
)}
{/* Deskripsi */}
{data.deskripsi && (
<Paper p="md" bg="gray.0" radius="md">
<Text
fz="md"
c="dark"
dangerouslySetInnerHTML={{ __html: data.deskripsi }}
style={{
wordBreak: 'break-word',
whiteSpace: 'normal',
lineHeight: 1.6,
}}
/>
</Paper>
)}
</Stack>
</Card>
</Box>
);
}

View File

@@ -100,7 +100,7 @@ function Page() {
{data.name} {data.name}
</Text> </Text>
</Container> </Container>
<Box px={{ base: "md", md: 100 }}> <Box px={{ base: "35", md: 100 }}>
<Stack gap="md"> <Stack gap="md">
<Text <Text
dangerouslySetInnerHTML={{ __html: data.deskripsi }} dangerouslySetInnerHTML={{ __html: data.deskripsi }}

View File

@@ -1,96 +0,0 @@
import colors from '@/con/colors';
import { Stack, Box, Container, Text, Paper, Group } from '@mantine/core';
import React from 'react';
import BackButton from '../../../layanan/_com/BackButto';
import { IconCalendar, IconClock, IconMapPin } from '@tabler/icons-react';
const dataPengumuman = [
{
id: 1,
judul: 'Sosialisasi Perarem Konservasi Adat di Darmasaba',
tanggal: 'Jumat, 26 April 2025',
jam: '16:00 WITA',
lokasi: 'Wantilan Adat Desa',
deskripsi: 'Para krama diundang hadir dalam sosialisasi perarem (aturan adat) baru yang berkaitan dengan konservasi lingkungan berbasis budaya, termasuk larangan alih fungsi lahan pekarangan suci.'
},
{
id: 2,
judul: 'Sosialisasi Perarem Konservasi Adat di Darmasaba',
tanggal: 'Jumat, 26 April 2025',
jam: '16:00 WITA',
lokasi: 'Wantilan Adat Desa',
deskripsi: 'Para krama diundang hadir dalam sosialisasi perarem (aturan adat) baru yang berkaitan dengan konservasi lingkungan berbasis budaya, termasuk larangan alih fungsi lahan pekarangan suci.'
},
{
id: 3,
judul: 'Sosialisasi Perarem Konservasi Adat di Darmasaba',
tanggal: 'Jumat, 26 April 2025',
jam: '16:00 WITA',
lokasi: 'Wantilan Adat Desa',
deskripsi: 'Para krama diundang hadir dalam sosialisasi perarem (aturan adat) baru yang berkaitan dengan konservasi lingkungan berbasis budaya, termasuk larangan alih fungsi lahan pekarangan suci.'
},
{
id: 4,
judul: 'Sosialisasi Perarem Konservasi Adat di Darmasaba',
tanggal: 'Jumat, 26 April 2025',
jam: '16:00 WITA',
lokasi: 'Wantilan Adat Desa',
deskripsi: 'Para krama diundang hadir dalam sosialisasi perarem (aturan adat) baru yang berkaitan dengan konservasi lingkungan berbasis budaya, termasuk larangan alih fungsi lahan pekarangan suci.'
},
{
id: 5,
judul: 'Sosialisasi Perarem Konservasi Adat di Darmasaba',
tanggal: 'Jumat, 26 April 2025',
jam: '16:00 WITA',
lokasi: 'Wantilan Adat Desa',
deskripsi: 'Para krama diundang hadir dalam sosialisasi perarem (aturan adat) baru yang berkaitan dengan konservasi lingkungan berbasis budaya, termasuk larangan alih fungsi lahan pekarangan suci.'
},
]
function Page() {
return (
<Stack pos="relative" bg={colors.Bg} py="xl" gap="md">
{/* Header */}
<Box px={{ base: "md", md: 100 }}>
<BackButton />
</Box>
<Container size="lg" px="md" >
<Stack align="center" gap="0" >
<Text fz={{ base: "2rem", md: "3.4rem" }} c={colors["blue-button"]} fw="bold" ta="center">
Pengumuman Adat & Budaya
</Text>
<Text ta="center" px="md" pb={10}>
Informasi dan pengumuman resmi terkait pengumuman adat & budaya di Desa Darmasaba.
</Text>
</Stack>
</Container>
<Box px={{ base: 'md', md: 100 }}>
{dataPengumuman.map((v, k) => {
return (
<Paper mb={10} key={k} withBorder p="lg" radius="md" shadow="md" bg={colors["white-1"]}>
<Text fz={'h3'}>{v.judul}</Text>
<Group style={{ color: 'black' }} pb={20}>
<Group gap="xs">
<IconCalendar size={18} />
<Text size="sm">{v.tanggal}</Text>
</Group>
<Group gap="xs">
<IconClock size={18} />
<Text size="sm">{v.jam}</Text>
</Group>
<Group gap="xs">
<IconMapPin size={18} />
<Text size="sm">{v.lokasi}</Text>
</Group>
</Group>
<Text ta={'justify'}>
{v.deskripsi}
</Text>
</Paper>
)
})}
</Box>
</Stack>
);
}
export default Page;

View File

@@ -1,96 +0,0 @@
import colors from '@/con/colors';
import { Stack, Box, Container, Text, Paper, Group } from '@mantine/core';
import React from 'react';
import BackButton from '../../../layanan/_com/BackButto';
import { IconCalendar, IconClock, IconMapPin } from '@tabler/icons-react';
const dataPengumuman = [
{
id: 1,
judul: 'Pelatihan Dasar Komputer untuk Lansia dan Ibu Rumah Tangga',
tanggal: 'Selasa, 30 April 2025',
jam: '09:00 WITA',
lokasi: 'Perpustakaan Desa',
deskripsi: 'Belajar dari nol: cara menyalakan komputer, menulis di Word, membuat email, dan dasar penggunaan HP Android untuk komunikasi dan akses layanan desa online. Terbuka untuk 20 peserta.'
},
{
id: 2,
judul: 'Pelatihan Dasar Komputer untuk Lansia dan Ibu Rumah Tangga',
tanggal: 'Selasa, 30 April 2025',
jam: '09:00 WITA',
lokasi: 'Perpustakaan Desa',
deskripsi: 'Belajar dari nol: cara menyalakan komputer, menulis di Word, membuat email, dan dasar penggunaan HP Android untuk komunikasi dan akses layanan desa online. Terbuka untuk 20 peserta.'
},
{
id: 3,
judul: 'Pelatihan Dasar Komputer untuk Lansia dan Ibu Rumah Tangga',
tanggal: 'Selasa, 30 April 2025',
jam: '09:00 WITA',
lokasi: 'Perpustakaan Desa',
deskripsi: 'Belajar dari nol: cara menyalakan komputer, menulis di Word, membuat email, dan dasar penggunaan HP Android untuk komunikasi dan akses layanan desa online. Terbuka untuk 20 peserta.'
},
{
id: 4,
judul: 'Pelatihan Dasar Komputer untuk Lansia dan Ibu Rumah Tangga',
tanggal: 'Selasa, 30 April 2025',
jam: '09:00 WITA',
lokasi: 'Perpustakaan Desa',
deskripsi: 'Belajar dari nol: cara menyalakan komputer, menulis di Word, membuat email, dan dasar penggunaan HP Android untuk komunikasi dan akses layanan desa online. Terbuka untuk 20 peserta.'
},
{
id: 5,
judul: 'Pelatihan Dasar Komputer untuk Lansia dan Ibu Rumah Tangga',
tanggal: 'Selasa, 30 April 2025',
jam: '09:00 WITA',
lokasi: 'Perpustakaan Desa',
deskripsi: 'Belajar dari nol: cara menyalakan komputer, menulis di Word, membuat email, dan dasar penggunaan HP Android untuk komunikasi dan akses layanan desa online. Terbuka untuk 20 peserta.'
},
]
function Page() {
return (
<Stack pos="relative" bg={colors.Bg} py="xl" gap="md">
{/* Header */}
<Box px={{ base: "md", md: 100 }}>
<BackButton />
</Box>
<Container size="lg" px="md" >
<Stack align="center" gap="0" >
<Text fz={{ base: "2rem", md: "3.4rem" }} c={colors["blue-button"]} fw="bold" ta="center">
Pengumuman Digitalisasi Desa
</Text>
<Text ta="center" px="md" pb={10}>
Informasi dan pengumuman resmi terkait pengumuman digitalisasi desa
</Text>
</Stack>
</Container>
<Box px={{ base: 'md', md: 100 }}>
{dataPengumuman.map((v, k) => {
return (
<Paper mb={10} key={k} withBorder p="lg" radius="md" shadow="md" bg={colors["white-1"]}>
<Text fz={'h3'}>{v.judul}</Text>
<Group style={{ color: 'black' }} pb={20}>
<Group gap="xs">
<IconCalendar size={18} />
<Text size="sm">{v.tanggal}</Text>
</Group>
<Group gap="xs">
<IconClock size={18} />
<Text size="sm">{v.jam}</Text>
</Group>
<Group gap="xs">
<IconMapPin size={18} />
<Text size="sm">{v.lokasi}</Text>
</Group>
</Group>
<Text ta={'justify'}>
{v.deskripsi}
</Text>
</Paper>
)
})}
</Box>
</Stack>
);
}
export default Page;

View File

@@ -1,96 +0,0 @@
import colors from '@/con/colors';
import { Stack, Box, Container, Text, Paper, Group } from '@mantine/core';
import React from 'react';
import BackButton from '../../../layanan/_com/BackButto';
import { IconCalendar, IconClock, IconMapPin } from '@tabler/icons-react';
const dataPengumuman = [
{
id: 1,
judul: 'Pelatihan Digital Marketing untuk UMKM Desa',
tanggal: 'Rabu, 23 April 2025',
jam: '13:00 WITA',
lokasi: 'Aula Kantor Desa',
deskripsi: 'Para pelaku UMKM diundang untuk mengikuti pelatihan dasar digital marketing: mulai dari membuat akun bisnis, copywriting produk, hingga promosi melalui media sosial. Peserta akan mendapat sertifikat dan materi pelatihan.'
},
{
id: 2,
judul: 'Pelatihan Digital Marketing untuk UMKM Desa',
tanggal: 'Rabu, 23 April 2025',
jam: '13:00 WITA',
lokasi: 'Aula Kantor Desa',
deskripsi: 'Para pelaku UMKM diundang untuk mengikuti pelatihan dasar digital marketing: mulai dari membuat akun bisnis, copywriting produk, hingga promosi melalui media sosial. Peserta akan mendapat sertifikat dan materi pelatihan.'
},
{
id: 3,
judul: 'Pelatihan Digital Marketing untuk UMKM Desa',
tanggal: 'Rabu, 23 April 2025',
jam: '13:00 WITA',
lokasi: 'Aula Kantor Desa',
deskripsi: 'Para pelaku UMKM diundang untuk mengikuti pelatihan dasar digital marketing: mulai dari membuat akun bisnis, copywriting produk, hingga promosi melalui media sosial. Peserta akan mendapat sertifikat dan materi pelatihan.'
},
{
id: 4,
judul: 'Pelatihan Digital Marketing untuk UMKM Desa',
tanggal: 'Rabu, 23 April 2025',
jam: '13:00 WITA',
lokasi: 'Aula Kantor Desa',
deskripsi: 'Para pelaku UMKM diundang untuk mengikuti pelatihan dasar digital marketing: mulai dari membuat akun bisnis, copywriting produk, hingga promosi melalui media sosial. Peserta akan mendapat sertifikat dan materi pelatihan.'
},
{
id: 5,
judul: 'Pelatihan Digital Marketing untuk UMKM Desa',
tanggal: 'Rabu, 23 April 2025',
jam: '13:00 WITA',
lokasi: 'Aula Kantor Desa',
deskripsi: 'Para pelaku UMKM diundang untuk mengikuti pelatihan dasar digital marketing: mulai dari membuat akun bisnis, copywriting produk, hingga promosi melalui media sosial. Peserta akan mendapat sertifikat dan materi pelatihan.'
},
]
function Page() {
return (
<Stack pos="relative" bg={colors.Bg} py="xl" gap="md">
{/* Header */}
<Box px={{ base: "md", md: 100 }}>
<BackButton />
</Box>
<Container size="lg" px="md" >
<Stack align="center" gap="0" >
<Text fz={{ base: "2rem", md: "3.4rem" }} c={colors["blue-button"]} fw="bold" ta="center">
Pengumuman Ekonomi & UMKM
</Text>
<Text ta="center" px="md" pb={10}>
Informasi dan pengumuman resmi terkait pengumuman ekonomi & umkm
</Text>
</Stack>
</Container>
<Box px={{ base: 'md', md: 100 }}>
{dataPengumuman.map((v, k) => {
return (
<Paper mb={10} key={k} withBorder p="lg" radius="md" shadow="md" bg={colors["white-1"]}>
<Text fz={'h3'}>{v.judul}</Text>
<Group style={{ color: 'black' }} pb={20}>
<Group gap="xs">
<IconCalendar size={18} />
<Text size="sm">{v.tanggal}</Text>
</Group>
<Group gap="xs">
<IconClock size={18} />
<Text size="sm">{v.jam}</Text>
</Group>
<Group gap="xs">
<IconMapPin size={18} />
<Text size="sm">{v.lokasi}</Text>
</Group>
</Group>
<Text ta={'justify'}>
{v.deskripsi}
</Text>
</Paper>
)
})}
</Box>
</Stack>
);
}
export default Page;

View File

@@ -1,96 +0,0 @@
import colors from '@/con/colors';
import { Stack, Box, Container, Text, Paper, Group } from '@mantine/core';
import React from 'react';
import BackButton from '../../../layanan/_com/BackButto';
import { IconCalendar, IconClock, IconMapPin } from '@tabler/icons-react';
const dataPengumuman = [
{
id: 1,
judul: 'Gotong Royong Bersih Sungai dan Drainase',
tanggal: 'Minggu, 21 April 2025',
jam: '06:30 WITA',
lokasi: 'Titik Kumpul: Poskamling RW 02',
deskripsi: 'Seluruh warga RW 02 diimbau ikut serta dalam kegiatan bersih-bersih aliran sungai dan saluran air untuk mencegah banjir saat musim hujan. Disediakan alat kebersihan dan konsumsi ringan.'
},
{
id: 2,
judul: 'Gotong Royong Bersih Sungai dan Drainase',
tanggal: 'Minggu, 21 April 2025',
jam: '06:30 WITA',
lokasi: 'Titik Kumpul: Poskamling RW 02',
deskripsi: 'Seluruh warga RW 02 diimbau ikut serta dalam kegiatan bersih-bersih aliran sungai dan saluran air untuk mencegah banjir saat musim hujan. Disediakan alat kebersihan dan konsumsi ringan.'
},
{
id: 3,
judul: 'Gotong Royong Bersih Sungai dan Drainase',
tanggal: 'Minggu, 21 April 2025',
jam: '06:30 WITA',
lokasi: 'Titik Kumpul: Poskamling RW 02',
deskripsi: 'Seluruh warga RW 02 diimbau ikut serta dalam kegiatan bersih-bersih aliran sungai dan saluran air untuk mencegah banjir saat musim hujan. Disediakan alat kebersihan dan konsumsi ringan.'
},
{
id: 4,
judul: 'Gotong Royong Bersih Sungai dan Drainase',
tanggal: 'Minggu, 21 April 2025',
jam: '06:30 WITA',
lokasi: 'Titik Kumpul: Poskamling RW 02',
deskripsi: 'Seluruh warga RW 02 diimbau ikut serta dalam kegiatan bersih-bersih aliran sungai dan saluran air untuk mencegah banjir saat musim hujan. Disediakan alat kebersihan dan konsumsi ringan.'
},
{
id: 5,
judul: 'Gotong Royong Bersih Sungai dan Drainase',
tanggal: 'Minggu, 21 April 2025',
jam: '06:30 WITA',
lokasi: 'Titik Kumpul: Poskamling RW 02',
deskripsi: 'Seluruh warga RW 02 diimbau ikut serta dalam kegiatan bersih-bersih aliran sungai dan saluran air untuk mencegah banjir saat musim hujan. Disediakan alat kebersihan dan konsumsi ringan.'
},
]
function Page() {
return (
<Stack pos="relative" bg={colors.Bg} py="xl" gap="md">
{/* Header */}
<Box px={{ base: "md", md: 100 }}>
<BackButton />
</Box>
<Container size="lg" px="md" >
<Stack align="center" gap="0" >
<Text fz={{ base: "2rem", md: "3.4rem" }} c={colors["blue-button"]} fw="bold" ta="center">
Pengumuman Lingkungan & Bencana
</Text>
<Text ta="center" px="md" pb={10}>
Informasi dan pengumuman resmi terkait pengumuman lingkungan & bencana
</Text>
</Stack>
</Container>
<Box px={{ base: 'md', md: 100 }}>
{dataPengumuman.map((v, k) => {
return (
<Paper mb={10} key={k} withBorder p="lg" radius="md" shadow="md" bg={colors["white-1"]}>
<Text fz={'h3'}>{v.judul}</Text>
<Group style={{ color: 'black' }} pb={20}>
<Group gap="xs">
<IconCalendar size={18} />
<Text size="sm">{v.tanggal}</Text>
</Group>
<Group gap="xs">
<IconClock size={18} />
<Text size="sm">{v.jam}</Text>
</Group>
<Group gap="xs">
<IconMapPin size={18} />
<Text size="sm">{v.lokasi}</Text>
</Group>
</Group>
<Text ta={'justify'}>
{v.deskripsi}
</Text>
</Paper>
)
})}
</Box>
</Stack>
);
}
export default Page;

View File

@@ -1,96 +0,0 @@
import colors from '@/con/colors';
import { Stack, Box, Container, Text, Paper, Group } from '@mantine/core';
import React from 'react';
import BackButton from '../../../layanan/_com/BackButto';
import { IconCalendar, IconClock, IconMapPin } from '@tabler/icons-react';
const dataPengumuman = [
{
id: 1,
judul: 'Pendaftaran Bimbingan Belajar Gratis untuk Pelajar',
tanggal: 'Sabtu, 20 April 2025',
jam: '08:00 WITA',
lokasi: 'Balai Banjar Desa Darmasaba',
deskripsi: 'Dalam rangka meningkatkan kesadaran kesehatan, Pemerintah Desa Darmasaba bekerja sama dengan Puskesmas Abiansemal akan mengadakan pemeriksaan kesehatan gratis meliputi cek tekanan darah, kolesterol, gula darah, dan konsultasi gizi.'
},
{
id: 2,
judul: 'Lomba Video Pendek Hari Lingkungan',
tanggal: 'Deadline: 28 April 2025',
jam: '08:00 WITA',
lokasi: 'Online Submission',
deskripsi: 'Karang Taruna Desa mengadakan lomba video pendek bertema "Lingkunganku, Tanggung Jawabku". Pemenang akan diumumkan saat acara Hari Desa Hijau. Total hadiah Rp1.000.000.'
},
{
id: 3,
judul: 'Pendaftaran Bimbingan Belajar Gratis untuk Pelajar',
tanggal: 'Sabtu, 20 April 2025',
jam: '08:00 WITA',
lokasi: 'Balai Banjar Desa Darmasaba',
deskripsi: 'Dalam rangka meningkatkan kesadaran kesehatan, Pemerintah Desa Darmasaba bekerja sama dengan Puskesmas Abiansemal akan mengadakan pemeriksaan kesehatan gratis meliputi cek tekanan darah, kolesterol, gula darah, dan konsultasi gizi.'
},
{
id: 4,
judul: 'Lomba Video Pendek Hari Lingkungan',
tanggal: 'Deadline: 28 April 2025',
jam: '08:00 WITA',
lokasi: 'Online Submission',
deskripsi: 'Karang Taruna Desa mengadakan lomba video pendek bertema "Lingkunganku, Tanggung Jawabku". Pemenang akan diumumkan saat acara Hari Desa Hijau. Total hadiah Rp1.000.000.'
},
{
id: 5,
judul: 'Pendaftaran Bimbingan Belajar Gratis untuk Pelajar',
tanggal: 'Sabtu, 20 April 2025',
jam: '08:00 WITA',
lokasi: 'Balai Banjar Desa Darmasaba',
deskripsi: 'Dalam rangka meningkatkan kesadaran kesehatan, Pemerintah Desa Darmasaba bekerja sama dengan Puskesmas Abiansemal akan mengadakan pemeriksaan kesehatan gratis meliputi cek tekanan darah, kolesterol, gula darah, dan konsultasi gizi.'
},
]
function Page() {
return (
<Stack pos="relative" bg={colors.Bg} py="xl" gap="md">
{/* Header */}
<Box px={{ base: "md", md: 100 }}>
<BackButton />
</Box>
<Container size="lg" px="md" >
<Stack align="center" gap="0" >
<Text fz={{ base: "2rem", md: "3.4rem" }} c={colors["blue-button"]} fw="bold" ta="center">
Pengumuman Pendidikan & Kepemudaan
</Text>
<Text ta="center" px="md" pb={10}>
Informasi dan pengumuman resmi terkait pengumuman pendidikan & kepemudaan
</Text>
</Stack>
</Container>
<Box px={{ base: 'md', md: 100 }}>
{dataPengumuman.map((v, k) => {
return (
<Paper mb={10} key={k} withBorder p="lg" radius="md" shadow="md" bg={colors["white-1"]}>
<Text fz={'h3'}>{v.judul}</Text>
<Group style={{ color: 'black' }} pb={20}>
<Group gap="xs">
<IconCalendar size={18} />
<Text size="sm">{v.tanggal}</Text>
</Group>
<Group gap="xs">
<IconClock size={18} />
<Text size="sm">{v.jam}</Text>
</Group>
<Group gap="xs">
<IconMapPin size={18} />
<Text size="sm">{v.lokasi}</Text>
</Group>
</Group>
<Text ta={'justify'}>
{v.deskripsi}
</Text>
</Paper>
)
})}
</Box>
</Stack>
);
}
export default Page;

View File

@@ -1,96 +0,0 @@
import colors from '@/con/colors';
import { Stack, Box, Container, Text, Paper, Group } from '@mantine/core';
import React from 'react';
import BackButton from '../../../layanan/_com/BackButto';
import { IconCalendar, IconClock, IconMapPin } from '@tabler/icons-react';
const dataPengumuman = [
{
id: 1,
judul: 'Pemeriksaan Kesehatan Gratis Untuk Warga Desa',
tanggal: 'Sabtu, 20 April 2025',
jam: '08:00 WITA',
lokasi: 'Balai Banjar Desa Darmasaba',
deskripsi: 'Dalam rangka meningkatkan kesadaran kesehatan, Pemerintah Desa Darmasaba bekerja sama dengan Puskesmas Abiansemal akan mengadakan pemeriksaan kesehatan gratis meliputi cek tekanan darah, kolesterol, gula darah, dan konsultasi gizi.'
},
{
id: 2,
judul: 'Pemeriksaan Kesehatan Gratis Untuk Warga Desa',
tanggal: 'Sabtu, 20 April 2025',
jam: '08:00 WITA',
lokasi: 'Balai Banjar Desa Darmasaba',
deskripsi: 'Dalam rangka meningkatkan kesadaran kesehatan, Pemerintah Desa Darmasaba bekerja sama dengan Puskesmas Abiansemal akan mengadakan pemeriksaan kesehatan gratis meliputi cek tekanan darah, kolesterol, gula darah, dan konsultasi gizi.'
},
{
id: 3,
judul: 'Pemeriksaan Kesehatan Gratis Untuk Warga Desa',
tanggal: 'Sabtu, 20 April 2025',
jam: '08:00 WITA',
lokasi: 'Balai Banjar Desa Darmasaba',
deskripsi: 'Dalam rangka meningkatkan kesadaran kesehatan, Pemerintah Desa Darmasaba bekerja sama dengan Puskesmas Abiansemal akan mengadakan pemeriksaan kesehatan gratis meliputi cek tekanan darah, kolesterol, gula darah, dan konsultasi gizi.'
},
{
id: 4,
judul: 'Pemeriksaan Kesehatan Gratis Untuk Warga Desa',
tanggal: 'Sabtu, 20 April 2025',
jam: '08:00 WITA',
lokasi: 'Balai Banjar Desa Darmasaba',
deskripsi: 'Dalam rangka meningkatkan kesadaran kesehatan, Pemerintah Desa Darmasaba bekerja sama dengan Puskesmas Abiansemal akan mengadakan pemeriksaan kesehatan gratis meliputi cek tekanan darah, kolesterol, gula darah, dan konsultasi gizi.'
},
{
id: 5,
judul: 'Pemeriksaan Kesehatan Gratis Untuk Warga Desa',
tanggal: 'Sabtu, 20 April 2025',
jam: '08:00 WITA',
lokasi: 'Balai Banjar Desa Darmasaba',
deskripsi: 'Dalam rangka meningkatkan kesadaran kesehatan, Pemerintah Desa Darmasaba bekerja sama dengan Puskesmas Abiansemal akan mengadakan pemeriksaan kesehatan gratis meliputi cek tekanan darah, kolesterol, gula darah, dan konsultasi gizi.'
},
]
function Page() {
return (
<Stack pos="relative" bg={colors.Bg} py="xl" gap="md">
{/* Header */}
<Box px={{ base: "md", md: 100 }}>
<BackButton />
</Box>
<Container size="lg" px="md" >
<Stack align="center" gap="0" >
<Text fz={{ base: "2rem", md: "3.4rem" }} c={colors["blue-button"]} fw="bold" ta="center">
Pengumuman Sosial & Kesehatan
</Text>
<Text ta="center" px="md" pb={10}>
Informasi dan pengumuman resmi terkait pengumuman sosial & kesehatan
</Text>
</Stack>
</Container>
<Box px={{ base: 'md', md: 100 }}>
{dataPengumuman.map((v, k) => {
return (
<Paper mb={10} key={k} withBorder p="lg" radius="md" shadow="md" bg={colors["white-1"]}>
<Text fz={'h3'}>{v.judul}</Text>
<Group style={{ color: 'black' }} pb={20}>
<Group gap="xs">
<IconCalendar size={18} />
<Text size="sm">{v.tanggal}</Text>
</Group>
<Group gap="xs">
<IconClock size={18} />
<Text size="sm">{v.jam}</Text>
</Group>
<Group gap="xs">
<IconMapPin size={18} />
<Text size="sm">{v.lokasi}</Text>
</Group>
</Group>
<Text ta={'justify'}>
{v.deskripsi}
</Text>
</Paper>
)
})}
</Box>
</Stack>
);
}
export default Page;

View File

@@ -46,8 +46,8 @@ function Page() {
</Group> </Group>
</Group> </Group>
<Paper bg={colors["white-1"]} p="md"> <Paper bg={colors["white-1"]} p="md">
<Text id='news-content' fz={"md"} style={{wordBreak: "break-word", whiteSpace: "normal"}} dangerouslySetInnerHTML={{ __html: detail.data?.content }} /> <Text px="lg" 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" > <Text px="lg" fz={"md"} c={colors["blue-button"]} fw="bold" >
{new Date(detail.data?.createdAt).toLocaleDateString('id-ID', { {new Date(detail.data?.createdAt).toLocaleDateString('id-ID', {
weekday: 'long', weekday: 'long',
day: 'numeric', day: 'numeric',

View File

@@ -14,6 +14,9 @@ import {
Loader, Loader,
Paper, Paper,
Stack, Stack,
Tabs,
TabsList,
TabsTab,
Text, Text,
TextInput, TextInput,
Title, Title,
@@ -35,6 +38,7 @@ import { useEffect, useRef, useState } from 'react'
import { useProxy } from 'valtio/utils' import { useProxy } from 'valtio/utils'
import './struktur.css' import './struktur.css'
import BackButton from '../_com/BackButto' import BackButton from '../_com/BackButto'
import { useMediaQuery } from '@mantine/hooks'
export default function StrukturPerangkatDesa() { export default function StrukturPerangkatDesa() {
return ( return (
@@ -231,87 +235,121 @@ function StrukturPerangkatDesaNode() {
p="md" p="md"
radius="md" radius="md"
style={{ style={{
background: colors['blue-button'] background: colors['blue-button'],
width: '100%', // ⬅️ penting
maxWidth: '100%', // ⬅️ penting
overflowX: 'auto' // ⬅️ untuk mencegah overflow
}} }}
> >
<Group gap="sm" wrap="wrap" justify="center">
<TextInput <Stack gap="sm">
placeholder="Cari nama atau jabatan..." <Group justify='center'>
leftSection={<IconSearch size={16} />} <TextInput
onChange={(e) => debouncedSearch(e.target.value)} placeholder="Cari nama atau jabatan..."
leftSection={<IconSearch size={16} />}
onChange={(e) => debouncedSearch(e.target.value)}
styles={{
input: {
minWidth: 250,
},
}}
/>
</Group>
<Tabs
defaultValue="zoom-out"
variant="outline"
radius="md"
styles={{ styles={{
input: { panel: { display: 'none' },
minWidth: 250, tab: {
color: colors['blue-button'],
backgroundColor: colors['blue-button-2'],
border: 'none',
fontWeight: 600,
fontSize: '0.875rem',
padding: '6px 12px',
minHeight: 'auto',
flexShrink: 0, // 👈 PENTING: mencegah tab mengecil
}, },
}} }}
/> >
<TabsList
<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={{ style={{
fontSize: 14, display: 'flex',
fontWeight: 700, overflowX: 'auto',
borderRadius: '8px', overflowY: 'hidden', // 👈 tambahkan ini
minWidth: 70, gap: '4px',
textAlign: 'center', paddingBottom: '4px',
flexWrap: 'nowrap',
WebkitOverflowScrolling: 'touch', // 👈 smooth scroll di iOS
scrollbarWidth: 'thin', // 👈 scrollbar tipis di Firefox
msOverflowStyle: '-ms-autohiding-scrollbar', // 👈 untuk IE/Edge
}} }}
> >
{Math.round(scale * 100)}% <TabsTab
</Box> value="zoom-out"
onClick={handleZoomOut}
leftSection={<IconZoomOut size={16} />}
style={{ flexShrink: 0 }} // 👈 pastikan tidak mengecil
>
Zoom Out
</TabsTab>
<Button <Box
bg={colors['blue-button-2']} bg={colors['blue-button-2']}
c={colors['blue-button']} c={colors['blue-button']}
variant="light" px={12}
size="sm" py={6}
onClick={handleZoomIn} style={{
leftSection={<IconZoomIn size={16} />} fontSize: 14,
> fontWeight: 700,
Zoom In borderRadius: '6px',
</Button> minWidth: 60,
textAlign: 'center',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
whiteSpace: 'nowrap', // 👈 mencegah text wrap
}}
>
{Math.round(scale * 100)}%
</Box>
<Button <TabsTab
bg={colors['blue-button-2']} value="zoom-in"
c={colors['blue-button']} onClick={handleZoomIn}
variant="light" leftSection={<IconZoomIn size={16} />}
size="sm" style={{ flexShrink: 0 }}
onClick={resetZoom} >
> Zoom In
Reset </TabsTab>
</Button>
<Button <TabsTab
bg={colors['blue-button-2']} value="reset"
c={colors['blue-button']} onClick={resetZoom}
size="sm" style={{ flexShrink: 0 }}
onClick={toggleFullscreen} >
leftSection={ Reset
isFullscreen ? ( </TabsTab>
<IconArrowsMinimize size={16} />
) : ( <TabsTab
<IconArrowsMaximize size={16} /> value="fullscreen"
) onClick={toggleFullscreen}
} leftSection={
> isFullscreen ? (
Fullscreen <IconArrowsMinimize size={16} />
</Button> ) : (
</Group> <IconArrowsMaximize size={16} />
</Group> )
}
style={{ flexShrink: 0 }}
>
{isFullscreen ? 'Exit' : 'Fullscreen'}
</TabsTab>
</TabsList>
</Tabs>
</Stack>
</Paper> </Paper>
{/* 🧩 Chart Container */} {/* 🧩 Chart Container */}
@@ -325,15 +363,20 @@ function StrukturPerangkatDesaNode() {
maxWidth: '100%', maxWidth: '100%',
padding: '32px 16px', padding: '32px 16px',
transition: 'transform 0.2s ease', transition: 'transform 0.2s ease',
transform: `scale(${scale})`,
transformOrigin: 'center top',
}} }}
> >
<OrganizationChart <Box style={{
value={chartData} transform: `scale(${scale})`,
nodeTemplate={(node) => <NodeCard node={node} router={router} />} transformOrigin: 'center top',
className="p-organizationchart p-organizationchart-horizontal" display: 'inline-block', // 👈 agar tidak memenuhi lebar parent
/> minWidth: 'min-content', // 👈 penting agar chart tidak dipaksa muat di width 100%
}}>
<OrganizationChart
value={chartData}
nodeTemplate={(node) => <NodeCard node={node} router={router} />}
className="p-organizationchart p-organizationchart-horizontal"
/>
</Box>
</Box> </Box>
</Center> </Center>
</Stack> </Stack>
@@ -345,6 +388,7 @@ function NodeCard({ node, router }: any) {
const name = node?.data?.name || 'Tanpa Nama' const name = node?.data?.name || 'Tanpa Nama'
const title = node?.data?.title || 'Tanpa Jabatan' const title = node?.data?.title || 'Tanpa Jabatan'
const hasId = Boolean(node?.data?.id) const hasId = Boolean(node?.data?.id)
const isMobile = useMediaQuery("(max-width: 768px)");
return ( return (
<Transition mounted transition="pop" duration={300}> <Transition mounted transition="pop" duration={300}>
@@ -355,9 +399,10 @@ function NodeCard({ node, router }: any) {
withBorder withBorder
style={{ style={{
...styles, ...styles,
width: 240, width: '100%',
minHeight: 280, maxWidth: isMobile ? 200 : 240, // lebih kecil di mobile
padding: 20, minHeight: isMobile ? 240 : 280,
padding: isMobile ? 16 : 20,
background: 'linear-gradient(135deg, rgba(28,110,164,0.15) 0%, rgba(255,255,255,0.95) 100%)', 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)', borderColor: 'rgba(28, 110, 164, 0.3)',
borderWidth: 2, borderWidth: 2,

View File

@@ -175,7 +175,9 @@ function Page() {
<Title order={4}>Layanan Unggulan</Title> <Title order={4}>Layanan Unggulan</Title>
<Divider /> <Divider />
{layananUnggulan ? ( {layananUnggulan ? (
<Text fz="md" style={{ lineHeight: 1.7, wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: layananUnggulan }} /> <Box pl={"lg"}>
<Text fz="md" style={{ lineHeight: 1.7, wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: layananUnggulan }} />
</Box>
) : ( ) : (
<Paper withBorder radius="md" p="md"> <Paper withBorder radius="md" p="md">
<Group gap="sm"> <Group gap="sm">
@@ -251,7 +253,9 @@ function Page() {
<Title order={3}>Fasilitas Pendukung</Title> <Title order={3}>Fasilitas Pendukung</Title>
<Divider /> <Divider />
{fasilitasPendukungHtml ? ( {fasilitasPendukungHtml ? (
<Text fz="md" style={{ wordBreak: "break-word", whiteSpace: "normal", lineHeight: 1.7 }} dangerouslySetInnerHTML={{ __html: fasilitasPendukungHtml }} /> <Box pl="lg">
<Text fz="md" style={{ wordBreak: "break-word", whiteSpace: "normal", lineHeight: 1.7 }} dangerouslySetInnerHTML={{ __html: fasilitasPendukungHtml }} />
</Box>
) : ( ) : (
<Paper withBorder radius="md" p="md"> <Paper withBorder radius="md" p="md">
<Group gap="sm"> <Group gap="sm">
@@ -313,7 +317,7 @@ function Page() {
<Title order={3}>Prosedur Pendaftaran</Title> <Title order={3}>Prosedur Pendaftaran</Title>
<Divider /> <Divider />
{prosedur ? ( {prosedur ? (
<Text fz="md" style={{ wordBreak: "break-word", whiteSpace: "normal", lineHeight: 1.7 }} dangerouslySetInnerHTML={{ __html: prosedur }} /> <Box pl="lg"><Text fz="md" style={{ wordBreak: "break-word", whiteSpace: "normal", lineHeight: 1.7 }} dangerouslySetInnerHTML={{ __html: prosedur }} /></Box>
) : ( ) : (
<Text fz="md" c="dimmed">Belum ada prosedur pendaftaran</Text> <Text fz="md" c="dimmed">Belum ada prosedur pendaftaran</Text>
)} )}

View File

@@ -87,7 +87,7 @@ export default function DetailInformasiPublikUser() {
<Divider /> <Divider />
<Stack gap="lg"> <Stack gap="lg">
<Box> <Box px="lg">
<Text fz={{ base: 'md', md: 'lg' }} fw="bold" mb={4}> <Text fz={{ base: 'md', md: 'lg' }} fw="bold" mb={4}>
Jenis Informasi Jenis Informasi
</Text> </Text>
@@ -96,7 +96,7 @@ export default function DetailInformasiPublikUser() {
</Text> </Text>
</Box> </Box>
<Box> <Box px="lg">
<Text fz={{ base: 'md', md: 'lg' }} fw="bold" mb={4}> <Text fz={{ base: 'md', md: 'lg' }} fw="bold" mb={4}>
Tanggal Publikasi Tanggal Publikasi
</Text> </Text>
@@ -111,15 +111,19 @@ export default function DetailInformasiPublikUser() {
</Text> </Text>
</Box> </Box>
<Box> <Box px="lg">
<Text fz={{ base: 'md', md: 'lg' }} fw="bold" mb={4}> <Text fz={{ base: 'md', md: 'lg' }} fw="bold" mb={4}>
Deskripsi Deskripsi
</Text> </Text>
<Box <Box>
className="prose max-w-none leading-relaxed" <Text
dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }} ta={"justify"}
style={{wordBreak: "break-word", whiteSpace: "normal"}} className="prose max-w-none leading-relaxed"
/> dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }}
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
fz={{ base: 'md', md: 'lg' }}
/>
</Box>
</Box> </Box>
</Stack> </Stack>
</Stack> </Stack>

View File

@@ -31,7 +31,11 @@ function Page() {
<Box px={{ base: 'md', md: 100 }}> <Box px={{ base: 'md', md: 100 }}>
<BackButton /> <BackButton />
</Box> </Box>
<Stack align="center" gap="xs"> <Stack
align="center"
gap="xs"
px={{ base: 'md', md: 100 }}
>
<IconBook2 size={42} stroke={1.5} color={colors["blue-button"]} /> <IconBook2 size={42} stroke={1.5} color={colors["blue-button"]} />
<Text <Text
ta="center" ta="center"
@@ -42,7 +46,7 @@ function Page() {
> >
Dasar Hukum Dasar Hukum
</Text> </Text>
<Text ta="center" fz="md" > <Text ta="center" fz="md" c={"black"}>
Informasi regulasi dan kebijakan resmi yang menjadi dasar hukum Informasi regulasi dan kebijakan resmi yang menjadi dasar hukum
</Text> </Text>
</Stack> </Stack>
@@ -71,12 +75,15 @@ function Page() {
<Stack gap="md"> <Stack gap="md">
<Text <Text
ta="center" ta="center"
c={"black"}
fw="bold" fw="bold"
fz={{ base: 'lg', md: 'xl' }} fz={{ base: 'lg', md: 'xl' }}
style={{ lineHeight: 1.4 }} style={{ lineHeight: 1.4 }}
dangerouslySetInnerHTML={{ __html: item.judul }} dangerouslySetInnerHTML={{ __html: item.judul }}
/> />
<Text <Text
c={"black"}
ta={"justify"}
fz={{ base: 'sm', md: 'md' }} fz={{ base: 'sm', md: 'md' }}
style={{ lineHeight: 1.7, wordBreak: "break-word", whiteSpace: "normal" }} style={{ lineHeight: 1.7, wordBreak: "break-word", whiteSpace: "normal" }}
dangerouslySetInnerHTML={{ __html: item.content }} dangerouslySetInnerHTML={{ __html: item.content }}

View File

@@ -598,7 +598,7 @@ const state = useProxy(indeksKepuasanState.responden);
<TextInput <TextInput
label="Nama" label="Nama"
type='text' type='text'
placeholder="masukkan nama" placeholder="Masukkan nama"
value={state.create.form.name} value={state.create.form.name}
onChange={(val) => { onChange={(val) => {
state.create.form.name = val.currentTarget.value; state.create.form.name = val.currentTarget.value;
@@ -607,7 +607,7 @@ const state = useProxy(indeksKepuasanState.responden);
<TextInput <TextInput
label="Tanggal Pengisian" label="Tanggal Pengisian"
type="date" type="date"
placeholder="masukkan tanggal" placeholder="Masukkan tanggal"
value={state.create.form.tanggal} value={state.create.form.tanggal}
onChange={(val) => { onChange={(val) => {
state.create.form.tanggal = val.currentTarget.value; state.create.form.tanggal = val.currentTarget.value;

View File

@@ -53,23 +53,11 @@ function Page() {
const permohonanInformasiPublikState = useProxy(statePermohonanInformasi); const permohonanInformasiPublikState = useProxy(statePermohonanInformasi);
const router = useRouter(); const router = useRouter();
const submitForms = () => { const submitForms = async () => {
const { create } = permohonanInformasiPublikState.statepermohonanInformasiPublik; const { create } = permohonanInformasiPublikState.statepermohonanInformasiPublik;
const hasil = await create.create(); // tunggu hasilnya
if ( if (hasil) {
create.form.name &&
create.form.nik &&
create.form.notelp &&
create.form.alamat &&
create.form.email &&
create.form.jenisInformasiDimintaId &&
create.form.caraMemperolehInformasiId &&
create.form.caraMemperolehSalinanInformasiId
) {
create.create();
router.push('/darmasaba/permohonan/berhasil'); router.push('/darmasaba/permohonan/berhasil');
} else {
console.log('Validasi gagal, form tidak lengkap');
} }
}; };

View File

@@ -55,17 +55,13 @@ function Page() {
const stateKeberatan = useProxy(permohonanKeberatanInformasi); const stateKeberatan = useProxy(permohonanKeberatanInformasi);
const router = useRouter(); const router = useRouter();
const submit = () => { const submit = async () => {
if ( const { create } = stateKeberatan;
stateKeberatan.create.form.name &&
stateKeberatan.create.form.email && const hasil = await create.create(); // tunggu hasilnya
stateKeberatan.create.form.notelp &&
stateKeberatan.create.form.alasan if (hasil) {
) {
stateKeberatan.create.create();
router.push('/darmasaba/permohonan/berhasil'); router.push('/darmasaba/permohonan/berhasil');
} else {
console.log('Formulir belum lengkap');
} }
}; };
@@ -190,7 +186,7 @@ function Page() {
<TextInput <TextInput
label="Nomor Telepon" label="Nomor Telepon"
placeholder="Contoh: 0812-3456-7890" placeholder="Contoh: 081234567890"
radius="md" radius="md"
size="md" size="md"
withAsterisk withAsterisk

View File

@@ -96,14 +96,20 @@ function Page() {
<IconUser size={28} /> <IconUser size={28} />
<Text fz={{ base: "1.25rem", md: "1.5rem" }} fw="bold">Biografi</Text> <Text fz={{ base: "1.25rem", md: "1.5rem" }} fw="bold">Biografi</Text>
</Flex> </Flex>
<Text fz={{ base: "1rem", md: "1.125rem", lg: "1.25rem" }} ta="justify" dangerouslySetInnerHTML={{ __html: item.biodata }} style={{ wordBreak: "break-word", whiteSpace: "normal" }} /> <Box px={20}>
<Text fz={{ base: "1rem", md: "1.125rem", lg: "1.25rem" }} ta="justify" dangerouslySetInnerHTML={{ __html: item.biodata }} style={{ wordBreak: "break-word", whiteSpace: "normal" }} />
</Box>
</Box> </Box>
<Box> <Box>
<Flex align="center" gap="sm" mb="sm"> <Flex align="center" gap="sm" mb="sm">
<IconTimeline size={28} /> <IconTimeline size={28} />
<Text fz={{ base: "1.25rem", md: "1.5rem" }} fw="bold">Riwayat Karir</Text> <Text fz={{ base: "1.25rem", md: "1.5rem" }} fw="bold">Riwayat Karir</Text>
</Flex> </Flex>
<Text fz={{ base: "1rem", md: "1.125rem", lg: "1.25rem" }} dangerouslySetInnerHTML={{ __html: item.riwayat }} style={{ wordBreak: "break-word", whiteSpace: "normal" }} /> <List spacing="xs" size="sm">
<Box px={20}>
<Text fz={{ base: "1rem", md: "1.125rem", lg: "1.25rem" }} dangerouslySetInnerHTML={{ __html: item.riwayat }} style={{ wordBreak: "break-word", whiteSpace: "normal" }} />
</Box>
</List>
</Box> </Box>
</Stack> </Stack>
</Box> </Box>

View File

@@ -14,6 +14,9 @@ import {
Loader, Loader,
Paper, Paper,
Stack, Stack,
Tabs,
TabsList,
TabsTab,
Text, Text,
TextInput, TextInput,
Title, Title,
@@ -35,6 +38,7 @@ import { useEffect, useRef, useState } from 'react'
import { useProxy } from 'valtio/utils' import { useProxy } from 'valtio/utils'
import BackButton from '../../desa/layanan/_com/BackButto' import BackButton from '../../desa/layanan/_com/BackButto'
import './struktur.css' import './struktur.css'
import { useMediaQuery } from '@mantine/hooks'
export default function Page() { export default function Page() {
return ( return (
@@ -231,87 +235,121 @@ function StrukturOrganisasiPPID() {
p="md" p="md"
radius="md" radius="md"
style={{ style={{
background: colors['blue-button'] background: colors['blue-button'],
width: '100%', // ⬅️ penting
maxWidth: '100%', // ⬅️ penting
overflowX: 'auto' // ⬅️ untuk mencegah overflow
}} }}
> >
<Group gap="sm" wrap="wrap" justify="center">
<TextInput <Stack gap="sm">
placeholder="Cari nama atau jabatan..." <Group justify='center'>
leftSection={<IconSearch size={16} />} <TextInput
onChange={(e) => debouncedSearch(e.target.value)} placeholder="Cari nama atau jabatan..."
leftSection={<IconSearch size={16} />}
onChange={(e) => debouncedSearch(e.target.value)}
styles={{
input: {
minWidth: 250,
},
}}
/>
</Group>
<Tabs
defaultValue="zoom-out"
variant="outline"
radius="md"
styles={{ styles={{
input: { panel: { display: 'none' },
minWidth: 250, tab: {
color: colors['blue-button'],
backgroundColor: colors['blue-button-2'],
border: 'none',
fontWeight: 600,
fontSize: '0.875rem',
padding: '6px 12px',
minHeight: 'auto',
flexShrink: 0, // 👈 PENTING: mencegah tab mengecil
}, },
}} }}
/> >
<TabsList
<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={{ style={{
fontSize: 14, display: 'flex',
fontWeight: 700, overflowX: 'auto',
borderRadius: '8px', overflowY: 'hidden', // 👈 tambahkan ini
minWidth: 70, gap: '4px',
textAlign: 'center', paddingBottom: '4px',
flexWrap: 'nowrap',
WebkitOverflowScrolling: 'touch', // 👈 smooth scroll di iOS
scrollbarWidth: 'thin', // 👈 scrollbar tipis di Firefox
msOverflowStyle: '-ms-autohiding-scrollbar', // 👈 untuk IE/Edge
}} }}
> >
{Math.round(scale * 100)}% <TabsTab
</Box> value="zoom-out"
onClick={handleZoomOut}
leftSection={<IconZoomOut size={16} />}
style={{ flexShrink: 0 }} // 👈 pastikan tidak mengecil
>
Zoom Out
</TabsTab>
<Button <Box
bg={colors['blue-button-2']} bg={colors['blue-button-2']}
c={colors['blue-button']} c={colors['blue-button']}
variant="light" px={12}
size="sm" py={6}
onClick={handleZoomIn} style={{
leftSection={<IconZoomIn size={16} />} fontSize: 14,
> fontWeight: 700,
Zoom In borderRadius: '6px',
</Button> minWidth: 60,
textAlign: 'center',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
whiteSpace: 'nowrap', // 👈 mencegah text wrap
}}
>
{Math.round(scale * 100)}%
</Box>
<Button <TabsTab
bg={colors['blue-button-2']} value="zoom-in"
c={colors['blue-button']} onClick={handleZoomIn}
variant="light" leftSection={<IconZoomIn size={16} />}
size="sm" style={{ flexShrink: 0 }}
onClick={resetZoom} >
> Zoom In
Reset </TabsTab>
</Button>
<Button <TabsTab
bg={colors['blue-button-2']} value="reset"
c={colors['blue-button']} onClick={resetZoom}
size="sm" style={{ flexShrink: 0 }}
onClick={toggleFullscreen} >
leftSection={ Reset
isFullscreen ? ( </TabsTab>
<IconArrowsMinimize size={16} />
) : ( <TabsTab
<IconArrowsMaximize size={16} /> value="fullscreen"
) onClick={toggleFullscreen}
} leftSection={
> isFullscreen ? (
Fullscreen <IconArrowsMinimize size={16} />
</Button> ) : (
</Group> <IconArrowsMaximize size={16} />
</Group> )
}
style={{ flexShrink: 0 }}
>
{isFullscreen ? 'Exit' : 'Fullscreen'}
</TabsTab>
</TabsList>
</Tabs>
</Stack>
</Paper> </Paper>
{/* 🧩 Chart Container */} {/* 🧩 Chart Container */}
@@ -325,15 +363,20 @@ function StrukturOrganisasiPPID() {
maxWidth: '100%', maxWidth: '100%',
padding: '32px 16px', padding: '32px 16px',
transition: 'transform 0.2s ease', transition: 'transform 0.2s ease',
transform: `scale(${scale})`,
transformOrigin: 'center top',
}} }}
> >
<OrganizationChart <Box style={{
value={chartData} transform: `scale(${scale})`,
nodeTemplate={(node) => <NodeCard node={node} router={router} />} transformOrigin: 'center top',
className="p-organizationchart p-organizationchart-horizontal" display: 'inline-block', // 👈 agar tidak memenuhi lebar parent
/> minWidth: 'min-content', // 👈 penting agar chart tidak dipaksa muat di width 100%
}}>
<OrganizationChart
value={chartData}
nodeTemplate={(node) => <NodeCard node={node} router={router} />}
className="p-organizationchart p-organizationchart-horizontal"
/>
</Box>
</Box> </Box>
</Center> </Center>
</Stack> </Stack>
@@ -345,6 +388,7 @@ function NodeCard({ node, router }: any) {
const name = node?.data?.name || 'Tanpa Nama' const name = node?.data?.name || 'Tanpa Nama'
const title = node?.data?.title || 'Tanpa Jabatan' const title = node?.data?.title || 'Tanpa Jabatan'
const hasId = Boolean(node?.data?.id) const hasId = Boolean(node?.data?.id)
const isMobile = useMediaQuery("(max-width: 768px)");
return ( return (
<Transition mounted transition="pop" duration={300}> <Transition mounted transition="pop" duration={300}>
@@ -355,9 +399,10 @@ function NodeCard({ node, router }: any) {
withBorder withBorder
style={{ style={{
...styles, ...styles,
width: 240, width: '100%',
minHeight: 280, maxWidth: isMobile ? 200 : 240, // lebih kecil di mobile
padding: 20, minHeight: isMobile ? 240 : 280,
padding: isMobile ? 16 : 20,
background: 'linear-gradient(135deg, rgba(28,110,164,0.15) 0%, rgba(255,255,255,0.95) 100%)', 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)', borderColor: 'rgba(28, 110, 164, 0.3)',
borderWidth: 2, borderWidth: 2,
@@ -411,6 +456,7 @@ function NodeCard({ node, router }: any) {
c={colors['blue-button']} c={colors['blue-button']}
lineClamp={2} lineClamp={2}
style={{ style={{
// fontSize: 'clamp(12px, 4vw, 16px)', // 👈 responsif font size
minHeight: 40, minHeight: 40,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',

View File

@@ -75,7 +75,7 @@ function Page() {
lh={1.7} lh={1.7}
ta="center" ta="center"
dangerouslySetInnerHTML={{ __html: item.visi }} dangerouslySetInnerHTML={{ __html: item.visi }}
style={{wordBreak: "break-word", whiteSpace: "normal"}} style={{ wordBreak: "break-word", whiteSpace: "normal" }}
/> />
</Box> </Box>
@@ -86,12 +86,15 @@ function Page() {
c={colors['blue-button']} mb="sm"> c={colors['blue-button']} mb="sm">
Misi PPID Misi PPID
</Text> </Text>
<Text <Box px={{ base: 'md', md: 100 }}>
fz={{ base: 'md', md: 'lg' }} <Text
lh={1.7} ta={"justify"}
dangerouslySetInnerHTML={{ __html: item.misi }} fz={{ base: 'md', md: 'lg' }}
style={{wordBreak: "break-word", whiteSpace: "normal"}} lh={1.7}
/> dangerouslySetInnerHTML={{ __html: item.misi }}
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
/>
</Box>
</Box> </Box>
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -1,85 +1,117 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import {
import { Box, Paper, Text, Group, CloseButton, Badge, ActionIcon, Stack, Transition } from "@mantine/core"; ActionIcon,
Badge,
Box,
CloseButton,
Group,
Paper,
Stack,
Text,
Transition,
} from "@mantine/core";
import { IconBell, IconChevronRight } from "@tabler/icons-react"; import { IconBell, IconChevronRight } from "@tabler/icons-react";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
interface NewsItem { // === Tipe yang bisa diimpor di tempat lain ===
id: string | number; export interface KategoriBerita {
id: string;
name: string;
}
export interface KategoriPengumuman {
id: string;
name: string;
}
export interface NewsItem {
id: string;
type: "berita" | "pengumuman"; type: "berita" | "pengumuman";
title: string; title: string;
content: string; content: string;
timestamp?: string | Date; timestamp?: string | Date;
kategoriBerita?: KategoriBerita;
kategoriPengumuman?: KategoriPengumuman;
} }
interface ModernNewsNotificationProps { export interface ModernNewsNotificationProps {
news: NewsItem[]; news: NewsItem[];
hasNewContent?: boolean;
newItemCount?: number;
onSeen?: () => void;
autoShowDelay?: number; autoShowDelay?: number;
} }
// === Helper ===
function stripHtml(html: string): string { function stripHtml(html: string): string {
return html return html
.replace(/<[^>]+>/g, '') .replace(/<[^>]+>/g, "")
.replace(/&nbsp;/gi, ' ') .replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, '&') .replace(/&amp;/gi, "&")
.replace(/\s+/g, ' ') .replace(/\s+/g, " ")
.trim(); .trim();
} }
// === Komponen Utama ===
export default function ModernNewsNotification({ export default function ModernNewsNotification({
news = [], news = [],
autoShowDelay = 2000 hasNewContent = false,
newItemCount = 0,
onSeen,
autoShowDelay = 2000,
}: ModernNewsNotificationProps) { }: ModernNewsNotificationProps) {
const router = useRouter(); const router = useRouter();
const [toastVisible, setToastVisible] = useState(false);
const [widgetOpen, setWidgetOpen] = useState(false);
const [hasNewNotifications, setHasNewNotifications] = useState(true);
const [hasShownToast, setHasShownToast] = useState(false);
const [iconVisible, setIconVisible] = useState(true);
const pathname = usePathname(); const pathname = usePathname();
// Auto show toast on page load const [toastVisible, setToastVisible] = useState(false);
const [widgetOpen, setWidgetOpen] = useState(false);
const [hasNewNotifications, setHasNewNotifications] = useState(hasNewContent);
const [hasShownToast, setHasShownToast] = useState(false);
const [iconVisible, setIconVisible] = useState(true);
// Sinkronisasi prop eksternal
useEffect(() => {
setHasNewNotifications(hasNewContent);
}, [hasNewContent]);
// Tampilkan toast pertama kali
useEffect(() => { useEffect(() => {
if (news.length > 0 && !toastVisible && !hasShownToast) { if (news.length > 0 && !toastVisible && !hasShownToast) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
setToastVisible(true); setToastVisible(true);
setHasShownToast(true); setHasShownToast(true);
if (hasNewNotifications) onSeen?.();
}, autoShowDelay); }, autoShowDelay);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
}, [news.length, autoShowDelay, toastVisible, hasShownToast]); }, [news.length, autoShowDelay, toastVisible, hasShownToast, hasNewNotifications, onSeen]);
// Auto hide toast after 8 seconds // Sembunyikan toast otomatis
useEffect(() => { useEffect(() => {
if (toastVisible) { if (toastVisible) {
const timer = setTimeout(() => { const timer = setTimeout(() => setToastVisible(false), 8000);
setToastVisible(false);
}, 8000);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
}, [toastVisible]); }, [toastVisible]);
// Enhanced scroll handler with better thresholds // Kontrol visibilitas ikon saat scroll
useEffect(() => { useEffect(() => {
let lastScrollY = window.scrollY; let lastScrollY = window.scrollY;
const HIDE_THRESHOLD = 100; // Mulai hide saat scroll > 100px const HIDE_THRESHOLD = 100;
const SHOW_THRESHOLD = 50; // Hanya show ketika benar-benar di atas (< 50px) const SHOW_THRESHOLD = 50;
const handleScroll = () => { const handleScroll = () => {
const currentScrollY = window.scrollY; const currentScrollY = window.scrollY;
const scrollDirection = currentScrollY > lastScrollY ? 'down' : 'up'; const scrollDirection = currentScrollY > lastScrollY ? "down" : "up";
// Logic untuk hide/show icon if (scrollDirection === "down" && currentScrollY > HIDE_THRESHOLD) {
if (scrollDirection === 'down' && currentScrollY > HIDE_THRESHOLD) {
// Scroll ke bawah dan sudah melewati threshold → hide
setIconVisible(false); setIconVisible(false);
} else if (scrollDirection === 'up' && currentScrollY < SHOW_THRESHOLD) { } else if (scrollDirection === "up" && currentScrollY < SHOW_THRESHOLD) {
// Scroll ke atas dan sudah di posisi paling atas → show
setIconVisible(true); setIconVisible(true);
} }
// Hide toast saat scroll ke bawah melewati 150px
if (currentScrollY > 150 && toastVisible) { if (currentScrollY > 150 && toastVisible) {
setToastVisible(false); setToastVisible(false);
} }
@@ -87,19 +119,25 @@ export default function ModernNewsNotification({
lastScrollY = currentScrollY; lastScrollY = currentScrollY;
}; };
window.addEventListener('scroll', handleScroll, { passive: true }); window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll); return () => window.removeEventListener("scroll", handleScroll);
}, [toastVisible]); }, [toastVisible]);
const currentNews = news[0]; const currentNews = news[0];
// Handle notification click // 🔗 Arahkan ke detail dengan kategori aman
const handleNotificationClick = (item: NewsItem) => { const handleNotificationClick = (item: NewsItem) => {
setWidgetOpen(false); setWidgetOpen(false);
onSeen?.();
if (item.type === "berita") { if (item.type === "berita") {
router.push("/darmasaba/desa/berita/semua"); const kategori = item.kategoriBerita?.name || "umum";
const safeKategori = encodeURIComponent(kategori);
router.push(`/darmasaba/desa/berita/${safeKategori}/${item.id}`);
} else if (item.type === "pengumuman") { } else if (item.type === "pengumuman") {
router.push("/darmasaba/desa/pengumuman"); const kategori = item.kategoriPengumuman?.name || "umum";
const safeKategori = encodeURIComponent(kategori);
router.push(`/darmasaba/desa/pengumuman/${safeKategori}/${item.id}`);
} }
}; };
@@ -107,12 +145,17 @@ export default function ModernNewsNotification({
setToastVisible(false); setToastVisible(false);
setWidgetOpen(true); setWidgetOpen(true);
setHasNewNotifications(false); setHasNewNotifications(false);
onSeen?.();
}; };
// Only show on landing page const handleDismissToast = (e: React.MouseEvent) => {
if (pathname !== '/darmasaba') { e.stopPropagation();
return null; setToastVisible(false);
} onSeen?.();
};
// Hanya tampilkan di landing page
if (pathname !== "/darmasaba") return null;
return ( return (
<> <>
@@ -133,8 +176,9 @@ export default function ModernNewsNotification({
variant="filled" variant="filled"
color="#1e5a7e" color="#1e5a7e"
onClick={() => { onClick={() => {
setWidgetOpen(!widgetOpen); setWidgetOpen((open) => !open);
setHasNewNotifications(false); setHasNewNotifications(false);
onSeen?.();
}} }}
style={{ style={{
width: "60px", width: "60px",
@@ -146,20 +190,22 @@ export default function ModernNewsNotification({
<IconBell size={28} /> <IconBell size={28} />
{hasNewNotifications && news.length > 0 && ( {hasNewNotifications && news.length > 0 && (
<Badge <Badge
size="sm" size="sm"
variant="filled" variant="filled"
color="red" color="red"
style={{ style={{
position: "absolute", position: "absolute",
top: "6px", top: "6px",
right: "6px", right: "6px",
minWidth: "22px", minWidth: "22px",
height: "22px", height: "22px",
padding: "0 6px", display: "flex",
}} alignItems: "center",
> justifyContent: "center",
{news.length} }}
</Badge> >
{newItemCount || news.length}
</Badge>
)} )}
</ActionIcon> </ActionIcon>
</Box> </Box>
@@ -174,8 +220,9 @@ export default function ModernNewsNotification({
...styles, ...styles,
position: "fixed", position: "fixed",
bottom: "100px", bottom: "100px",
right: "24px", left: "24px",
width: "380px", width: "90vw",
maxWidth: 380,
maxHeight: "500px", maxHeight: "500px",
boxShadow: "0 8px 32px rgba(0,0,0,0.12)", boxShadow: "0 8px 32px rgba(0,0,0,0.12)",
borderRadius: "16px", borderRadius: "16px",
@@ -192,32 +239,33 @@ export default function ModernNewsNotification({
<Group justify="space-between"> <Group justify="space-between">
<Group gap="xs"> <Group gap="xs">
<IconBell size={20} /> <IconBell size={20} />
<Text c="white" fw={600} size="md">Berita & Pengumuman</Text> <Text c="white" fw={600} size="md">
Berita & Pengumuman
</Text>
</Group> </Group>
<CloseButton <CloseButton
onClick={() => setWidgetOpen(false)} onClick={() => {
setWidgetOpen(false);
onSeen?.();
}}
variant="transparent" variant="transparent"
c="white" c="white"
/> />
</Group> </Group>
</Box> </Box>
<Box <Box style={{ maxHeight: "400px", overflowY: "auto", padding: "12px" }}>
style={{
maxHeight: "400px",
overflowY: "auto",
padding: "12px",
}}
>
{news.length === 0 ? ( {news.length === 0 ? (
<Box p="xl" style={{ textAlign: "center" }}> <Box p="xl" style={{ textAlign: "center" }}>
<Text c="dimmed" size="sm">Tidak ada berita terbaru</Text> <Text c="dimmed" size="sm">
Tidak ada berita terbaru
</Text>
</Box> </Box>
) : ( ) : (
<Stack gap="xs"> <Stack gap="xs">
{news.map((item, index) => ( {news.map((item) => (
<Paper <Paper
key={item.id || index} key={item.id}
p="md" p="md"
radius="md" radius="md"
style={{ style={{
@@ -243,7 +291,7 @@ export default function ModernNewsNotification({
color={item.type === "berita" ? "blue" : "orange"} color={item.type === "berita" ? "blue" : "orange"}
variant="light" variant="light"
> >
{item.type === "berita" ? "📰 Berita" : "📢 Pengumuman"} {item.type === "berita" ? "Berita" : "Pengumuman"}
</Badge> </Badge>
<IconChevronRight size={16} color="#adb5bd" /> <IconChevronRight size={16} color="#adb5bd" />
</Group> </Group>
@@ -263,15 +311,20 @@ export default function ModernNewsNotification({
</Transition> </Transition>
{/* Toast Notification */} {/* Toast Notification */}
<Transition mounted={toastVisible && !!currentNews} transition="slide-left" duration={300}> <Transition
mounted={toastVisible && !!currentNews}
transition="slide-left"
duration={300}
>
{(styles) => ( {(styles) => (
<Paper <Paper
style={{ style={{
...styles, ...styles,
position: "fixed", position: "fixed",
bottom: "100px", bottom: "100px",
right: "24px", left: "24px",
width: "380px", width: "90vw",
maxWidth: 380,
boxShadow: "0 8px 32px rgba(0,0,0,0.15)", boxShadow: "0 8px 32px rgba(0,0,0,0.15)",
borderRadius: "12px", borderRadius: "12px",
overflow: "hidden", overflow: "hidden",
@@ -299,17 +352,12 @@ export default function ModernNewsNotification({
size="md" size="md"
color={currentNews?.type === "berita" ? "blue" : "orange"} color={currentNews?.type === "berita" ? "blue" : "orange"}
variant="light" variant="light"
leftSection={currentNews?.type === "berita" ? "📰" : "📢"}
> >
{currentNews?.type === "berita" ? "Berita Terbaru" : "Pengumuman"} {currentNews?.type === "berita"
? "Berita Terbaru"
: "Pengumuman"}
</Badge> </Badge>
<CloseButton <CloseButton onClick={handleDismissToast} size="sm" />
onClick={(e) => {
e.stopPropagation();
setToastVisible(false);
}}
size="sm"
/>
</Group> </Group>
<Text fw={600} size="sm" mb={6}> <Text fw={600} size="sm" mb={6}>
@@ -322,7 +370,7 @@ export default function ModernNewsNotification({
<Group justify="space-between" mt="md"> <Group justify="space-between" mt="md">
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{news.length > 1 ? `${news.length} berita tersedia` : '1 berita'} {news.length > 1 ? `${news.length} berita tersedia` : "1 berita"}
</Text> </Text>
<Text <Text
size="xs" size="xs"

View File

@@ -4,7 +4,7 @@ import indeksKepuasanState from "@/app/admin/(dashboard)/_state/landing-page/ind
import colors from "@/con/colors"; import colors from "@/con/colors";
import { BarChart, PieChart } from '@mantine/charts'; import { BarChart, PieChart } from '@mantine/charts';
import { Box, Button, Center, Container, Flex, Modal, Paper, Select, SimpleGrid, Skeleton, Stack, Text, TextInput, Title } from "@mantine/core"; import { Box, Button, Center, Container, Flex, Modal, Paper, Select, SimpleGrid, Skeleton, Stack, Text, TextInput, Title } from "@mantine/core";
import { useDisclosure, useShallowEffect } from "@mantine/hooks"; import { useDisclosure, useMediaQuery, useShallowEffect } from "@mantine/hooks";
import { useState } from "react"; import { useState } from "react";
import { useProxy } from "valtio/utils"; import { useProxy } from "valtio/utils";
@@ -25,6 +25,7 @@ function Kepuasan() {
const [donutDataKelompokUmur, setDonutDataKelompokUmur] = useState<ChartDataItem[]>([]); const [donutDataKelompokUmur, setDonutDataKelompokUmur] = useState<ChartDataItem[]>([]);
const [barChartData, setBarChartData] = useState<Array<{ month: string; Responden: number }>>([]); const [barChartData, setBarChartData] = useState<Array<{ month: string; Responden: number }>>([]);
const [opened, { open, close }] = useDisclosure(false) const [opened, { open, close }] = useDisclosure(false)
const isMobile = useMediaQuery("(max-width: 768px)");
const resetForm = () => { const resetForm = () => {
state.create.form = { state.create.form = {
@@ -41,7 +42,7 @@ function Kepuasan() {
indeksKepuasanState.jenisKelaminResponden.findMany.load() indeksKepuasanState.jenisKelaminResponden.findMany.load()
indeksKepuasanState.pilihanRatingResponden.findMany.load() indeksKepuasanState.pilihanRatingResponden.findMany.load()
indeksKepuasanState.kelompokUmurResponden.findMany.load() indeksKepuasanState.kelompokUmurResponden.findMany.load()
},[]) }, [])
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
@@ -82,13 +83,13 @@ function Kepuasan() {
// Update gender chart data // Update gender chart data
setDonutDataJenisKelamin([ setDonutDataJenisKelamin([
{ name: 'Laki-laki', value: totalLaki, color: colors['blue-button'] }, { name: 'Laki-laki', value: totalLaki, color: '#52ABE3FF' },
{ name: 'Perempuan', value: totalPerempuan, color: '#10A85AFF' }, { name: 'Perempuan', value: totalPerempuan, color: '#10A85AFF' },
]); ]);
// Update rating chart data // Update rating chart data
setDonutDataRating([ setDonutDataRating([
{ name: 'Sangat Baik', value: totalSangatBaik, color: colors['blue-button'] }, { name: 'Sangat Baik', value: totalSangatBaik, color: '#52ABE3FF' },
{ name: 'Baik', value: totalBaik, color: '#10A85AFF' }, { name: 'Baik', value: totalBaik, color: '#10A85AFF' },
{ name: 'Kurang Baik', value: totalKurangBaik, color: '#FFA500' }, { name: 'Kurang Baik', value: totalKurangBaik, color: '#FFA500' },
{ name: 'Sangat Kurang Baik', value: totalSangatKurangBaik, color: '#FF4500' }, { name: 'Sangat Kurang Baik', value: totalSangatKurangBaik, color: '#FF4500' },
@@ -96,7 +97,7 @@ function Kepuasan() {
// Update age group chart data // Update age group chart data
setDonutDataKelompokUmur([ setDonutDataKelompokUmur([
{ name: 'Muda', value: totalMuda, color: colors['blue-button'] }, { name: 'Muda', value: totalMuda, color: '#52ABE3FF' },
{ name: 'Dewasa', value: totalDewasa, color: '#10A85AFF' }, { name: 'Dewasa', value: totalDewasa, color: '#10A85AFF' },
{ name: 'Lansia', value: totalLansia, color: '#FFA500' }, { name: 'Lansia', value: totalLansia, color: '#FFA500' },
]); ]);
@@ -220,10 +221,13 @@ function Kepuasan() {
<Box style={{ position: 'relative', width: '100%' }}> <Box style={{ position: 'relative', width: '100%' }}>
<Center> <Center>
<PieChart <PieChart
withLabels
withTooltip withTooltip
tooltipAnimationDuration={200}
withLabels
labelsPosition="inside" // 👈 ini yang penting!
labelsType="percent" labelsType="percent"
size={250} // Fixed size in pixels withLabelsLine
size={isMobile ? 180 : 250} // 👈 kecilkan ukuran di mobile
data={donutDataJenisKelamin} data={donutDataJenisKelamin}
/> />
</Center> </Center>
@@ -259,10 +263,10 @@ function Kepuasan() {
withTooltip withTooltip
tooltipAnimationDuration={200} tooltipAnimationDuration={200}
withLabels withLabels
labelsPosition="outside" labelsPosition="inside" // 👈 ini yang penting!
labelsType="percent" labelsType="percent"
withLabelsLine withLabelsLine
size={250} size={isMobile ? 180 : 250} // 👈 kecilkan ukuran di mobile
data={donutDataRating} data={donutDataRating}
/> />
</Center> </Center>
@@ -302,10 +306,10 @@ function Kepuasan() {
withTooltip withTooltip
tooltipAnimationDuration={200} tooltipAnimationDuration={200}
withLabels withLabels
labelsPosition="outside" labelsPosition="inside"// 👈 ini yang penting!
labelsType="percent" labelsType="percent"
withLabelsLine withLabelsLine
size={250} size={isMobile ? 180 : 250} // 👈 kecilkan ukuran di mobile
data={donutDataKelompokUmur} data={donutDataKelompokUmur}
/> />
</Center> </Center>
@@ -494,6 +498,8 @@ function Kepuasan() {
<PieChart <PieChart
withLabels withLabels
withTooltip withTooltip
labelsPosition="inside"
labelsType="percent" labelsType="percent"
size={200} size={200}
data={donutDataJenisKelamin} data={donutDataJenisKelamin}
@@ -531,7 +537,8 @@ function Kepuasan() {
withTooltip withTooltip
tooltipAnimationDuration={200} tooltipAnimationDuration={200}
withLabels withLabels
labelsPosition="outside"
labelsPosition="inside"
labelsType="percent" labelsType="percent"
withLabelsLine withLabelsLine
size={200} size={200}
@@ -574,7 +581,8 @@ function Kepuasan() {
withTooltip withTooltip
tooltipAnimationDuration={200} tooltipAnimationDuration={200}
withLabels withLabels
labelsPosition="outside"
labelsPosition="inside"
labelsType="percent" labelsType="percent"
withLabelsLine withLabelsLine
size={190} size={190}
@@ -610,7 +618,7 @@ function Kepuasan() {
<TextInput <TextInput
label="Nama" label="Nama"
type='text' type='text'
placeholder="masukkan nama" placeholder="Masukkan nama"
value={state.create.form.name} value={state.create.form.name}
onChange={(val) => { onChange={(val) => {
state.create.form.name = val.currentTarget.value; state.create.form.name = val.currentTarget.value;
@@ -619,7 +627,7 @@ function Kepuasan() {
<TextInput <TextInput
label="Tanggal Pengisian" label="Tanggal Pengisian"
type="date" type="date"
placeholder="masukkan tanggal" placeholder="Masukkan tanggal"
value={state.create.form.tanggal} value={state.create.form.tanggal}
onChange={(val) => { onChange={(val) => {
state.create.form.tanggal = val.currentTarget.value; state.create.form.tanggal = val.currentTarget.value;

View File

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

View File

@@ -1,10 +1,10 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client'; 'use client';
import penghargaanState from "@/app/admin/(dashboard)/_state/desa/penghargaan"; import penghargaanState from "@/app/admin/(dashboard)/_state/desa/penghargaan";
import { Stack, Box, Container, Button, Text, Loader, Paper } from "@mantine/core"; import { Stack, Box, Container, Button, Text, Loader, Paper, Center, ActionIcon } from "@mantine/core";
import { IconAward, IconArrowRight } from "@tabler/icons-react"; import { IconAward, IconArrowRight, IconPlayerPlay } from "@tabler/icons-react";
import { useTransitionRouter } from 'next-view-transitions'; import { useTransitionRouter } from 'next-view-transitions';
import { useEffect, useState } from "react"; import { useEffect, useState, useRef } from "react";
import { useProxy } from "valtio/utils"; import { useProxy } from "valtio/utils";
import { useMediaQuery } from "@mantine/hooks"; import { useMediaQuery } from "@mantine/hooks";
@@ -13,6 +13,37 @@ function Penghargaan() {
const state = useProxy(penghargaanState); const state = useProxy(penghargaanState);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const isMobile = useMediaQuery('(max-width: 768px)'); const isMobile = useMediaQuery('(max-width: 768px)');
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const [showVideo, setShowVideo] = useState(true);
const [videoError, setVideoError] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
// Deteksi iOS dengan lebih akurat
const isIOS = typeof window !== 'undefined' && (
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) // iPad dengan iPadOS 13+
);
useEffect(() => {
// Di iOS, coba autoplay dulu, kalau gagal tampilkan fallback
if (isIOS && videoRef.current) {
const playPromise = videoRef.current.play();
if (playPromise !== undefined) {
playPromise
.then(() => {
// Autoplay berhasil
setShowVideo(true);
setIsVideoLoaded(true);
})
.catch(() => {
// Autoplay gagal, tampilkan fallback
setShowVideo(false);
setVideoError(true);
});
}
}
}, [isIOS]);
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
@@ -26,28 +57,99 @@ function Penghargaan() {
loadData(); loadData();
}, []); }, []);
const handlePlayVideo = () => {
setShowVideo(true);
setVideoError(false);
// Paksa play video setelah user interaction
setTimeout(() => {
if (videoRef.current) {
videoRef.current.play().catch(err => {
console.error("Video play error:", err);
setVideoError(true);
});
}
}, 100);
};
// kalau mobile ambil 1 data aja, kalau desktop ambil 3 // kalau mobile ambil 1 data aja, kalau desktop ambil 3
const data = state.findMany.data?.slice(0, isMobile ? 1 : 3); const data = state.findMany.data?.slice(0, isMobile ? 1 : 3);
return ( return (
<Stack pos="relative" h="auto" mih={{ base: 500, md: 720 }}> <Stack pos="relative" h="auto" mih={{ base: 500, md: 720 }} style={{ overflow: 'hidden' }}>
<video {/* Video Layer */}
loop {showVideo && !videoError && (
autoPlay <video
muted ref={videoRef}
style={{ autoPlay
width: "100%", muted
height: "100%", loop
objectFit: "cover", playsInline
position: "absolute", preload="auto"
top: 0, onLoadedData={() => setIsVideoLoaded(true)}
left: 0, onError={() => {
zIndex: 0, console.error("Video load error");
}} setVideoError(true);
> setShowVideo(false);
<source src="/assets/videos/award.mp4" type="video/mp4" /> }}
</video> style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
opacity: isVideoLoaded ? 1 : 0,
transition: 'opacity 0.5s ease',
zIndex: 0,
}}
>
<source src="/assets/videos/award.mp4" type="video/mp4" />
</video>
)}
{/* Fallback Image + Play Button */}
{(!showVideo || videoError) && (
<Box
onClick={handlePlayVideo}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
backgroundImage: "url('/mangupuraaward.jpeg')",
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
cursor: 'pointer',
zIndex: 0,
}}
>
<Center
style={{
width: '100%',
height: '100%',
background: 'rgba(0,0,0,0.3)', // overlay gelap agar icon terlihat
}}
>
<ActionIcon
size={80}
radius="xl"
variant="filled"
color="blue"
style={{
backgroundColor: 'rgba(255,255,255,0.9)',
boxShadow: '0 8px 32px rgba(0,0,0,0.3)',
}}
>
<IconPlayerPlay size={40} color="var(--mantine-color-blue-6)" />
</ActionIcon>
</Center>
</Box>
)}
{/* Overlay Gradient + Content */}
<Box <Box
style={{ style={{
width: "100%", width: "100%",
@@ -126,4 +228,4 @@ function Penghargaan() {
); );
} }
export default Penghargaan; export default Penghargaan;

View File

@@ -1,5 +1,6 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client';
import DesaAntiKorupsi from "@/app/darmasaba/_com/main-page/desaantikorupsi"; import DesaAntiKorupsi from "@/app/darmasaba/_com/main-page/desaantikorupsi";
import Kepuasan from "@/app/darmasaba/_com/main-page/kepuasan"; import Kepuasan from "@/app/darmasaba/_com/main-page/kepuasan";
import LandingPage from "@/app/darmasaba/_com/main-page/landing-page"; import LandingPage from "@/app/darmasaba/_com/main-page/landing-page";
@@ -14,23 +15,41 @@ import Apbdes from "./_com/main-page/apbdes";
import Prestasi from "./_com/main-page/prestasi"; import Prestasi from "./_com/main-page/prestasi";
import ScrollToTopButton from "./_com/scrollToTopButton"; import ScrollToTopButton from "./_com/scrollToTopButton";
import { useEffect, useMemo } from "react"; import { useEffect, useRef, useState } from "react";
import { useSnapshot } from "valtio"; import { useSnapshot } from "valtio";
import stateDashboardBerita from "../admin/(dashboard)/_state/desa/berita"; import stateDashboardBerita from "../admin/(dashboard)/_state/desa/berita";
import stateDesaPengumuman from "../admin/(dashboard)/_state/desa/pengumuman"; import stateDesaPengumuman from "../admin/(dashboard)/_state/desa/pengumuman";
import ModernNewsNotification from "./_com/ModernNeewsNotification";
import NewsReaderLanding from "./_com/NewsReaderalanding";
import NewsReaderLanding from "./_com/NewsReaderalanding";
import ModernNewsNotification from "./_com/ModernNewsNotification";
import type { NewsItem } from "./_com/ModernNewsNotification"; // pastikan tipe ini diekspor
export default function Page() { export default function Page() {
// Tetap gunakan Valtio untuk card utama (NewsReaderLanding)
const snap1 = useSnapshot(stateDashboardBerita.berita.findFirst); const snap1 = useSnapshot(stateDashboardBerita.berita.findFirst);
const snap2 = useSnapshot(stateDesaPengumuman.pengumuman.findFirst); const snap2 = useSnapshot(stateDesaPengumuman.pengumuman.findFirst);
const featured = snap1; const featured = snap1;
const pengumuman = snap2; const pengumuman = snap2;
const loadingFeatured = featured.loading; const loadingFeatured = featured.loading;
const loadingPengumuman = pengumuman.loading; const loadingPengumuman = pengumuman.loading;
// State untuk notifikasi
const [notificationNews, setNotificationNews] = useState<NewsItem[]>([]);
const [hasNewContent, setHasNewContent] = useState(false);
const [newItemCount, setNewItemCount] = useState(0);
const lastBeritaTimestamp = useRef<string | null>(null);
const lastPengumumanTimestamp = useRef<string | null>(null);
// Inisialisasi dari localStorage
useEffect(() => {
const savedBeritaTs = localStorage.getItem("lastSeenBeritaTs");
const savedPengumumanTs = localStorage.getItem("lastSeenPengumumanTs");
if (savedBeritaTs) lastBeritaTimestamp.current = savedBeritaTs;
if (savedPengumumanTs) lastPengumumanTimestamp.current = savedPengumumanTs;
}, []);
// Load data utama (untuk card)
useEffect(() => { useEffect(() => {
if (!featured.data && !loadingFeatured) { if (!featured.data && !loadingFeatured) {
stateDashboardBerita.berita.findFirst.load(); stateDashboardBerita.berita.findFirst.load();
@@ -43,49 +62,93 @@ export default function Page() {
} }
}, []); }, []);
// 🔁 Fetch berita & pengumuman lengkap untuk notifikasi
const fetchNotificationData = async () => {
try {
const res = await fetch("/api/news/latest");
const result = await res.json();
if (result.success && Array.isArray(result.news)) {
const news = result.news as NewsItem[];
const newsData = useMemo(() => { const latestBerita = news.find((n) => n.type === "berita");
const items = []; const latestPengumuman = news.find((n) => n.type === "pengumuman");
if (featured.data) { const latestBeritaTs = latestBerita?.timestamp
items.push({ ? new Date(latestBerita.timestamp).toISOString()
id: String(featured.data.id || "berita-1"), : null;
type: "berita" as const, const latestPengumumanTs = latestPengumuman?.timestamp
title: String(featured.data.judul || "Berita Terbaru"), ? new Date(latestPengumuman.timestamp).toISOString()
content: String(featured.data.content || ""), : null;
timestamp: featured.data.createdAt
? (typeof featured.data.createdAt === 'string' // Inisialisasi flag
? featured.data.createdAt let isNewBerita = false;
: new Date(featured.data.createdAt).toISOString()) let isNewPengumuman = false;
: new Date().toISOString(),
}); // Deteksi berita baru
if (latestBeritaTs) {
if (lastBeritaTimestamp.current === null) {
// Pertama kali: simpan tanpa notifikasi
lastBeritaTimestamp.current = latestBeritaTs;
localStorage.setItem("lastSeenBeritaTs", latestBeritaTs);
} else if (latestBeritaTs > lastBeritaTimestamp.current) {
isNewBerita = true;
lastBeritaTimestamp.current = latestBeritaTs;
}
}
// Deteksi pengumuman baru
if (latestPengumumanTs) {
if (lastPengumumanTimestamp.current === null) {
// Pertama kali: simpan tanpa notifikasi
lastPengumumanTimestamp.current = latestPengumumanTs;
localStorage.setItem("lastSeenPengumumanTs", latestPengumumanTs);
} else if (latestPengumumanTs > lastPengumumanTimestamp.current) {
isNewPengumuman = true;
lastPengumumanTimestamp.current = latestPengumumanTs;
}
}
// 🔔 Trigger notifikasi hanya jika ada yang benar-benar BARU
if (isNewBerita || isNewPengumuman) {
const count = (isNewBerita ? 1 : 0) + (isNewPengumuman ? 1 : 0);
setNewItemCount(count);
setHasNewContent(true); // ✅ INI YANG KAMU LUPA!
}
setNotificationNews(news);
}
} catch (err) {
console.error("Gagal fetch data notifikasi:", err);
} }
};
if (pengumuman.data) { // Load data notifikasi pertama kali
items.push({ useEffect(() => {
id: String(pengumuman.data.id || "pengumuman-1"), fetchNotificationData();
type: "pengumuman" as const, }, []);
title: String(pengumuman.data.judul || "Pengumuman Penting"),
content: String(pengumuman.data.content || ""), // Polling setiap 30 detik
timestamp: pengumuman.data.createdAt useEffect(() => {
? (typeof pengumuman.data.createdAt === 'string' const interval = setInterval(fetchNotificationData, 30_000);
? pengumuman.data.createdAt return () => clearInterval(interval);
: new Date(pengumuman.data.createdAt).toISOString()) }, []);
: new Date().toISOString(),
}); const handleSeen = () => {
setHasNewContent(false);
setNewItemCount(0);
const latestBerita = notificationNews.find(n => n.type === "berita");
const latestPengumuman = notificationNews.find(n => n.type === "pengumuman");
if (latestBerita) {
localStorage.setItem("lastSeenBeritaTs", new Date(latestBerita.timestamp!).toISOString());
} }
if (latestPengumuman) {
return items; localStorage.setItem("lastSeenPengumumanTs", new Date(latestPengumuman.timestamp!).toISOString());
}, [featured.data, pengumuman.data]); }
};
return ( return (
<Box id="page-root"> <Box id="page-root">
<Stack <Stack bg={colors.grey[1]} gap={0}>
bg={colors.grey[1]}
gap={0}
>
{/* HAPUS RUNNING TEXT, GANTI DENGAN MODERN NOTIFICATION */}
<LandingPage /> <LandingPage />
<Penghargaan /> <Penghargaan />
<Layanan /> <Layanan />
@@ -97,13 +160,15 @@ export default function Page() {
<Prestasi /> <Prestasi />
</Stack> </Stack>
{/* Tombol Scroll ke Atas */}
<ScrollToTopButton /> <ScrollToTopButton />
<NewsReaderLanding /> <NewsReaderLanding />
<ModernNewsNotification <ModernNewsNotification
news={newsData} news={notificationNews}
autoShowDelay={2000} // Muncul 2 detik setelah load hasNewContent={hasNewContent}
newItemCount={newItemCount}
onSeen={handleSeen}
autoShowDelay={2000}
/> />
</Box> </Box>
); );