PointTracker/src/App.js
2025-02-20 16:05:44 -05:00

702 lines
21 KiB
JavaScript

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<rawData.length;i++){
outputArray[i]=rawData.charCodeAt(i);
}
return outputArray;
}
// Render Bubbles
function renderBubbles(usedArr, category){
return usedArr.map((val,idx)=>{
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 (
<div
key={idx}
style={{
width:30, height:30, borderRadius:'50%',
backgroundColor: bubbleColor,
margin:5, cursor:'pointer',
border: outline, boxSizing:'border-box'
}}
onClick={()=>{
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 (
<div style={styles.container}>
<h1>Mylin's Points</h1>
{statusMsg && (
<div style={styles.statusBox}>{statusMsg}</div>
)}
<audio ref={alarmRef} src="/alarm.mp3" />
<section>
<h2>Solo Points</h2>
<div style={styles.bubbleRow}>
{renderBubbles(soloUsed, 'solo')}
</div>
</section>
<section>
<h2>Together Points</h2>
<div style={styles.bubbleRow}>
{renderBubbles(togetherUsed, 'together')}
</div>
</section>
<button style={styles.button} onClick={openAddPointsFlow}>
Add Points
</button>
<section style={{marginTop:20}}>
{chooseMovieMode?(
<div>
<p>Select exactly 4 points => Movie</p>
<button style={styles.button} onClick={submitMovie}>Submit Movie</button>
<button style={styles.button} onClick={()=>{
setChooseMovieMode(false);
setChosenPoints([]);
}}>Cancel</button>
</div>
):(
<button style={styles.button} onClick={startChooseMovie}>
Movie (4)
</button>
)}
{chooseEpisodeMode?(
<div>
<p>Select exactly 2 points => Episode</p>
<button style={styles.button} onClick={submitEpisode}>Submit Episode</button>
<button style={styles.button} onClick={()=>{
setChooseEpisodeMode(false);
setChosenPoints([]);
}}>Cancel</button>
</div>
):(
<button style={styles.button} onClick={startChooseEpisode}>
Episode (2)
</button>
)}
</section>
<section style={{ marginTop:30 }}>
{(timerRunning || timeRemaining>0) && (
<div>
<h3>Timer: {formatTime(timeRemaining)} {isPaused && '(Paused)'}</h3>
<button style={styles.button} onClick={handlePauseResume}>
{isPaused? 'Resume':'Pause'}
</button>
</div>
)}
{ringing && (
<div style={{marginTop:10}}>
<p style={{color:'yellow'}}>Timer ended! Alarm is ringing!</p>
<button style={styles.button} onClick={handleFinishUp}>
Finish Up (2 min)
</button>
<button style={styles.button} onClick={handleIgnoreRing}>
Ignore
</button>
</div>
)}
{inFinishUp && (
<div style={{marginTop:10}}>
<button style={styles.button} onClick={handleCancelFinishUp}>
Cancel Finish Up
</button>
</div>
)}
</section>
<section style={{marginTop:20}}>
<h3>Recent Logs (last 5)</h3>
{logs.slice(-5).reverse().map((entry,i)=>(
<p key={i}>
[{entry.type.toUpperCase()}]
{entry.index!=null && ` #${entry.index+1}`}
{' '}at {formatLogDate(entry.usedAt)}
</p>
))}
</section>
{showAddPointsOverlay && (
<div style={styles.overlay}>
<div style={styles.addPointsDialog}>
{addPointsStep===1 && (
<>
<h3>Step 1: Enter Password</h3>
<input
style={styles.input}
type="password"
value={pointsPassword}
onChange={e=> setPointsPassword(e.target.value)}
/>
<button style={styles.button} onClick={checkPointsPassword}>
Check Password
</button>
<button style={{...styles.button, backgroundColor:'#999'}} onClick={closeAddPointsFlow}>
Cancel
</button>
</>
)}
{addPointsStep===2 && (
<>
<h3>Step 2: Choose Category</h3>
<div style={styles.buttonRow}>
<button style={styles.button} onClick={()=> pickAddPointsCategory('solo')}>Solo</button>
<button style={styles.button} onClick={()=> pickAddPointsCategory('together')}>Together</button>
</div>
<button style={{...styles.button, backgroundColor:'#999'}} onClick={closeAddPointsFlow}>
Cancel
</button>
</>
)}
{addPointsStep===3 && (
<>
<h3>Step 3: How many points?</h3>
<div style={styles.buttonRow}>
{[1,2,3,4].map(n=>(
<button
key={n}
style={styles.button}
onClick={()=> pickAddPointsAmount(n)}
>
+{n}
</button>
))}
</div>
<button style={{...styles.button, backgroundColor:'#999'}} onClick={closeAddPointsFlow}>
Cancel
</button>
</>
)}
</div>
</div>
)}
{adminMode?(
<div style={styles.adminPanel}>
<h2>Admin Panel</h2>
<button style={styles.adminButton} onClick={()=>adminRequest('clear-logs')}>Clear Logs</button>
<button style={styles.adminButton} onClick={()=>adminRequest('reset-usage')}>Reset Usage</button>
<button style={styles.adminButton} onClick={()=>adminRequest('remove-all-earned')}>Remove All Earned</button>
<button style={styles.adminButton} onClick={()=>adminRequest('add-earned')}>+1 (SoloUsed)</button>
<button style={styles.adminButton} onClick={()=>adminRequest('remove-earned')}>-1 (SoloUsed)</button>
<button style={styles.adminButton} onClick={()=>adminRequest('clear-timer')}>Clear Timer</button>
<button style={styles.adminButton} onClick={()=>adminRequest('expire-timer')}>Expire Timer (1s)</button>
<button style={styles.adminButton} onClick={handleAdminLogout}>Logout Admin</button>
</div>
):(
<div style={{marginTop:30}}>
<button style={styles.adminButton} onClick={handleAdminLogin}>
Admin Login
</button>
</div>
)}
</div>
);
}
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;