diff --git a/src/app/admin/(dashboard)/ekonomi/demografi-pekerjaan/page.tsx b/src/app/admin/(dashboard)/ekonomi/demografi-pekerjaan/page.tsx index 0b2d4a92..1b41b2ae 100644 --- a/src/app/admin/(dashboard)/ekonomi/demografi-pekerjaan/page.tsx +++ b/src/app/admin/(dashboard)/ekonomi/demografi-pekerjaan/page.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ 'use client' import colors from '@/con/colors'; import { BarChart } from '@mantine/charts'; @@ -85,7 +86,7 @@ function ListDemografiPekerjaan({ search }: { search: string }) { useEffect(() => { if (data) { setChartData( - data.map((item) => ({ + data.map((item: any) => ({ id: item.id, pekerjaan: item.pekerjaan, lakiLaki: Number(item.lakiLaki), diff --git a/src/app/api/tts/route.ts b/src/app/api/tts/route.ts new file mode 100644 index 00000000..31106e6c --- /dev/null +++ b/src/app/api/tts/route.ts @@ -0,0 +1,35 @@ +// app/api/tts/route.ts +import { NextRequest } from 'next/server'; + +export async function POST(req: NextRequest) { + const { text } = await req.json(); + + if (!text || text.length > 500) { + return new Response('Teks terlalu panjang atau kosong', { status: 400 }); + } + + const res = await fetch('https://api.elevenlabs.io/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'xi-api-key': process.env.ELEVENLABS_API_KEY!, // Simpan di .env.local + }, + body: JSON.stringify({ + text, + model_id: 'eleven_multilingual_v2', + voice_settings: { + stability: 0.7, + similarity_boost: 0.8, + }, + }), + }); + + if (!res.ok) { + return new Response('Gagal generate suara', { status: 500 }); + } + + const audioBuffer = await res.arrayBuffer(); + return new Response(audioBuffer, { + headers: { 'Content-Type': 'audio/mpeg' }, + }); +} \ No newline at end of file diff --git a/src/app/darmasaba/(pages)/desa/berita/[kategori]/[id]/page.tsx b/src/app/darmasaba/(pages)/desa/berita/[kategori]/[id]/page.tsx index c78246d6..858f739a 100644 --- a/src/app/darmasaba/(pages)/desa/berita/[kategori]/[id]/page.tsx +++ b/src/app/darmasaba/(pages)/desa/berita/[kategori]/[id]/page.tsx @@ -55,7 +55,7 @@ function Page() { - + {state.findUnique.data?.judul} { - const [isSpeaking, setIsSpeaking] = useState(false); - const [isAllowed, setIsAllowed] = useState(false); + const [isPointerMode, setIsPointerMode] = useState(false); const utteranceRef = useRef(null); - // Fungsi untuk membaca teks - const speakText = () => { - if (typeof window === 'undefined' || !window.speechSynthesis) { - console.warn('Browser tidak mendukung SpeechSynthesis.'); - return; - } + const speakText = (text: string) => { + if (!window.speechSynthesis || !text.trim()) return; - const contentElement = document.getElementById('news-content'); - const rawText = contentElement?.innerText || ''; - if (!rawText.trim()) return; - - // Hentikan semua suara sebelumnya - window.speechSynthesis.cancel(); - - const utterance = new SpeechSynthesisUtterance(rawText); + window.speechSynthesis.cancel(); // hentikan sebelumnya + const utterance = new SpeechSynthesisUtterance(text); utterance.lang = 'id-ID'; utterance.rate = 1; utterance.pitch = 1; - - utterance.onstart = () => setIsSpeaking(true); - utterance.onend = () => setIsSpeaking(false); - utteranceRef.current = utterance; - - try { - window.speechSynthesis.speak(utterance); - } catch (err) { - console.warn('Autoplay gagal karena kebijakan browser:', err); - } + window.speechSynthesis.speak(utterance); }; - // Auto play jika sudah pernah diizinkan + // Tambahkan listener hover ke semua elemen teks useEffect(() => { - const hasPermission = localStorage.getItem('ttsAllowed') === 'true'; - setIsAllowed(hasPermission); + const content = document.getElementById('news-title'); + if (!content) return; - if (hasPermission) { - const trySpeak = setInterval(() => { - const contentElement = document.getElementById('news-content'); - if (contentElement && contentElement.innerText.trim()) { - speakText(); - clearInterval(trySpeak); - } - }, 1000); - return () => clearInterval(trySpeak); + // Atur cursor saat mode aktif/nonaktif + if (isPointerMode) { + content.style.cursor = 'pointer'; + } else { + content.style.cursor = 'auto'; } - }, []); - // Hentikan suara saat user keluar halaman / komponen unmount - useEffect(() => { - return () => { - if (typeof window !== 'undefined' && window.speechSynthesis) { - window.speechSynthesis.cancel(); - setIsSpeaking(false); + if (!isPointerMode) return; + + const handleMouseOver = (e: MouseEvent) => { + const target = e.target as HTMLElement; + // opsional: hanya baca teks dari elemen tertentu + if (target && target.innerText) { + speakText(target.innerText); + target.style.backgroundColor = '#eef6ff'; // highlight biar keliatan } }; - }, []); - // Handle tombol manual - const handleToggle = () => { - if (isSpeaking) { + const handleMouseOut = (e: MouseEvent) => { + const target = e.target as HTMLElement; + if (target) target.style.backgroundColor = ''; // hilangkan highlight window.speechSynthesis.cancel(); - setIsSpeaking(false); + }; + + content.addEventListener('mouseover', handleMouseOver); + content.addEventListener('mouseout', handleMouseOut); + + return () => { + content.removeEventListener('mouseover', handleMouseOver); + content.removeEventListener('mouseout', handleMouseOut); + content.style.cursor = 'auto'; // reset cursor saat mode dimatikan + window.speechSynthesis.cancel(); + }; + }, [isPointerMode]); + + useEffect(() => { + const content = document.getElementById('news-content'); + if (!content) return; + + // Atur cursor saat mode aktif/nonaktif + if (isPointerMode) { + content.style.cursor = 'pointer'; } else { - if (!isAllowed) { - localStorage.setItem('ttsAllowed', 'true'); - setIsAllowed(true); - } - speakText(); + content.style.cursor = 'auto'; } + + if (!isPointerMode) return; + + const handleMouseOver = (e: MouseEvent) => { + const target = e.target as HTMLElement; + // opsional: hanya baca teks dari elemen tertentu + if (target && target.innerText) { + speakText(target.innerText); + target.style.backgroundColor = '#eef6ff'; // highlight biar keliatan + } + }; + + const handleMouseOut = (e: MouseEvent) => { + const target = e.target as HTMLElement; + if (target) target.style.backgroundColor = ''; // hilangkan highlight + window.speechSynthesis.cancel(); + }; + + content.addEventListener('mouseover', handleMouseOver); + content.addEventListener('mouseout', handleMouseOut); + + return () => { + content.removeEventListener('mouseover', handleMouseOver); + content.removeEventListener('mouseout', handleMouseOut); + content.style.cursor = 'auto'; // reset cursor saat mode dimatikan + window.speechSynthesis.cancel(); + }; + }, [isPointerMode]); + + + + const handleToggle = () => { + setIsPointerMode((prev) => { + if (prev) { + window.speechSynthesis.cancel(); + } + return !prev; + }); }; return ( @@ -84,11 +114,20 @@ const NewsReader = () => { onClick={handleToggle} color="#0B4F78" variant="filled" - radius="xl" size="md" mt="md" + style={{ + zIndex: 500, + position: 'fixed', + bottom: '350px', + left: '0px', + borderBottomRightRadius: '20px', + borderTopRightRadius: '20px', + borderBottomLeftRadius: '0px', + borderTopLeftRadius: '0px', + }} > - {isSpeaking ? '🔇 Hentikan Suara' : '🔊 Dengarkan Berita'} + {isPointerMode ? : } ); }; diff --git a/src/app/darmasaba/_com/NewsReaderalanding.tsx b/src/app/darmasaba/_com/NewsReaderalanding.tsx new file mode 100644 index 00000000..53f21935 --- /dev/null +++ b/src/app/darmasaba/_com/NewsReaderalanding.tsx @@ -0,0 +1,110 @@ +'use client'; +import { Button } from '@mantine/core'; +import { IconMusic, IconMusicOff } from '@tabler/icons-react'; +import { useEffect, useRef, useState } from 'react'; + +const NewsReaderLanding = () => { + const [isPointerMode, setIsPointerMode] = useState(false); + const utteranceRef = useRef(null); + const timeoutRef = useRef | null>(null); + const lastTextRef = useRef(''); + + const speakText = (text: string) => { + if (!window.speechSynthesis || !text.trim()) return; + + // Jangan baca ulang kalau teksnya sama + if (lastTextRef.current === text) return; + lastTextRef.current = text; + + window.speechSynthesis.cancel(); + + const utterance = new SpeechSynthesisUtterance(text); + utterance.lang = 'id-ID'; + utterance.rate = 1; + utterance.pitch = 1; + + utteranceRef.current = utterance; + window.speechSynthesis.speak(utterance); + }; + + useEffect(() => { + const root = document.getElementById('page-root'); + if (!root) return; + + root.style.cursor = isPointerMode ? 'pointer' : 'auto'; + if (!isPointerMode) return; + + const handleMouseOver = (e: MouseEvent) => { + const target = e.target as HTMLElement; + if ( + !target || + !target.innerText || + target.tagName === 'BUTTON' || + target.tagName === 'SVG' || + target.closest('button') + ) + return; + + // Hapus timeout sebelumnya biar gak spam + if (timeoutRef.current) clearTimeout(timeoutRef.current); + + // Delay dikit biar smooth (hindari brebet) + timeoutRef.current = setTimeout(() => { + speakText(target.innerText); + }, 250); // 250ms delay kasih waktu pindah cursor + }; + + const handleMouseOut = () => { + // Delay kecil sebelum cancel supaya gak motong kasar + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => { + window.speechSynthesis.cancel(); + lastTextRef.current = ''; + }, 150); + }; + + root.addEventListener('mouseover', handleMouseOver); + root.addEventListener('mouseout', handleMouseOut); + + return () => { + root.removeEventListener('mouseover', handleMouseOver); + root.removeEventListener('mouseout', handleMouseOut); + root.style.cursor = 'auto'; + if (timeoutRef.current) clearTimeout(timeoutRef.current); + window.speechSynthesis.cancel(); + lastTextRef.current = ''; + }; + }, [isPointerMode]); + + const handleToggle = () => { + setIsPointerMode((prev) => { + if (prev) { + window.speechSynthesis.cancel(); + lastTextRef.current = ''; + } + return !prev; + }); + }; + + return ( + + ); +}; + +export default NewsReaderLanding; diff --git a/src/app/darmasaba/_com/main-page/apbdes/index.tsx b/src/app/darmasaba/_com/main-page/apbdes/index.tsx index 0c77e8c7..803065b4 100644 --- a/src/app/darmasaba/_com/main-page/apbdes/index.tsx +++ b/src/app/darmasaba/_com/main-page/apbdes/index.tsx @@ -1,14 +1,25 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable react-hooks/exhaustive-deps */ 'use client' import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes' import colors from '@/con/colors' -import { ActionIcon, BackgroundImage, Box, Button, Center, Flex, Group, Loader, SimpleGrid, Stack, Text } from '@mantine/core' +import { BarChart } from '@mantine/charts' +import { ActionIcon, BackgroundImage, Box, Button, Center, Flex, Group, Loader, Paper, SimpleGrid, Stack, Text, Title } from '@mantine/core' import { IconDownload } from '@tabler/icons-react' import Link from 'next/link' import { useEffect, useState } from 'react' import { useProxy } from 'valtio/utils' +import parseJumlah from './lib/convert' function Apbdes() { + type APBDes = { + id: string + name: string + jumlah: number + }; + + const [chartData, setChartData] = useState([]) + const [mounted, setMounted] = useState(false); const state = useProxy(apbdes) const [loading, setLoading] = useState(false) @@ -17,9 +28,22 @@ function Apbdes() { des: 'Transparansi APBDes Darmasaba adalah langkah nyata menuju tata kelola desa yang bersih, terbuka, dan bertanggung jawab.' } + useEffect(() => { + if (state.findMany.data) { + setChartData( + state.findMany.data.map((item: any) => ({ + id: item.id, + name: item.name, + jumlah: parseJumlah(item.jumlah), + })) + ); + } + }, [state.findMany.data]); + useEffect(() => { const loadData = async () => { try { + setMounted(true); setLoading(true) await state.findMany.load() } catch (error) { @@ -46,6 +70,55 @@ function Apbdes() { + {/* Chart */} + + + + + Grafik APBDes + + {mounted && chartData.length > 0 ? ( + { + if (value >= 1_000_000_000_000) + return `Rp ${(value / 1_000_000_000_000).toFixed(1)} T`; + if (value >= 1_000_000_000) + return `Rp ${(value / 1_000_000_000).toFixed(1)} M`; + if (value >= 1_000_000) + return `Rp ${(value / 1_000_000).toFixed(1)} Jt`; + if (value >= 1_000) + return `Rp ${(value / 1_000).toFixed(1)} Rb`; + return `Rp ${value}`; + }} + series={[ + { + name: 'jumlah', + color: colors['blue-button'], + label: 'Jumlah', + }, + ]} + /> + ) : ( + Belum ada data untuk ditampilkan dalam grafik + )} + + + + + Jumlah + + + + + + + {loading ? (
@@ -72,7 +145,7 @@ function Apbdes() { pos="relative" style={{ overflow: 'hidden' }} > - + (0); const isHoveredRef = useRef(false); - + // Refs for drag functionality const isDraggingRef = useRef(false); const startXRef = useRef(0); const scrollLeftRef = useRef(0); const velocityRef = useRef(0); const lastScrollTimeRef = useRef(0); - + // Speed configuration const normalSpeed = 1.0; // pixels per frame const hoverSpeed = 0.3; // slower speed on hover @@ -143,7 +143,7 @@ function Slider() { } else { // Sync scroll position when user is scrolling scrollPositionRef.current = container.scrollLeft; - + // Apply momentum/velocity for smooth drag release if (!isDraggingRef.current && Math.abs(velocityRef.current) > 0.1) { scrollPositionRef.current += velocityRef.current; @@ -176,26 +176,26 @@ function Slider() { // Mouse drag handlers const handleMouseDown = (e: React.MouseEvent) => { if (!containerRef.current) return; - + isDraggingRef.current = true; startXRef.current = e.pageX - containerRef.current.offsetLeft; scrollLeftRef.current = containerRef.current.scrollLeft; velocityRef.current = 0; - + containerRef.current.style.cursor = 'grabbing'; }; const handleMouseMove = (e: React.MouseEvent) => { if (!isDraggingRef.current || !containerRef.current) return; - + e.preventDefault(); const x = e.pageX - containerRef.current.offsetLeft; const walk = (x - startXRef.current) * 2; // Multiply for faster scroll const newScrollLeft = scrollLeftRef.current - walk; - + // Calculate velocity for momentum velocityRef.current = containerRef.current.scrollLeft - newScrollLeft; - + containerRef.current.scrollLeft = newScrollLeft; scrollPositionRef.current = newScrollLeft; lastScrollTimeRef.current = Date.now(); @@ -203,7 +203,7 @@ function Slider() { const handleMouseUp = () => { if (!containerRef.current) return; - + isDraggingRef.current = false; containerRef.current.style.cursor = 'grab'; }; @@ -211,7 +211,7 @@ function Slider() { // Wheel scroll handler const handleWheel = (e: React.WheelEvent) => { if (!containerRef.current) return; - + e.preventDefault(); containerRef.current.scrollLeft += e.deltaY; scrollPositionRef.current = containerRef.current.scrollLeft; diff --git a/src/app/darmasaba/_com/main-page/potensi/index.tsx b/src/app/darmasaba/_com/main-page/potensi/index.tsx index ff2bf284..7fc00670 100644 --- a/src/app/darmasaba/_com/main-page/potensi/index.tsx +++ b/src/app/darmasaba/_com/main-page/potensi/index.tsx @@ -51,10 +51,10 @@ function Potensi() { return ( - + {textHeading.title} - + {textHeading.des} diff --git a/src/app/darmasaba/page.tsx b/src/app/darmasaba/page.tsx index eab9320c..ba50fad1 100644 --- a/src/app/darmasaba/page.tsx +++ b/src/app/darmasaba/page.tsx @@ -13,11 +13,12 @@ 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 { useProxy } from "valtio/utils"; import stateDashboardBerita from "../admin/(dashboard)/_state/desa/berita"; -import { useEffect, useMemo } from "react"; import stateDesaPengumuman from "../admin/(dashboard)/_state/desa/pengumuman"; import ModernNewsNotification from "./_com/ModernNeewsNotification"; +import NewsReaderLanding from "./_com/NewsReaderalanding"; export default function Page() { @@ -74,7 +75,7 @@ export default function Page() { }, [featured.data, pengumuman.data]); return ( - + + + + ); } \ No newline at end of file