import React, { useState, useEffect, useRef } from 'react'; // REPLACE with your public VAPID key from "web-push generate-vapid-keys" const VAPID_PUBLIC_KEY = 'BGTl7xYXEr2gY_O6gmVGYy0DTFlm6vepYUmkt8_6P9PHwOJcHsPZ5CUSEzsoCq7CszPwMyUbq0nG6xjrzJMWZOg'; function App() { const [statusMsg, setStatusMsg] = useState(''); // Timer state const [soloUsed, setSoloUsed] = useState([]); const [togetherUsed, setTogetherUsed] = useState([]); const [logs, setLogs] = useState([]); const [timerRunning, setTimerRunning] = useState(false); const [timeRemaining, setTimeRemaining] = useState(0); const [isPaused, setIsPaused] = useState(false); const [ringing, setRinging] = useState(false); // Movie/Episode selection const [chooseMovieMode, setChooseMovieMode] = useState(false); const [chooseEpisodeMode, setChooseEpisodeMode] = useState(false); const [chosenPoints, setChosenPoints] = useState([]); // Add Points flow const [showAddPointsOverlay, setShowAddPointsOverlay] = useState(false); const [addPointsStep, setAddPointsStep] = useState(1); const [pointsPassword, setPointsPassword] = useState(''); const [pointsCategory, setPointsCategory] = useState(''); const [pointsAmount, setPointsAmount] = useState(1); const [adminMode, setAdminMode] = useState(false); // Local "alarm" audio (only if page is foreground) const alarmRef = useRef(null); useEffect(() => { // Poll server state fetchState(); const id = setInterval(fetchState, 1000); return () => clearInterval(id); }, []); useEffect(() => { // Register SW + push registerServiceWorkerAndSubscribe(); }, []); useEffect(() => { if (ringing) { playLocalAlarm(); } else { stopLocalAlarm(); } }, [ringing]); async function fetchState() { try { const res = await fetch('/state'); // same origin => server on port 80 const data = await res.json(); setSoloUsed(data.soloUsed || []); setTogetherUsed(data.togetherUsed || []); setLogs(data.logs || []); setTimerRunning(data.timerRunning); setTimeRemaining(data.timeRemaining); setIsPaused(data.isPaused); setRinging(data.ringing); } catch (err) { console.error('Fetch state error:', err); setStatusMsg('Error: cannot reach server'); } } function playLocalAlarm() { if (!alarmRef.current) return; alarmRef.current.loop = true; alarmRef.current.currentTime = 0; alarmRef.current.play().catch(err => { console.log('Audio play blocked until user interaction:', err); }); } function stopLocalAlarm() { if (!alarmRef.current) return; alarmRef.current.pause(); alarmRef.current.currentTime = 0; alarmRef.current.loop = false; } async function handleBubbleClick(category, index) { try { const res = await fetch('/use-bubble', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ category, index }), }); const data = await res.json(); if (data.error) { setStatusMsg(`Error: ${data.error}`); } else { setStatusMsg(`Using 1 ${category} => 15-min started`); } } catch (err) { console.error(err); setStatusMsg('Error using bubble'); } } async function handlePauseResume() { try { const res = await fetch('/pause-resume', { method: 'POST' }); const data = await res.json(); if (data.error) { setStatusMsg(`Error: ${data.error}`); } else { setStatusMsg(`Timer paused => ${data.isPaused}`); } } catch (err) { console.error(err); setStatusMsg('Error pause/resume'); } } async function handleFinishUp() { try { const res = await fetch('/finish-up', { method: 'POST' }); const data = await res.json(); if (data.error) { setStatusMsg(`Error: ${data.error}`); } else { setStatusMsg('2-min finish-up started'); } } catch (err) { console.error(err); setStatusMsg('Error finishing up'); } } async function handleCancelFinishUp() { try { const res = await fetch('/cancel-finish-up', { method: 'POST' }); const data = await res.json(); if (data.error) { setStatusMsg(`Error: ${data.error}`); } else { setStatusMsg('Finish-up canceled'); } } catch (err) { console.error(err); setStatusMsg('Error cancel finish-up'); } } async function handleIgnoreRing() { try { const res = await fetch('/ignore-ring', { method: 'POST' }); const data = await res.json(); if (data.error) { setStatusMsg(`Error: ${data.error}`); } else { setStatusMsg('Ring ignored'); } } catch (err) { console.error(err); setStatusMsg('Error ignoring ring'); } } // Movie/Episode function startChooseMovie() { setChooseMovieMode(true); setChooseEpisodeMode(false); setChosenPoints([]); setStatusMsg('Select exactly 4 points for a Movie'); } function startChooseEpisode() { setChooseEpisodeMode(true); setChooseMovieMode(false); setChosenPoints([]); setStatusMsg('Select exactly 2 points for an Episode'); } function toggleChosen(category, index) { const found = chosenPoints.find(c => c.category===category && c.index===index); if (found) { setChosenPoints(chosenPoints.filter(c => c !== found)); } else { setChosenPoints([...chosenPoints, { category, index }]); } } async function submitMovie() { if (chosenPoints.length!==4) { setStatusMsg('Must pick exactly 4 points for a movie'); return; } try { const res=await fetch('/use-movie',{ method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ chosenPoints }), }); const data=await res.json(); if(data.error){ setStatusMsg(`Error: ${data.error}`); } else { setStatusMsg('Movie => used chosen 4 points'); } } catch(err){ console.error(err); setStatusMsg('Error submitting movie'); } setChooseMovieMode(false); setChosenPoints([]); } async function submitEpisode() { if(chosenPoints.length!==2){ setStatusMsg('Must pick exactly 2 points for an episode'); return; } try { const res=await fetch('/use-episode',{ method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ chosenPoints }) }); const data=await res.json(); if(data.error){ setStatusMsg(`Error: ${data.error}`); } else { setStatusMsg('Episode => used chosen 2 points'); } } catch(err){ console.error(err); setStatusMsg('Error submitting episode'); } setChooseEpisodeMode(false); setChosenPoints([]); } // Add Points flow function openAddPointsFlow() { setShowAddPointsOverlay(true); setAddPointsStep(1); setPointsPassword(''); setPointsCategory(''); setPointsAmount(1); } function closeAddPointsFlow() { setShowAddPointsOverlay(false); } // Step 1 => check password async function checkPointsPassword() { if(!pointsPassword){ setStatusMsg('Please enter a password'); return; } try { const res=await fetch('/check-password',{ method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ password:pointsPassword }) }); const data=await res.json(); if(data.error){ setStatusMsg(`Error: ${data.error}`); } else { setStatusMsg('Password OK, pick category next'); setAddPointsStep(2); } } catch(err){ console.error(err); setStatusMsg('Error checking password'); } } // Step 2 => pick category function pickAddPointsCategory(cat){ setPointsCategory(cat); setAddPointsStep(3); } // Step 3 => pick amount => /add-points async function pickAddPointsAmount(amt){ setPointsAmount(amt); try { const res=await fetch('/add-points',{ method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ password: pointsPassword, category: pointsCategory, amount: amt }) }); const data=await res.json(); if(data.error){ setStatusMsg(`Error: ${data.error}`); } else { setStatusMsg(`+${amt} points added to ${pointsCategory}`); } } catch(err){ console.error(err); setStatusMsg('Error adding points'); } setShowAddPointsOverlay(false); } // Admin async function handleAdminLogin(){ const pw=prompt('Enter admin password:'); if(!pw) return; try { const res=await fetch('/admin/login',{ method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ password:pw }) }); const data=await res.json(); if(data.error){ setStatusMsg(`Error: ${data.error}`); } else { setAdminMode(true); setStatusMsg('Admin mode enabled'); } } catch(err){ console.error(err); setStatusMsg('Error admin login'); } } function handleAdminLogout(){ setAdminMode(false); setStatusMsg('Admin mode disabled'); } async function adminRequest(path){ try { const res=await fetch(`/admin/${path}`,{ method:'POST'}); const data=await res.json(); if(data.error){ setStatusMsg(`Error: ${data.error}`); } else { setStatusMsg(`Admin => ${path} success`); } } catch(err){ console.error(err); setStatusMsg(`Error admin ${path}`); } } // Push subscription async function registerServiceWorkerAndSubscribe(){ if(!('serviceWorker' in navigator)){ console.log('No service worker support in this browser'); return; } try { const reg = await navigator.serviceWorker.register('/service-worker.js'); console.log('Service Worker registered:', reg); // Request notifications const permission = await Notification.requestPermission(); if(permission!=='granted'){ console.log('User denied notifications'); return; } // Subscribe const subscription = await reg.pushManager.subscribe({ userVisibleOnly:true, applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY) }); console.log('Push subscription:', subscription); // Send to server await fetch('/subscribe',{ method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ subscription }) }); console.log('Subscribed to push!'); } catch(err){ console.error('SW or push subscription failed:', err); } } function urlBase64ToUint8Array(base64String){ const padding = '='.repeat((4 - (base64String.length % 4)) % 4); const base64 = (base64String+padding).replace(/\-/g,'+').replace(/_/g,'/'); const rawData=window.atob(base64); const outputArray=new Uint8Array(rawData.length); for(let i=0;i{ const isChosen = chosenPoints.find(c=> c.category===category && c.index===idx); const bubbleColor = val? '#8B0000':'#4caf50'; // used => dark red, unused => green let outline = (idx<5)? '2px solid #5f7b99':'2px solid #7f5f99'; // base vs appended if(isChosen) outline='3px solid yellow'; return (
{ if(val){ setStatusMsg('That bubble is already used'); return; } if(chooseMovieMode||chooseEpisodeMode){ toggleChosen(category, idx); } else { handleBubbleClick(category, idx); } }} /> ); }); } // Are we in the 2-min finish-up const inFinishUp = timerRunning && !ringing && timeRemaining>0 && timeRemaining<=120000; function formatTime(ms){ if(ms<=0) return '00:00'; const totalSec=Math.floor(ms/1000); const mm=Math.floor(totalSec/60); const ss=totalSec%60; return `${String(mm).padStart(2,'0')}:${String(ss).padStart(2,'0')}`; } function formatLogDate(iso){ const d=new Date(iso); const dayOfWeek=d.toLocaleDateString('en-US',{ weekday:'long'}); const day=d.getDate(); const ord=getOrdinal(day); const timeStr=d.toLocaleTimeString('en-US',{ hour12:true,hour:'numeric',minute:'2-digit'}); return `${dayOfWeek} ${day}${ord}, ${timeStr}`; } function getOrdinal(n){ const s=['th','st','nd','rd']; const v=n%100; return s[(v-20)%10]||s[v]||s[0]; } return (

