diff options
| author | Pinapelz <yukais@pinapelz.com> | 2025-10-03 02:25:55 -0700 |
|---|---|---|
| committer | Pinapelz <yukais@pinapelz.com> | 2025-10-03 02:27:57 -0700 |
| commit | 3d1d33c2aac15e07c3b840a1fb9428e3feda8330 (patch) | |
| tree | 84a16e5b620acde37cc9cf1eba811d1f53426704 /site/src | |
| parent | 19fc6861e567ac8ca56476152edf11d1abb15661 (diff) | |
initial firebase fcm implementation
Diffstat (limited to 'site/src')
| -rw-r--r-- | site/src/components/NotificationButton.tsx | 196 | ||||
| -rw-r--r-- | site/src/firebase.ts | 81 | ||||
| -rw-r--r-- | site/src/pages/Homepage.tsx | 125 |
3 files changed, 355 insertions, 47 deletions
diff --git a/site/src/components/NotificationButton.tsx b/site/src/components/NotificationButton.tsx new file mode 100644 index 0000000..8f4fb61 --- /dev/null +++ b/site/src/components/NotificationButton.tsx @@ -0,0 +1,196 @@ +import { useState, useEffect } from "react"; +import { messaging, initializeForegroundNotifications } from "../firebase.ts"; +import { getToken, deleteToken } from "firebase/messaging"; + +const VAPID_KEY = + "BK7tpLF5Loy8Ew8bKxhTi-vOEJdxJSnu-jPyagWecLdD_SrEAt_OQS7nu0Xu3hR7AQpn0cOmgcdeeQd5zq5-Gyo"; + +interface NotificationButtonProps { + className?: string; + isMoe?: boolean; +} + +export default function NotificationButton({ className = "", isMoe = false }: NotificationButtonProps) { + const [permission, setPermission] = useState<NotificationPermission>("default"); + const [isRegistered, setIsRegistered] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + + useEffect(() => { + // Check initial permission status + setPermission(Notification.permission); + + // Check if service worker is registered + const checkRegistration = async () => { + if ('serviceWorker' in navigator) { + const registration = await navigator.serviceWorker.getRegistration('/firebase-messaging-sw.js'); + setIsRegistered(!!registration); + + // Initialize foreground notifications if already registered + if (registration && Notification.permission === "granted") { + initializeForegroundNotifications(); + } + } + }; + + checkRegistration(); + }, []); + + const handleEnableNotifications = async () => { + setLoading(true); + setError(null); + + try { + const permissionResult = await Notification.requestPermission(); + setPermission(permissionResult); + + if (permissionResult === "granted") { + // Register service worker + const registration = await navigator.serviceWorker.register('/firebase-messaging-sw.js'); + console.log("Service Worker registered:", registration); + const token = await getToken(messaging, { vapidKey: VAPID_KEY }); + console.log("FCM Token:", token); + // Store token locally (you might want to send this to your server) + localStorage.setItem('fcm_token', token); + + // Initialize foreground notification handler + initializeForegroundNotifications(); + + setIsRegistered(true); + } else { + setError("Notification permission was denied"); + } + } catch (err) { + console.error("Error enabling notifications:", err); + setError("Failed to enable notifications. Please try again."); + } finally { + setLoading(false); + } + }; + + const handleDisableNotifications = async () => { + setLoading(true); + setError(null); + + try { + await deleteToken(messaging); + console.log("FCM token deleted"); + localStorage.removeItem('fcm_token'); + if ('serviceWorker' in navigator) { + const registration = await navigator.serviceWorker.getRegistration('/firebase-messaging-sw.js'); + if (registration) { + await registration.unregister(); + console.log("Service Worker unregistered"); + } + } + + setIsRegistered(false); + } catch (err) { + console.error("Error disabling notifications:", err); + setError("Failed to disable notifications. Please try again."); + } finally { + setLoading(false); + } + }; + + // Determine button state and action + const getButtonContent = () => { + if (loading) { + return ( + <> + <svg className="animate-spin h-5 w-5 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> + <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> + <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> + </svg> + {isRegistered ? "Disabling..." : "Enabling..."} + </> + ); + } + + if (permission === "denied") { + return ( + <> + <svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" /> + </svg> + Notifications Blocked + </> + ); + } + + if (isRegistered && permission === "granted") { + return ( + <> + <svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 24 24"> + <path d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /> + </svg> + Disable Notifications + </> + ); + } + + return ( + <> + <svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /> + </svg> + Enable Notifications + </> + ); + }; + + const handleClick = () => { + if (permission === "denied") { + // Can't re-request permission if denied + alert("Notifications are blocked. Please enable them in your browser settings."); + return; + } + + if (isRegistered && permission === "granted") { + handleDisableNotifications(); + } else { + handleEnableNotifications(); + } + }; + + // Determine button styles + const getButtonStyles = () => { + if (loading || permission === "denied") { + return isMoe + ? `bg-pink-300 cursor-not-allowed opacity-60` + : `bg-gray-600 cursor-not-allowed opacity-60`; + } + + if (isMoe) { + return isRegistered + ? `bg-pink-600 text-white hover:bg-pink-700` + : `bg-pink-500 text-white hover:bg-pink-600`; + } else { + return isRegistered + ? `bg-purple-700 text-white hover:bg-purple-800` + : `bg-purple-600 text-white hover:bg-purple-700`; + } + }; + + return ( + <div className="flex flex-col items-center gap-2"> + <button + onClick={handleClick} + disabled={loading || permission === "denied"} + className={`flex items-center justify-center px-4 py-2 rounded-lg font-semibold transition-colors ${getButtonStyles()} ${className}`} + > + {getButtonContent()} + </button> + {error && ( + <p className={`text-sm ${isMoe ? "text-pink-600" : "text-red-500"}`}> + {error} + </p> + )} + {permission === "denied" && ( + <p className={`text-xs ${isMoe ? "text-pink-600" : "text-gray-400"}`}> + To enable notifications, update your browser settings + </p> + )} + </div> + ); +} diff --git a/site/src/firebase.ts b/site/src/firebase.ts new file mode 100644 index 0000000..e908e58 --- /dev/null +++ b/site/src/firebase.ts @@ -0,0 +1,81 @@ +import { initializeApp } from "firebase/app"; +import { getMessaging, Messaging, onMessage } from "firebase/messaging"; + +const firebaseConfig = { + apiKey: "AIzaSyAkxH71PlZJxhD7vuN_Q8kn3TtNnB09_cU", + authDomain: "updates-9eab8.firebaseapp.com", + projectId: "updates-9eab8", + storageBucket: "updates-9eab8.firebasestorage.app", + messagingSenderId: "347275855103", + appId: "1:347275855103:web:fb59a7504792c2736538ca" +}; + +const app = initializeApp(firebaseConfig); + +export const messaging: Messaging = getMessaging(app); + +// Handle foreground messages +export const initializeForegroundNotifications = () => { + onMessage(messaging, (payload) => { + console.log('[firebase.ts] Message received in foreground:', payload); + + // Check if browser supports notifications + if (!("Notification" in window)) { + console.log("This browser does not support desktop notifications"); + return; + } + + // Check notification permission + if (Notification.permission === "granted") { + // Create notification + const notificationTitle = payload.notification?.title || 'New Update'; + const notificationOptions: NotificationOptions = { + body: payload.notification?.body || 'You have a new notification', + icon: payload.notification?.icon || '/android/android-launchericon-192-192.png', + badge: '/android/android-launchericon-72-72.png', + tag: payload.data?.tag || 'default-tag', + requireInteraction: payload.data?.requireInteraction === 'true', + silent: false, + data: { + url: payload.data?.url || '/', + gameId: payload.data?.gameId, + ...payload.data + } + }; + + // Add image if provided + if (payload.notification?.image) { + notificationOptions.badge = payload.notification.image; + } + + // Create and show the notification + const notification = new Notification(notificationTitle, notificationOptions); + + // Handle notification click + notification.onclick = (event) => { + event.preventDefault(); + notification.close(); + + // Navigate to the URL if provided + const url = payload.data?.url || '/'; + window.open(url, '_blank'); + }; + + // Handle notification error + notification.onerror = (event) => { + console.error('[firebase.ts] Notification error:', event); + }; + + // Auto-close notification after 10 seconds if not require interaction + if (payload.data?.requireInteraction !== 'true') { + setTimeout(() => { + notification.close(); + }, 10000); + } + } else { + console.log('[firebase.ts] Notification permission not granted'); + } + }); + + console.log('[firebase.ts] Foreground notification handler initialized'); +}; diff --git a/site/src/pages/Homepage.tsx b/site/src/pages/Homepage.tsx index 2f35280..db658ca 100644 --- a/site/src/pages/Homepage.tsx +++ b/site/src/pages/Homepage.tsx @@ -4,6 +4,7 @@ import { useParams, useSearchParams } from "react-router-dom"; import { getGameTitle } from "../utils.ts"; import TitleBar from "../components/TitleBar"; import { GameNotes } from "../components/GameNotes"; +import NotificationButton from "../components/NotificationButton"; interface ArcadeNewsAPIData { fetch_time: number; @@ -14,8 +15,9 @@ export default function Home() { const { gameId } = useParams<{ gameId?: string }>(); const [searchParams] = useSearchParams(); const isMoe = searchParams.has("moe"); - const rssFeedUrl = import.meta.env.VITE_NEWS_BASE_URL + "/" +gameId + "_news.xml"; - const newsAPIBase = import.meta.env.VITE_NEWS_BASE_URL + const rssFeedUrl = + import.meta.env.VITE_NEWS_BASE_URL + "/" + gameId + "_news.xml"; + const newsAPIBase = import.meta.env.VITE_NEWS_BASE_URL; const [newsFeedData, setNewsFeedData] = useState<ArcadeNewsAPIData | null>( null, @@ -29,9 +31,7 @@ export default function Home() { setError(false); const newsDataFileName = gameId ? `${gameId}_news.json` : "news.json"; try { - const response = await fetch( - newsAPIBase+"/" + `${newsDataFileName}`, - ); + const response = await fetch(newsAPIBase + "/" + `${newsDataFileName}`); if (!response.ok) { throw new Error(`Failed to fetch news: ${response.statusText}`); } @@ -70,21 +70,31 @@ export default function Home() { className={`${isMoe ? "bg-pink-100" : "bg-gray-950"} min-h-screen flex items-center justify-center`} > <div className="text-center px-4"> - <div className={`${isMoe ? "text-pink-500" : "text-purple-500"} text-8xl font-bold mb-4`}> + <div + className={`${isMoe ? "text-pink-500" : "text-purple-500"} text-8xl font-bold mb-4`} + > 404 </div> - <h1 className={`${isMoe ? "text-pink-900" : "text-white"} text-3xl font-bold mb-4`}> + <h1 + className={`${isMoe ? "text-pink-900" : "text-white"} text-3xl font-bold mb-4`} + > News Not Found </h1> - <p className={`${isMoe ? "text-pink-700" : "text-gray-400"} text-lg mb-8`}> + <p + className={`${isMoe ? "text-pink-700" : "text-gray-400"} text-lg mb-8`} + > {gameId ? `Unable to fetch news for ${getGameTitle(gameId)}` - : "Unable to fetch news feed" - } + : "Unable to fetch news feed"} </p> - <div className={`${isMoe ? "bg-pink-200" : "bg-gray-800"} rounded-lg p-6 max-w-md mx-auto`}> - <p className={`${isMoe ? "text-pink-800" : "text-gray-300"} mb-4`}> - The news feed you're looking for might be temporarily unavailable or doesn't exist. + <div + className={`${isMoe ? "bg-pink-200" : "bg-gray-800"} rounded-lg p-6 max-w-md mx-auto`} + > + <p + className={`${isMoe ? "text-pink-800" : "text-gray-300"} mb-4`} + > + The news feed you're looking for might be temporarily + unavailable or doesn't exist. </p> <a href="/" @@ -127,43 +137,64 @@ export default function Home() { </div> </div> ) : ( - <div - className={`${isMoe ? "bg-pink-200 text-pink-900" : "bg-gray-800 text-white"} rounded-lg p-6 text-center shadow-lg`} - > - <h1 className="text-2xl font-bold">Welcome to 573-UPDATES</h1> - <h2 - className={`text-2xl font-extrabold mb-4 tracking-widest text-center uppercase glow-neon ${ - isMoe ? "text-pink-500" : "text-[#00FF00]" - }`} + <> + <div + className={`${isMoe ? "bg-pink-200 text-pink-900" : "bg-gray-800 text-white"} rounded-lg p-6 text-center shadow-lg`} > - SECOND STYLE - </h2> - <div className="floating"> - <img - src="/liris.webp" - className="w-48 mx-auto mb-2 object-contain rounded-2xl" - /> - </div> - <p> - News and Information for various arcade games is aggregated - here! - </p> - <p className="mt-2"> - RSS feeds are available on each game's individual page - </p> - <p className="mt-2"> - Please see the{" "} - <a - href="https://github.com/pinapelz/573-updates" - className="text-blue-500 hover:underline" + <h1 className="text-2xl font-bold">Welcome to 573-UPDATES</h1> + <h2 + className={`text-2xl font-extrabold mb-4 tracking-widest text-center uppercase glow-neon ${ + isMoe ? "text-pink-500" : "text-[#00FF00]" + }`} > - GitHub - </a>{" "} - for API information - </p> - </div> + SECOND STYLE + </h2> + <div className="floating"> + <img + src="/liris.webp" + className="w-48 mx-auto mb-2 object-contain rounded-2xl" + /> + </div> + <p> + News and Information for various arcade games is aggregated + here! + </p> + <p className="mt-2"> + RSS feeds are available on each game's individual page + </p> + <p className="mt-2"> + Please see the{" "} + <a + href="https://github.com/pinapelz/573-updates" + className="text-blue-500 hover:underline" + > + GitHub + </a>{" "} + for API information + </p> + <div className="mt-6"> + <details className="rounded-lg"> + <summary + className={`cursor-pointer text-lg font-semibold ${ + isMoe + ? "text-pink-700 hover:text-pink-500" + : "text-gray-300 hover:text-white" + }`} + > + Receive Notifications + </summary> + <div className="mt-4"> + <NotificationButton isMoe={isMoe} /> + <p className="mt-2"> + Enables notifications for the main feed + </p> + </div> + </details> + </div> + </div> + </> )} - <NewsFeed newsItems={newsFeedData.news_posts}/> + <NewsFeed newsItems={newsFeedData.news_posts} /> </div> <footer className={`mt-8 text-center text-sm ${isMoe ? "text-pink-800" : "text-gray-400"}`} |
