Add Menu Musik

Add News Reader for Difable
Add Running text news / announcement
This commit is contained in:
2025-11-04 15:08:48 +08:00
parent d128313e71
commit fb57698dc9
21 changed files with 1328 additions and 48 deletions

View File

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

View File

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

View File

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