Add Menu Musik
Add News Reader for Difable Add Running text news / announcement
This commit is contained in:
96
src/app/darmasaba/_com/NewsReader.tsx
Normal file
96
src/app/darmasaba/_com/NewsReader.tsx
Normal 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;
|
||||
185
src/app/darmasaba/_com/RunningText.tsx
Normal file
185
src/app/darmasaba/_com/RunningText.tsx
Normal 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(/ /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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user