Mylin's Points

{statusMsg && (
{statusMsg}
)}
); } const styles = { container: { maxWidth:800, margin:'20px auto', fontFamily:'sans-serif', backgroundColor:'#222', color:'#fff', padding:20, borderRadius:6 }, statusBox: { backgroundColor:'#333', padding:10, marginBottom:10, borderRadius:4 }, bubbleRow: { display:'flex', flexWrap:'wrap', justifyContent:'center', marginTop:10, marginBottom:10 }, button: { backgroundColor:'#4caf50', border:'none', color:'#fff', padding:'8px 12px', margin:5, borderRadius:4, cursor:'pointer' }, overlay: { position:'fixed', top:0, left:0, right:0, bottom:0, backgroundColor:'rgba(0,0,0,0.6)', display:'flex', justifyContent:'center', alignItems:'center', zIndex:9999 }, addPointsDialog: { backgroundColor:'#333', padding:20, borderRadius:8, width:'80%', maxWidth:400, textAlign:'center' }, input: { width:'80%', padding:8, margin:'10px 0', borderRadius:4, border:'1px solid #999', fontSize:16 }, buttonRow: { display:'flex', justifyContent:'center', flexWrap:'wrap', marginTop:10, marginBottom:10 }, adminPanel: { marginTop:30, backgroundColor:'#333', padding:20, borderRadius:4 }, adminButton: { backgroundColor:'#9C27B0', border:'none', color:'#fff', padding:'6px 10px', margin:5, borderRadius:4, cursor:'pointer' } }; export default App;