diff --git a/bun.lockb b/bun.lockb index fb4cf5da..f6b34ff1 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 1f829ad9..c25cb87d 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@elysiajs/static": "^1.3.0", "@elysiajs/stream": "^1.1.0", "@elysiajs/swagger": "^1.2.0", + "@emotion/react": "^11.14.0", "@mantine/carousel": "^7.16.2", "@mantine/charts": "^7.17.1", "@mantine/core": "^7.17.4", diff --git a/src/app/admin/(dashboard)/landing-page/profile/media-sosial/[id]/edit/page.tsx b/src/app/admin/(dashboard)/landing-page/profile/media-sosial/[id]/edit/page.tsx index 6f101c47..dd41c2a9 100644 --- a/src/app/admin/(dashboard)/landing-page/profile/media-sosial/[id]/edit/page.tsx +++ b/src/app/admin/(dashboard)/landing-page/profile/media-sosial/[id]/edit/page.tsx @@ -12,8 +12,7 @@ import { Stack, Text, TextInput, - Title, - Tooltip, + Title } from '@mantine/core'; import { Dropzone } from '@mantine/dropzone'; import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react'; @@ -96,11 +95,9 @@ function EditMediaSosial() { py="md" > - - - + Edit Media Sosial diff --git a/src/app/admin/(dashboard)/landing-page/profile/media-sosial/create/page.tsx b/src/app/admin/(dashboard)/landing-page/profile/media-sosial/create/page.tsx index ae615815..b42fb69a 100644 --- a/src/app/admin/(dashboard)/landing-page/profile/media-sosial/create/page.tsx +++ b/src/app/admin/(dashboard)/landing-page/profile/media-sosial/create/page.tsx @@ -11,8 +11,7 @@ import { Stack, Text, TextInput, - Title, - Tooltip, + Title } from '@mantine/core'; import { Dropzone } from '@mantine/dropzone'; import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react'; @@ -69,11 +68,9 @@ export default function CreateMediaSosial() { return ( - - - + Tambah Media Sosial diff --git a/src/app/admin/(dashboard)/landing-page/profile/media-sosial/page.tsx b/src/app/admin/(dashboard)/landing-page/profile/media-sosial/page.tsx index b5232fb9..587590b3 100644 --- a/src/app/admin/(dashboard)/landing-page/profile/media-sosial/page.tsx +++ b/src/app/admin/(dashboard)/landing-page/profile/media-sosial/page.tsx @@ -1,6 +1,6 @@ 'use client' import colors from '@/con/colors'; -import { Box, Button, Center, Group, Image, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title, Tooltip } from '@mantine/core'; +import { Box, Button, Center, Group, Image, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title } from '@mantine/core'; import { useShallowEffect } from '@mantine/hooks'; import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react'; import { useRouter } from 'next/navigation'; @@ -56,11 +56,9 @@ function ListMediaSosial({ search }: { search: string }) { Daftar Media Sosial - - diff --git a/src/app/admin/(dashboard)/landing-page/profile/pejabat-desa/[id]/page.tsx b/src/app/admin/(dashboard)/landing-page/profile/pejabat-desa/[id]/page.tsx index 9b7c4a70..25599d5b 100644 --- a/src/app/admin/(dashboard)/landing-page/profile/pejabat-desa/[id]/page.tsx +++ b/src/app/admin/(dashboard)/landing-page/profile/pejabat-desa/[id]/page.tsx @@ -3,7 +3,7 @@ import colors from '@/con/colors'; import { Alert, Box, Button, Center, Group, Image, - Paper, Stack, Text, TextInput, Title, Tooltip + Paper, Stack, Text, TextInput, Title } from '@mantine/core'; import { useEffect, useState } from 'react'; import { useProxy } from 'valtio/utils'; @@ -177,11 +177,9 @@ function EditPejabatDesa() { - - - + Edit Pejabat Desa diff --git a/src/app/admin/(dashboard)/landing-page/profile/program-inovasi/[id]/edit/page.tsx b/src/app/admin/(dashboard)/landing-page/profile/program-inovasi/[id]/edit/page.tsx index d4b44eb3..b3892f26 100644 --- a/src/app/admin/(dashboard)/landing-page/profile/program-inovasi/[id]/edit/page.tsx +++ b/src/app/admin/(dashboard)/landing-page/profile/program-inovasi/[id]/edit/page.tsx @@ -13,8 +13,7 @@ import { Stack, Text, TextInput, - Title, - Tooltip, + Title } from '@mantine/core'; import { Dropzone } from '@mantine/dropzone'; import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react'; @@ -104,11 +103,9 @@ function EditProgramInovasi() { return ( - - - + Edit Program Inovasi diff --git a/src/app/admin/(dashboard)/landing-page/profile/program-inovasi/create/page.tsx b/src/app/admin/(dashboard)/landing-page/profile/program-inovasi/create/page.tsx index de402dd6..2954b30a 100644 --- a/src/app/admin/(dashboard)/landing-page/profile/program-inovasi/create/page.tsx +++ b/src/app/admin/(dashboard)/landing-page/profile/program-inovasi/create/page.tsx @@ -12,8 +12,7 @@ import { Stack, Text, TextInput, - Title, - Tooltip + Title } from '@mantine/core'; import { Dropzone } from '@mantine/dropzone'; import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react'; @@ -70,11 +69,9 @@ function CreateProgramInovasi() { return ( - - - + Tambah Program Inovasi diff --git a/src/app/admin/(dashboard)/landing-page/profile/program-inovasi/page.tsx b/src/app/admin/(dashboard)/landing-page/profile/program-inovasi/page.tsx index b34a5887..ba77436b 100644 --- a/src/app/admin/(dashboard)/landing-page/profile/program-inovasi/page.tsx +++ b/src/app/admin/(dashboard)/landing-page/profile/program-inovasi/page.tsx @@ -51,8 +51,7 @@ function ListProgramInovasi({ search }: { search: string }) { Daftar Program Inovasi - - -
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 031608aa..c78246d6 100644 --- a/src/app/darmasaba/(pages)/desa/berita/[kategori]/[id]/page.tsx +++ b/src/app/darmasaba/(pages)/desa/berita/[kategori]/[id]/page.tsx @@ -1,8 +1,9 @@ /* eslint-disable react-hooks/exhaustive-deps */ 'use client' import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita'; +import NewsReader from '@/app/darmasaba/_com/NewsReader'; import colors from '@/con/colors'; -import { Box, Center, Container, Image, Skeleton, Stack, Text } from '@mantine/core'; +import { Box, Center, Container, Group, Image, Skeleton, Stack, Text } from '@mantine/core'; import { useParams } from 'next/navigation'; import { useEffect, useState } from 'react'; import { useProxy } from 'valtio/utils'; @@ -49,6 +50,9 @@ function Page() { return ( + + + @@ -67,6 +71,7 @@ function Page() { + + + @@ -42,7 +46,7 @@ function Page() { - + {new Date(detail.data?.createdAt).toLocaleDateString('id-ID', { weekday: 'long', diff --git a/src/app/darmasaba/(pages)/desa/profile/page.tsx b/src/app/darmasaba/(pages)/desa/profile/page.tsx index 9103580c..bb26f571 100644 --- a/src/app/darmasaba/(pages)/desa/profile/page.tsx +++ b/src/app/darmasaba/(pages)/desa/profile/page.tsx @@ -11,6 +11,7 @@ import ProfilPerbekel from './ui/profilPerbekel'; import MotoDesa from './ui/motoDesa'; import SemuaPerbekel from './ui/semuaPerbekel'; import ScrollToTopButton from '@/app/darmasaba/_com/scrollToTopButton'; +import StrukturPerangkatDesa from './struktur-perangkat-desa/page'; function Page() { return ( @@ -33,6 +34,7 @@ function Page() { + diff --git a/src/app/darmasaba/(pages)/desa/profile/struktur-perangkat-desa/[id]/page.tsx b/src/app/darmasaba/(pages)/desa/profile/struktur-perangkat-desa/[id]/page.tsx new file mode 100644 index 00000000..5e9f9159 --- /dev/null +++ b/src/app/darmasaba/(pages)/desa/profile/struktur-perangkat-desa/[id]/page.tsx @@ -0,0 +1,143 @@ +'use client'; +import stateStrukturPPID from '@/app/admin/(dashboard)/_state/ppid/struktur_ppid/struktur_PPID'; +import colors from '@/con/colors'; +import { + Box, + Divider, + Group, + Image, + Paper, + Skeleton, + Stack, + Text, + Title, +} from '@mantine/core'; +import { useShallowEffect } from '@mantine/hooks'; +import { useParams } from 'next/navigation'; +import { useProxy } from 'valtio/utils'; +import BackButton from '../../_com/BackButto'; + +function DetailPegawaiUser() { + const statePegawai = useProxy(stateStrukturPPID.pegawai); + const params = useParams(); + + useShallowEffect(() => { + stateStrukturPPID.posisiOrganisasi.findMany.load(); + statePegawai.findUnique.load(params?.id as string); + }, []); + + + if (!statePegawai.findUnique.data) { + return ( + + + + ); + } + + const data = statePegawai.findUnique.data; + + return ( + + {/* Back button */} + + + + + + + {/* Foto Profil */} + + + {/* Nama & Jabatan */} + + + {data.namaLengkap || '-'} {data.gelarAkademik || ''} + + + {data.posisi?.nama || 'Posisi tidak tersedia'} + + + + + + + {/* Informasi Detail */} + + + + + + + + + + ); +} + +/* Komponen kecil untuk menampilkan baris informasi */ +function InfoRow({ + label, + value, + valueColor, + multiline = false, +}: { + label: string; + value?: string | null; + valueColor?: string; + multiline?: boolean; +}) { + return ( + + + {label} + + + {value || '-'} + + + ); +} + +export default DetailPegawaiUser; diff --git a/src/app/darmasaba/(pages)/desa/profile/struktur-perangkat-desa/page.tsx b/src/app/darmasaba/(pages)/desa/profile/struktur-perangkat-desa/page.tsx new file mode 100644 index 00000000..5ac50922 --- /dev/null +++ b/src/app/darmasaba/(pages)/desa/profile/struktur-perangkat-desa/page.tsx @@ -0,0 +1,469 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +'use client' +import stateStrukturPPID from '@/app/admin/(dashboard)/_state/ppid/struktur_ppid/struktur_PPID' +import ScrollToTopButton from '@/app/darmasaba/_com/scrollToTopButton' +import colors from '@/con/colors' +import { + Box, + Button, + Card, + Center, + Group, + Image, + Loader, + Paper, + Stack, + Text, + TextInput, + Title, + Transition +} from '@mantine/core' +import { + IconArrowsMaximize, + IconArrowsMinimize, + IconRefresh, + IconSearch, + IconUsers, + IconZoomIn, + IconZoomOut, +} from '@tabler/icons-react' +import { debounce } from 'lodash' +import { useTransitionRouter } from 'next-view-transitions' +import { OrganizationChart } from 'primereact/organizationchart' +import { useEffect, useRef, useState } from 'react' +import { useProxy } from 'valtio/utils' +import './struktur.css' +import BackButton from '../_com/BackButto' + +export default function StrukturPerangkatDesa() { + return ( + + + + + + + Struktur Perangkat Desa + + + Gambaran visual peran dan pegawai yang ditugaskan. Arahkan kursor + untuk melihat detail atau klik node untuk fokus tampilan. + + + + + + + + + + + ) +} + +function StrukturPerangkatDesaNode() { + const stateOrganisasi: any = useProxy(stateStrukturPPID.pegawai) + const router = useTransitionRouter() + const chartContainerRef = useRef(null) + const [scale, setScale] = useState(1) + const [isFullscreen, setFullscreen] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + + // debounce pencarian + const debouncedSearch = useRef( + debounce((value: string) => { + setSearchQuery(value) + }, 400) + ).current + + useEffect(() => { + void stateOrganisasi.findMany.load() + }, []) + + const isLoading = + !stateOrganisasi.findMany.data && stateOrganisasi.findMany.loading !== false + + if (isLoading) { + return ( +
+ + + Memuat struktur organisasi… + + Mengambil data pegawai dan posisi. Mohon tunggu sebentar. + + +
+ ) + } + + const data = stateOrganisasi.findMany.data || [] + if (data.length === 0) { + return ( +
+ + +
+ +
+ + Data pegawai belum tersedia + + + Belum ada data pegawai yang tercatat untuk PPID. + + + + +
+
+
+ ) + } + + // 🧩 buat struktur organisasi + const posisiMap = new Map() + const aktifPegawai = data.filter((p: any) => p.isActive) + + for (const pegawai of aktifPegawai) { + const posisiId = pegawai.posisi.id + if (!posisiMap.has(posisiId)) { + posisiMap.set(posisiId, { + ...pegawai.posisi, + pegawaiList: [], + children: [], + }) + } + posisiMap.get(posisiId)!.pegawaiList.push(pegawai) + } + + const root: any[] = [] + posisiMap.forEach((posisi) => { + if (posisi.parentId) { + const parent = posisiMap.get(posisi.parentId) + if (parent) parent.children.push(posisi) + else root.push(posisi) + } else root.push(posisi) + }) + + const toOrgChartFormat = (node: any): any => { + const pegawai = node.pegawaiList?.[0] + return { + expanded: true, + data: { + id: pegawai?.id, + name: pegawai?.namaLengkap || 'Belum Ditugaskan', + title: node.nama || 'Tanpa Jabatan', + image: pegawai?.image?.link || '/img/default.png', + }, + children: node.children?.map(toOrgChartFormat) || [], + } + } + + let chartData = root.map(toOrgChartFormat) + + // πŸ” filter by search + if (searchQuery) { + const filterNodes = (nodes: any[]): any[] => + nodes + .map((n) => ({ + ...n, + children: filterNodes(n.children || []), + })) + .filter( + (n) => + n.data.name.toLowerCase().includes(searchQuery.toLowerCase()) || + n.data.title.toLowerCase().includes(searchQuery.toLowerCase()) || + n.children.length > 0 + ) + chartData = filterNodes(chartData) + } + + // 🎬 fullscreen & zoom control + const toggleFullscreen = () => { + if (!document.fullscreenElement) { + chartContainerRef.current?.requestFullscreen() + setFullscreen(true) + } else { + document.exitFullscreen() + setFullscreen(false) + } + } + + const handleZoomIn = () => setScale((s) => Math.min(s + 0.1, 2)) + const handleZoomOut = () => setScale((s) => Math.max(s - 0.1, 0.5)) + const resetZoom = () => setScale(1) + + return ( + + {/* πŸ” Controls */} + + + } + onChange={(e) => debouncedSearch(e.target.value)} + styles={{ + input: { + minWidth: 250, + }, + }} + /> + + + + + + {Math.round(scale * 100)}% + + + + + + + + + + + + {/* 🧩 Chart Container */} +
+ + } + className="p-organizationchart p-organizationchart-horizontal" + /> + +
+
+ ) +} + +function NodeCard({ node, router }: any) { + const imageSrc = node?.data?.image || '/img/default.png' + const name = node?.data?.name || 'Tanpa Nama' + const title = node?.data?.title || 'Tanpa Jabatan' + const hasId = Boolean(node?.data?.id) + + return ( + + {(styles) => ( + { + if (hasId) { + e.currentTarget.style.transform = 'translateY(-4px)' + e.currentTarget.style.boxShadow = '0 8px 24px rgba(28, 110, 164, 0.25)' + } + }} + onMouseLeave={(e) => { + if (hasId) { + e.currentTarget.style.transform = 'translateY(0)' + e.currentTarget.style.boxShadow = '' + } + }} + > + + {/* Photo */} + + + + + {/* Name */} + + {name} + + + {/* Title/Position */} + + {title} + + + {/* Detail Button */} + {hasId && ( + + )} + + + )} + + ) +} diff --git a/src/app/darmasaba/(pages)/desa/profile/struktur-perangkat-desa/struktur.css b/src/app/darmasaba/(pages)/desa/profile/struktur-perangkat-desa/struktur.css new file mode 100644 index 00000000..0f9f4fdb --- /dev/null +++ b/src/app/darmasaba/(pages)/desa/profile/struktur-perangkat-desa/struktur.css @@ -0,0 +1,68 @@ +/* ============================================ + STRUKTUR ORGANISASI PPID - STYLING + ============================================ */ + +/* Tabel chart selalu center */ +.p-organizationchart-table { + margin: 0 auto !important; + } + + /* Jarak vertikal antar level - lebih lega */ + .p-organizationchart-line-down { + height: 32px !important; + } + + /* Padding di dalam node - lebih rapi */ + .p-organizationchart-node-content { + padding: 0 !important; + background: transparent !important; + border: none !important; + } + + /* Garis connector antar node - lebih tebal dan jelas */ + .p-organizationchart-line-down, + .p-organizationchart-line-left, + .p-organizationchart-line-right, + .p-organizationchart-line-top { + border-color: rgba(28, 110, 164, 0.4) !important; + border-width: 2px !important; + } + + /* Garis horizontal */ + .p-organizationchart-line-left, + .p-organizationchart-line-right { + border-top-width: 2px !important; + } + + /* Jarak horizontal antar node - lebih proporsional */ + .p-organizationchart-table > tbody > tr > td { + padding: 0 24px !important; + vertical-align: top !important; + } + + /* Node container spacing */ + .p-organizationchart-node { + padding: 8px !important; + } + + /* Responsive adjustments */ + @media (max-width: 768px) { + .p-organizationchart-table > tbody > tr > td { + padding: 0 12px !important; + } + + .p-organizationchart-line-down { + height: 24px !important; + } + } + + /* Smooth transitions untuk zoom */ + .p-organizationchart { + transition: transform 0.2s ease; + } + + /* Fullscreen mode adjustments */ + .p-organizationchart-table:fullscreen { + background: rgba(230, 240, 255, 0.98); + padding: 40px; + } \ No newline at end of file diff --git a/src/app/darmasaba/(pages)/musik/musik-desa/page.tsx b/src/app/darmasaba/(pages)/musik/musik-desa/page.tsx new file mode 100644 index 00000000..7c8ee6e8 --- /dev/null +++ b/src/app/darmasaba/(pages)/musik/musik-desa/page.tsx @@ -0,0 +1,248 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +'use client' +import { ActionIcon, Avatar, Badge, Box, Card, Flex, Grid, Group, Paper, Slider, Stack, Text, TextInput } from '@mantine/core'; +import { IconArrowsShuffle, IconPlayerPauseFilled, IconPlayerPlayFilled, IconPlayerSkipBackFilled, IconPlayerSkipForwardFilled, IconRepeat, IconRepeatOff, IconSearch, IconVolume, IconVolumeOff, IconX } from '@tabler/icons-react'; +import { useEffect, useState } from 'react'; +import BackButton from '../../desa/layanan/_com/BackButto'; + +const MusicPlayer = () => { + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(245); + const [volume, setVolume] = useState(70); + const [isMuted, setIsMuted] = useState(false); + const [isRepeat, setIsRepeat] = useState(false); + const [isShuffle, setIsShuffle] = useState(false); + + const songs = [ + { id: 1, title: 'Midnight Dreams', artist: 'The Wanderers', duration: '4:05', cover: 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop' }, + { id: 2, title: 'Summer Breeze', artist: 'Coastal Vibes', duration: '3:42', cover: 'https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=400&h=400&fit=crop' }, + { id: 3, title: 'City Lights', artist: 'Urban Echo', duration: '4:18', cover: 'https://images.unsplash.com/photo-1514320291840-2e0a9bf2a9ae?w=400&h=400&fit=crop' }, + { id: 4, title: 'Ocean Waves', artist: 'Serenity Sound', duration: '5:20', cover: 'https://images.unsplash.com/photo-1459749411175-04bf5292ceea?w=400&h=400&fit=crop' }, + { id: 5, title: 'Neon Nights', artist: 'Electric Dreams', duration: '3:55', cover: 'https://images.unsplash.com/photo-1487180144351-b8472da7d491?w=400&h=400&fit=crop' }, + { id: 6, title: 'Mountain High', artist: 'Peak Performers', duration: '4:32', cover: 'https://images.unsplash.com/photo-1511671782779-c97d3d27a1d4?w=400&h=400&fit=crop' } + ]; + + const [currentSong, setCurrentSong] = useState(songs[0]); + + useEffect(() => { + let interval: any; + if (isPlaying) { + interval = setInterval(() => { + setCurrentTime(prev => { + if (prev >= duration) { + setIsPlaying(false); + return 0; + } + return prev + 1; + }); + }, 1000); + } + return () => clearInterval(interval); + }, [isPlaying, duration]); + + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + const playSong = (song: any) => { + setCurrentSong(song); + setCurrentTime(0); + setIsPlaying(true); + const durationInSeconds = parseInt(song.duration.split(':')[0]) * 60 + parseInt(song.duration.split(':')[1]); + setDuration(durationInSeconds); + }; + + const toggleMute = () => { + setIsMuted(!isMuted); + }; + + return ( + + + + + +
+ Selamat Datang Kembali + Temukan musik favorit Anda hari ini +
+ + } + radius="xl" + w={280} + styles={{ input: { backgroundColor: '#fff' } }} + /> + +
+ +
+ Sedang Diputar + + + + +
+ {currentSong.title} + {currentSong.artist} +
+ + {formatTime(currentTime)} + + {formatTime(duration)} + +
+
+
+
+ +
+ Daftar Putar + + {songs.map(song => ( + + playSong(song)} + > + + + + {song.title} + {song.artist} + {song.duration} + + {currentSong.id === song.id && isPlaying && ( + Memutar + )} + + + + ))} + +
+
+ +
+ +
+ + + + + +
+ {currentSong.title} + {currentSong.artist} +
+
+ + + + setIsShuffle(!isShuffle)} + radius="xl" + > + {isShuffle ? : } + + + + + setIsPlaying(!isPlaying)} + > + {isPlaying ? : } + + + + + setIsRepeat(!isRepeat)} + radius="xl" + > + {isRepeat ? : } + + + + {formatTime(currentTime)} + + {formatTime(duration)} + + + + + + {isMuted || volume === 0 ? : } + + { + setVolume(val); + if (val > 0) setIsMuted(false); + }} + color="#0B4F78" + size="xs" + w={100} + /> + {isMuted ? 0 : volume}% + +
+
+
+ ); +}; + +export default MusicPlayer; \ No newline at end of file diff --git a/src/app/darmasaba/_com/NewsReader.tsx b/src/app/darmasaba/_com/NewsReader.tsx new file mode 100644 index 00000000..3973a3d8 --- /dev/null +++ b/src/app/darmasaba/_com/NewsReader.tsx @@ -0,0 +1,96 @@ +'use client'; +import { Button } from '@mantine/core'; +import { useEffect, useRef, useState } from 'react'; + +const NewsReader = () => { + const [isSpeaking, setIsSpeaking] = useState(false); + const [isAllowed, setIsAllowed] = useState(false); + const utteranceRef = useRef(null); + + // Fungsi untuk membaca teks + const speakText = () => { + if (typeof window === 'undefined' || !window.speechSynthesis) { + console.warn('Browser tidak mendukung SpeechSynthesis.'); + return; + } + + const contentElement = document.getElementById('news-content'); + const rawText = contentElement?.innerText || ''; + if (!rawText.trim()) return; + + // Hentikan semua suara sebelumnya + window.speechSynthesis.cancel(); + + const utterance = new SpeechSynthesisUtterance(rawText); + utterance.lang = 'id-ID'; + utterance.rate = 1; + utterance.pitch = 1; + + utterance.onstart = () => setIsSpeaking(true); + utterance.onend = () => setIsSpeaking(false); + + utteranceRef.current = utterance; + + try { + window.speechSynthesis.speak(utterance); + } catch (err) { + console.warn('Autoplay gagal karena kebijakan browser:', err); + } + }; + + // Auto play jika sudah pernah diizinkan + useEffect(() => { + const hasPermission = localStorage.getItem('ttsAllowed') === 'true'; + setIsAllowed(hasPermission); + + if (hasPermission) { + const trySpeak = setInterval(() => { + const contentElement = document.getElementById('news-content'); + if (contentElement && contentElement.innerText.trim()) { + speakText(); + clearInterval(trySpeak); + } + }, 1000); + return () => clearInterval(trySpeak); + } + }, []); + + // Hentikan suara saat user keluar halaman / komponen unmount + useEffect(() => { + return () => { + if (typeof window !== 'undefined' && window.speechSynthesis) { + window.speechSynthesis.cancel(); + setIsSpeaking(false); + } + }; + }, []); + + // Handle tombol manual + const handleToggle = () => { + if (isSpeaking) { + window.speechSynthesis.cancel(); + setIsSpeaking(false); + } else { + if (!isAllowed) { + localStorage.setItem('ttsAllowed', 'true'); + setIsAllowed(true); + } + speakText(); + } + }; + + return ( + + ); +}; + +export default NewsReader; diff --git a/src/app/darmasaba/_com/RunningText.tsx b/src/app/darmasaba/_com/RunningText.tsx new file mode 100644 index 00000000..c670818e --- /dev/null +++ b/src/app/darmasaba/_com/RunningText.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { Box } from "@mantine/core"; +import { IconBell } from "@tabler/icons-react"; +import { useMemo, useState, useEffect } from "react"; + +interface RunningTextProps { + news?: string[]; + speed?: number; // dalam detik (jika mau manual) + autoSpeed?: boolean; // otomatis sesuaikan speed dengan panjang text + bgColor?: string; + textColor?: string; + maxLength?: number; // max karakter per item +} + +// Utility function untuk strip HTML (works on both server and client) +function stripHtmlTags(html: string): string { + const text = html + .replace(/]*>.*?<\/style>/gi, '') + .replace(/]*>.*?<\/script>/gi, '') + .replace(/<[^>]+>/g, '') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/’/gi, "'") + .replace(/—/gi, 'β€”') + .replace(/–/gi, '–') + .replace(/\s+/g, ' ') + .trim(); + + return text; +} + +export default function RunningText({ + news = [ + "Selamat datang di Portal Desa Darmasaba", + "Jam operasional kantor: Senin - Jumat 08:00 - 17:00", + ], + speed = 20, + autoSpeed = true, + bgColor = "#1e5a7e", + textColor = "white", + maxLength = 200 // default max 200 karakter per item +}: RunningTextProps) { + + const [isMounted, setIsMounted] = useState(false); + + useEffect(() => { + setIsMounted(true); + }, []); + + // Process news data + const processedNews = useMemo(() => { + return news + .filter(item => item && item.trim() !== "") + .map(item => { + let text = stripHtmlTags(item); + // Limit panjang per item + if (text.length > maxLength) { + text = text.substring(0, maxLength) + "..."; + } + return text; + }) + .filter(item => item.length > 0); + }, [news, maxLength]); + + const allNews = processedNews.length > 0 + ? processedNews.join(" β€’ ") + : "Tidak ada pengumuman"; + + // Hitung speed berdasarkan mode + const calculatedSpeed = useMemo(() => { + if (!autoSpeed) { + return speed; // Gunakan speed manual + } + + // Auto speed: berdasarkan panjang text + const textLength = allNews.length; + + // Formula yang lebih natural: + // - Text pendek (< 100 char): 15 detik + // - Text sedang (100-300 char): 20-30 detik + // - Text panjang (> 300 char): 30-45 detik + let calculatedTime; + + if (textLength < 100) { + calculatedTime = 15; + } else if (textLength < 300) { + calculatedTime = 15 + ((textLength - 100) / 200) * 15; // 15-30 detik + } else { + calculatedTime = 30 + Math.min(((textLength - 300) / 500) * 15, 15); // 30-45 detik max + } + + return Math.round(calculatedTime); + }, [allNews, speed, autoSpeed]); + + // Prevent hydration mismatch + if (!isMounted) { + return ( + +
+ + + Memuat pengumuman... + +
+
+ ); + } + + return ( + +