Add app to git
This commit is contained in:
540
components/arcade.tsx
Normal file
540
components/arcade.tsx
Normal file
@@ -0,0 +1,540 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardHeader, CardTitle, CardContent, CardFooter, CardDescription } from "@/components/ui/card";
|
||||
import { Plus, Minus, Trash2, ArrowLeft } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
|
||||
interface ParticipantSetup {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Prize {
|
||||
id: string;
|
||||
name: string;
|
||||
points: number;
|
||||
}
|
||||
|
||||
interface Participant {
|
||||
id: number;
|
||||
name: string;
|
||||
pointsAvailable: number;
|
||||
prizes: Prize[];
|
||||
}
|
||||
|
||||
interface StoredData {
|
||||
stage: Stage;
|
||||
numParticipants: number;
|
||||
participantSetup: ParticipantSetup[];
|
||||
pointInputs: number[];
|
||||
participants: Participant[];
|
||||
totalPoints: number;
|
||||
}
|
||||
|
||||
type Stage = 'setup' | 'points' | 'tracking';
|
||||
|
||||
const STORAGE_KEY = 'arcadeTrackerData';
|
||||
|
||||
const ArcadeTracker: React.FC = () => {
|
||||
const [stage, setStage] = useState<Stage>('setup');
|
||||
const [numParticipants, setNumParticipants] = useState<number>(2);
|
||||
const [pointInputs, setPointInputs] = useState<number[]>([]);
|
||||
const [participants, setParticipants] = useState<Participant[]>([]);
|
||||
const [totalPoints, setTotalPoints] = useState<number>(0);
|
||||
const [newPrizeName, setNewPrizeName] = useState<string>('');
|
||||
const [newPrizePoints, setNewPrizePoints] = useState<string>('');
|
||||
const [participantSetup, setParticipantSetup] = useState<ParticipantSetup[]>([]);
|
||||
const [showResetDialog, setShowResetDialog] = useState(false);
|
||||
const [editingParticipantId, setEditingParticipantId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize participant setup when component mounts
|
||||
if (participantSetup.length === 0) {
|
||||
setParticipantSetup([
|
||||
{ id: 1, name: 'Player 1' },
|
||||
{ id: 2, name: 'Player 2' }
|
||||
]);
|
||||
}
|
||||
}, [participantSetup.length]); // Add the dependency
|
||||
|
||||
useEffect(() => {
|
||||
const savedData = localStorage.getItem(STORAGE_KEY);
|
||||
if (savedData) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedData) as StoredData;
|
||||
setStage(parsed.stage);
|
||||
setNumParticipants(parsed.numParticipants);
|
||||
setParticipantSetup(parsed.participantSetup || []);
|
||||
setPointInputs(parsed.pointInputs);
|
||||
setParticipants(parsed.participants);
|
||||
setTotalPoints(parsed.totalPoints);
|
||||
} catch (error) {
|
||||
console.error('Error parsing saved data:', error);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const dataToSave: StoredData = {
|
||||
stage,
|
||||
numParticipants,
|
||||
participantSetup,
|
||||
pointInputs,
|
||||
participants,
|
||||
totalPoints,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(dataToSave));
|
||||
}, [stage, numParticipants, pointInputs, participants, totalPoints, participantSetup]); // Add participantSetup
|
||||
|
||||
const handleParticipantNameChange = (id: number, name: string) => {
|
||||
setParticipantSetup(prev =>
|
||||
prev.map(p => p.id === id ? { ...p, name } : p)
|
||||
);
|
||||
};
|
||||
|
||||
const handleNumParticipantsChange = (change: number) => {
|
||||
const newNum = Math.max(2, Math.min(10, numParticipants + change));
|
||||
setNumParticipants(newNum);
|
||||
|
||||
// Update participant setup array
|
||||
setParticipantSetup(prev => {
|
||||
const newSetup = Array(newNum).fill(null).map((_, i) => ({
|
||||
id: i + 1,
|
||||
name: prev[i]?.name || `Player ${i + 1}`
|
||||
}));
|
||||
return newSetup;
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdatePoints = () => {
|
||||
const total = pointInputs.reduce((sum, points) => sum + points, 0);
|
||||
const pointsPerPerson = Math.floor(total / numParticipants);
|
||||
|
||||
// Update each participant's points while preserving prizes
|
||||
const updatedParticipants = participantSetup.map((setup) => {
|
||||
const existingParticipant = participants.find(p => p.id === setup.id);
|
||||
const currentPrizePoints = existingParticipant?.prizes.reduce((sum, prize) => sum + prize.points, 0) || 0;
|
||||
|
||||
return {
|
||||
id: setup.id,
|
||||
name: setup.name,
|
||||
pointsAvailable: pointsPerPerson - currentPrizePoints,
|
||||
prizes: existingParticipant?.prizes || []
|
||||
};
|
||||
});
|
||||
setTotalPoints(total);
|
||||
setParticipants(updatedParticipants);
|
||||
setStage('tracking');
|
||||
};
|
||||
|
||||
const handleAddPrize = (participantId: number) => {
|
||||
// Just add the prize, no validation
|
||||
const points = parseInt(newPrizePoints) || 0;
|
||||
|
||||
setParticipants(current =>
|
||||
current.map(p => {
|
||||
if (p.id === participantId) {
|
||||
return {
|
||||
...p,
|
||||
pointsAvailable: p.pointsAvailable - points,
|
||||
prizes: [
|
||||
...p.prizes,
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
name: newPrizeName || 'Prize',
|
||||
points
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
return p;
|
||||
})
|
||||
);
|
||||
|
||||
// Clear inputs and close dialog
|
||||
setNewPrizeName('');
|
||||
setNewPrizePoints('');
|
||||
setEditingParticipantId(null);
|
||||
};
|
||||
|
||||
const removePrize = (participantId: number, prizeId: string) => {
|
||||
setParticipants(participants.map(p => {
|
||||
if (p.id === participantId) {
|
||||
const prizeToRemove = p.prizes.find(prize => prize.id === prizeId);
|
||||
return {
|
||||
...p,
|
||||
pointsAvailable: p.pointsAvailable + (prizeToRemove?.points || 0),
|
||||
prizes: p.prizes.filter(prize => prize.id !== prizeId)
|
||||
};
|
||||
}
|
||||
return p;
|
||||
}));
|
||||
};
|
||||
|
||||
const handlePointInput = (index: number, value: string) => {
|
||||
const newInputs = [...pointInputs];
|
||||
newInputs[index] = value === '' ? 0 : parseInt(value) || 0;
|
||||
setPointInputs(newInputs);
|
||||
};
|
||||
|
||||
const resetApp = () => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
setStage('setup');
|
||||
setNumParticipants(2);
|
||||
setParticipantSetup([
|
||||
{ id: 1, name: 'Player 1' },
|
||||
{ id: 2, name: 'Player 2' }
|
||||
]);
|
||||
setPointInputs([]);
|
||||
setParticipants([]);
|
||||
setTotalPoints(0);
|
||||
setShowResetDialog(false);
|
||||
};
|
||||
|
||||
const renderSetupStage = () => (
|
||||
<div className="p-4 max-w-md mx-auto">
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle>Arcade Point Tracker</CardTitle>
|
||||
<CardDescription>Set up your group's point tracking</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Label>Number of Participants (2-10)</Label>
|
||||
<div className="flex items-center gap-4 mt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => handleNumParticipantsChange(-1)}
|
||||
disabled={numParticipants <= 2}
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="text-2xl font-medium w-8 text-center">
|
||||
{numParticipants}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => handleNumParticipantsChange(1)}
|
||||
disabled={numParticipants >= 10}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Label>Participant Names</Label>
|
||||
{participantSetup.map((p, index) => (
|
||||
<div key={p.id} className="flex gap-2">
|
||||
<Input
|
||||
value={p.name}
|
||||
onChange={(e) => handleParticipantNameChange(p.id, e.target.value)}
|
||||
placeholder={`Player ${index + 1}`}
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
className="transition-colors focus:bg-accent focus:text-accent-foreground"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
setPointInputs(Array(numParticipants).fill(0));
|
||||
setStage('points');
|
||||
}}
|
||||
disabled={participantSetup.some(p => !p.name.trim())}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderPointsStage = () => (
|
||||
<div className="p-4 max-w-md mx-auto">
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle>Enter Points</CardTitle>
|
||||
<CardDescription>Input the points from each card</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{pointInputs.map((points, index) => (
|
||||
<div key={index}>
|
||||
<Label htmlFor={`card-${index}`}>Card {index + 1} Points</Label>
|
||||
<Input
|
||||
key={`card-${index}`} // Key helps ensure clean re-render
|
||||
id={`card-${index}`}
|
||||
type="text" // Changed from number
|
||||
inputMode="numeric" // Still shows number keyboard on mobile
|
||||
value={pointInputs[index] === 0 ? '' : pointInputs[index]} // Empty if 0
|
||||
onChange={(e) => handlePointInput(index, e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="Enter points"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setStage('setup')}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpdatePoints} // Changed from calculatePoints
|
||||
disabled={pointInputs.some(p => !p)}
|
||||
>
|
||||
Calculate
|
||||
</Button> </CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderTrackingStage = () => (
|
||||
<>
|
||||
<div className="fixed top-0 left-0 right-0 z-10 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="max-w-md mx-auto p-4">
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader className="space-y-2">
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<CardTitle className="flex flex-col gap-1">
|
||||
<div>Total Points: {totalPoints}</div>
|
||||
<div className="text-base font-normal text-muted-foreground">
|
||||
Points Used: {participants.reduce((total, p) =>
|
||||
total + p.prizes.reduce((sum, prize) => sum + prize.points, 0)
|
||||
, 0)}
|
||||
</div>
|
||||
</CardTitle>
|
||||
<div className="space-x-2 flex-shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setStage('points')}
|
||||
>
|
||||
Edit Points
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setShowResetDialog(true)}
|
||||
>
|
||||
Reset App
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Progress
|
||||
value={(participants.reduce((total, p) =>
|
||||
total + p.prizes.reduce((sum, prize) => sum + prize.points, 0)
|
||||
, 0) / totalPoints) * 100}
|
||||
className={`h-2 ${(participants.reduce((total, p) =>
|
||||
total + p.prizes.reduce((sum, prize) => sum + prize.points, 0)
|
||||
, 0) > totalPoints) ? '[&>div]:bg-destructive' : ''}`}
|
||||
/>
|
||||
<CardDescription className={`text-right text-xs ${(participants.reduce((total, p) =>
|
||||
total + p.prizes.reduce((sum, prize) => sum + prize.points, 0)
|
||||
, 0) > totalPoints) ? 'text-destructive font-medium' : ''}`}>
|
||||
{((participants.reduce((total, p) =>
|
||||
total + p.prizes.reduce((sum, prize) => sum + prize.points, 0)
|
||||
, 0) / totalPoints) * 100).toFixed(1)}% used
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="h-[150px]" /> {/* Spacer for fixed header */}
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<Dialog open={showResetDialog} onOpenChange={setShowResetDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reset Application</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to reset all data? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowResetDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={resetApp}>
|
||||
Reset
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AnimatePresence>
|
||||
{participants.map((participant, index) => (
|
||||
<motion.div
|
||||
key={participant.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.1 }}
|
||||
>
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<CardTitle>{participant.name}</CardTitle>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-muted-foreground flex justify-between items-center">
|
||||
<span>Points Available: {participant.pointsAvailable} / {Math.floor(totalPoints / participants.length)}</span>
|
||||
<span className={`text-xs ${participant.pointsAvailable < 0 ? 'text-destructive font-medium' : ''}`}>
|
||||
{((participant.pointsAvailable / (totalPoints / participants.length)) * 100).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={100 - ((participant.pointsAvailable / (totalPoints / participants.length)) * 100)}
|
||||
className={`h-2 ${participant.pointsAvailable < 0 ? '[&>div]:bg-destructive' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-2">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{participant.prizes.map((prize) => (
|
||||
<motion.div
|
||||
key={`${participant.id}-${prize.id}`}
|
||||
layout
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 500,
|
||||
damping: 30,
|
||||
opacity: { duration: 0.2 }
|
||||
}}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="flex justify-between items-center p-2 bg-secondary rounded">
|
||||
<div>
|
||||
<div className="font-medium">{prize.name}</div>
|
||||
<div className="text-sm text-muted-foreground">{prize.points} points</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removePrize(participant.id, prize.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{!participant.prizes.length && (
|
||||
<Alert variant="default" className="bg-muted">
|
||||
<AlertDescription>
|
||||
"No prizes added yet. Click "Add Prize" to get started."
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Dialog open={editingParticipantId === participant.id} onOpenChange={(open) => {
|
||||
if (!open) setEditingParticipantId(null);
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
onClick={() => setEditingParticipantId(participant.id)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Prize
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Prize for {participant.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Available Points: {participant.pointsAvailable}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="prize-name">Prize Name</Label>
|
||||
<Input
|
||||
id="prize-name"
|
||||
value={newPrizeName}
|
||||
onChange={(e) => setNewPrizeName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="prize-points">Points Cost</Label>
|
||||
<Input
|
||||
id="prize-points"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={newPrizePoints}
|
||||
onChange={(e) => setNewPrizePoints(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setEditingParticipantId(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleAddPrize(participant.id)}
|
||||
disabled={!newPrizeName}
|
||||
>
|
||||
Add Prize
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
switch (stage) {
|
||||
case 'setup':
|
||||
return renderSetupStage();
|
||||
case 'points':
|
||||
return renderPointsStage();
|
||||
case 'tracking':
|
||||
return renderTrackingStage();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default ArcadeTracker;
|
||||
Reference in New Issue
Block a user