Compare commits

...

2 Commits

6 changed files with 299 additions and 143 deletions

View File

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

BIN
public/mangupuraaward.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

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

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

View File

@@ -1,8 +1,8 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import penghargaanState from "@/app/admin/(dashboard)/_state/desa/penghargaan";
import { Stack, Box, Container, Button, Text, Loader, Paper } from "@mantine/core";
import { IconAward, IconArrowRight } from "@tabler/icons-react";
import { Stack, Box, Container, Button, Text, Loader, Paper, Center, ActionIcon } from "@mantine/core";
import { IconAward, IconArrowRight, IconPlayerPlay } from "@tabler/icons-react";
import { useTransitionRouter } from 'next-view-transitions';
import { useEffect, useState } from "react";
import { useProxy } from "valtio/utils";
@@ -13,6 +13,18 @@ function Penghargaan() {
const state = useProxy(penghargaanState);
const [loading, setLoading] = useState(false);
const isMobile = useMediaQuery('(max-width: 768px)');
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const [showVideo, setShowVideo] = useState(true);
// Opsional: deteksi iOS
const isIOS = typeof window !== 'undefined' && /iPad|iPhone|iPod/.test(navigator.userAgent);
useEffect(() => {
if (isIOS) {
// Di iOS, jangan andalkan autoplay — tampilkan kontrol
setShowVideo(false);
}
}, []);
useEffect(() => {
const loadData = async () => {
@@ -31,22 +43,36 @@ function Penghargaan() {
return (
<Stack pos="relative" h="auto" mih={{ base: 500, md: 720 }}>
<video
loop
autoPlay
muted
style={{
width: "100%",
height: "100%",
objectFit: "cover",
position: "absolute",
top: 0,
left: 0,
zIndex: 0,
}}
>
<source src="/assets/videos/award.mp4" type="video/mp4" />
</video>
{showVideo ? (
<video
autoPlay
muted
loop
playsInline
webkit-playsinline="true"
onLoadedData={() => setIsVideoLoaded(true)}
style={{ opacity: isVideoLoaded ? 1 : 0, transition: 'opacity 0.5s' }}
>
<source src="/assets/videos/award.mp4" type="video/mp4" />
</video>
) : (
// Fallback: tampilkan poster + play button
<Box
onClick={() => setShowVideo(true)}
style={{
backgroundImage: "url('/mangupuraaward.jpeg')",
backgroundSize: 'cover',
backgroundPosition: 'center',
cursor: 'pointer',
}}
>
<Center h="100%">
<ActionIcon size="lg" radius="xl" color="white">
<IconPlayerPlay size={32} />
</ActionIcon>
</Center>
</Box>
)}
<Box
style={{

View File

@@ -1,5 +1,6 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
'use client';
import DesaAntiKorupsi from "@/app/darmasaba/_com/main-page/desaantikorupsi";
import Kepuasan from "@/app/darmasaba/_com/main-page/kepuasan";
import LandingPage from "@/app/darmasaba/_com/main-page/landing-page";
@@ -14,23 +15,41 @@ import Apbdes from "./_com/main-page/apbdes";
import Prestasi from "./_com/main-page/prestasi";
import ScrollToTopButton from "./_com/scrollToTopButton";
import { useEffect, useMemo } from "react";
import { useEffect, useRef, useState } from "react";
import { useSnapshot } from "valtio";
import stateDashboardBerita from "../admin/(dashboard)/_state/desa/berita";
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() {
// Tetap gunakan Valtio untuk card utama (NewsReaderLanding)
const snap1 = useSnapshot(stateDashboardBerita.berita.findFirst);
const snap2 = useSnapshot(stateDesaPengumuman.pengumuman.findFirst);
const featured = snap1;
const pengumuman = snap2;
const loadingFeatured = featured.loading;
const loadingPengumuman = pengumuman.loading;
// State untuk notifikasi
const [notificationNews, setNotificationNews] = useState<NewsItem[]>([]);
const [hasNewContent, setHasNewContent] = useState(false);
const [newItemCount, setNewItemCount] = useState(0);
const lastBeritaId = useRef<string | null>(null);
const lastPengumumanId = useRef<string | null>(null);
// Inisialisasi dari localStorage
useEffect(() => {
const savedBerita = localStorage.getItem("lastSeenBeritaId");
const savedPengumuman = localStorage.getItem("lastSeenPengumumanId");
if (savedBerita) lastBeritaId.current = savedBerita;
if (savedPengumuman) lastPengumumanId.current = savedPengumuman;
}, []);
// Load data utama (untuk card)
useEffect(() => {
if (!featured.data && !loadingFeatured) {
stateDashboardBerita.berita.findFirst.load();
@@ -43,49 +62,68 @@ 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 items = [];
// Ambil ID terbaru
const latestBerita = news.find((n) => n.type === "berita");
const latestPengumuman = news.find((n) => n.type === "pengumuman");
if (featured.data) {
items.push({
id: String(featured.data.id || "berita-1"),
type: "berita" as const,
title: String(featured.data.judul || "Berita Terbaru"),
content: String(featured.data.content || ""),
timestamp: featured.data.createdAt
? (typeof featured.data.createdAt === 'string'
? featured.data.createdAt
: new Date(featured.data.createdAt).toISOString())
: new Date().toISOString(),
});
const isNewBerita = latestBerita && lastBeritaId.current !== null && latestBerita.id !== lastBeritaId.current;
const isNewPengumuman = latestPengumuman && lastPengumumanId.current !== null && latestPengumuman.id !== lastPengumumanId.current;
// Simpan ID terbaru ke ref
if (latestBerita) lastBeritaId.current = (latestBerita.id);
if (latestPengumuman) lastPengumumanId.current = (latestPengumuman.id);
// Jika ini bukan inisialisasi pertama, tampilkan notifikasi
if (lastBeritaId.current !== null || lastPengumumanId.current !== null) {
if (isNewBerita || isNewPengumuman) {
const count = (isNewBerita ? 1 : 0) + (isNewPengumuman ? 1 : 0);
setNewItemCount(count);
setHasNewContent(true);
}
} else {
// Simpan ke localStorage saat pertama kali
if (latestBerita) localStorage.setItem("lastSeenBeritaId", (latestBerita.id));
if (latestPengumuman) localStorage.setItem("lastSeenPengumumanId", (latestPengumuman.id));
}
setNotificationNews(news);
}
} catch (err) {
console.error("Gagal fetch data notifikasi:", err);
}
};
if (pengumuman.data) {
items.push({
id: String(pengumuman.data.id || "pengumuman-1"),
type: "pengumuman" as const,
title: String(pengumuman.data.judul || "Pengumuman Penting"),
content: String(pengumuman.data.content || ""),
timestamp: pengumuman.data.createdAt
? (typeof pengumuman.data.createdAt === 'string'
? pengumuman.data.createdAt
: new Date(pengumuman.data.createdAt).toISOString())
: new Date().toISOString(),
});
}
// Load data notifikasi pertama kali
useEffect(() => {
fetchNotificationData();
}, []);
return items;
}, [featured.data, pengumuman.data]);
// Polling setiap 30 detik
useEffect(() => {
const interval = setInterval(fetchNotificationData, 30_000);
return () => clearInterval(interval);
}, []);
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("lastSeenBeritaId", String(latestBerita.id));
if (latestPengumuman) localStorage.setItem("lastSeenPengumumanId", String(latestPengumuman.id));
};
return (
<Box id="page-root">
<Stack
bg={colors.grey[1]}
gap={0}
>
{/* HAPUS RUNNING TEXT, GANTI DENGAN MODERN NOTIFICATION */}
<Stack bg={colors.grey[1]} gap={0}>
<LandingPage />
<Penghargaan />
<Layanan />
@@ -97,13 +135,15 @@ export default function Page() {
<Prestasi />
</Stack>
{/* Tombol Scroll ke Atas */}
<ScrollToTopButton />
<NewsReaderLanding />
<ModernNewsNotification
news={newsData}
autoShowDelay={2000} // Muncul 2 detik setelah load
news={notificationNews}
hasNewContent={hasNewContent}
newItemCount={newItemCount}
onSeen={handleSeen}
autoShowDelay={2000}
/>
</Box>
);