All checks were successful
Build & Deploy OS League Tools / build-and-deploy (push) Successful in 59s
413 lines
16 KiB
JavaScript
413 lines
16 KiB
JavaScript
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||
import PageWrapper from '../components/PageWrapper';
|
||
import Card from '../components/common/Card';
|
||
import images from '../assets/images';
|
||
import plannerRegions from '../data/planner/plannerRegions.json';
|
||
import plannerTasks from '../data/planner/plannerTasks.json';
|
||
import plannerItems from '../data/planner/plannerItems.json';
|
||
import plannerRelics from '../data/planner/plannerRelics.json';
|
||
|
||
// Constants
|
||
const MAX_REGIONS = 5;
|
||
|
||
// Map region names to their badge image keys
|
||
const getRegionBadge = regionName => {
|
||
const badgeKey = `${regionName}_Area_Badge.png`;
|
||
return images[badgeKey];
|
||
};
|
||
|
||
// Get default region IDs
|
||
const DEFAULT_REGION_IDS = new Set(plannerRegions.regions.filter(r => r.isDefault).map(r => r.id));
|
||
|
||
export default function Planner() {
|
||
// Region selection state - default regions are pre-selected
|
||
const [selectedRegions, setSelectedRegions] = useState(() => {
|
||
const defaults = plannerRegions.regions
|
||
.filter(r => r.isDefault)
|
||
.map(r => r.id);
|
||
return new Set(defaults);
|
||
});
|
||
|
||
// Task completion state
|
||
const [completedTasks, setCompletedTasks] = useState(new Set());
|
||
|
||
// Toast notification state
|
||
const [toast, setToast] = useState(null);
|
||
|
||
// Auto-hide toast after 3 seconds
|
||
useEffect(() => {
|
||
if (toast) {
|
||
const timer = setTimeout(() => setToast(null), 3000);
|
||
return () => clearTimeout(timer);
|
||
}
|
||
return undefined;
|
||
}, [toast]);
|
||
|
||
// Show toast notification
|
||
const showToast = useCallback((message, type = 'error') => {
|
||
setToast({ message, type, key: Date.now() });
|
||
}, []);
|
||
|
||
// Toggle region selection
|
||
const toggleRegion = regionId => {
|
||
// Prevent removing default regions
|
||
if (DEFAULT_REGION_IDS.has(regionId) && selectedRegions.has(regionId)) {
|
||
return;
|
||
}
|
||
|
||
// Check if trying to add beyond max
|
||
if (!selectedRegions.has(regionId) && selectedRegions.size >= MAX_REGIONS) {
|
||
showToast(`You cannot select more than ${MAX_REGIONS} regions!`);
|
||
return;
|
||
}
|
||
|
||
setSelectedRegions(prev => {
|
||
const next = new Set(prev);
|
||
if (next.has(regionId)) {
|
||
next.delete(regionId);
|
||
} else {
|
||
next.add(regionId);
|
||
}
|
||
return next;
|
||
});
|
||
};
|
||
|
||
// Get region names for filtering
|
||
const selectedRegionNames = useMemo(() => {
|
||
const names = new Set(['Global']);
|
||
plannerRegions.regions.forEach(region => {
|
||
if (selectedRegions.has(region.id)) {
|
||
names.add(region.name);
|
||
}
|
||
});
|
||
return names;
|
||
}, [selectedRegions]);
|
||
|
||
// Filter tasks based on selected regions
|
||
const availableTasks = useMemo(
|
||
() => plannerTasks.tasks.filter(task => task.regions.some(region => selectedRegionNames.has(region))),
|
||
[selectedRegionNames]
|
||
);
|
||
|
||
// Filter items based on selected regions
|
||
const availableItems = useMemo(
|
||
() => plannerItems.items.filter(item => item.regions.some(region => selectedRegionNames.has(region))),
|
||
[selectedRegionNames]
|
||
);
|
||
|
||
// Handle task completion with cascading logic
|
||
const toggleTaskCompletion = useCallback(taskId => {
|
||
setCompletedTasks(prev => {
|
||
const next = new Set(prev);
|
||
const task = plannerTasks.tasks.find(t => t.id === taskId);
|
||
|
||
if (!task) {
|
||
return prev;
|
||
}
|
||
|
||
if (next.has(taskId)) {
|
||
// Uncompleting a task - just remove it
|
||
next.delete(taskId);
|
||
} else {
|
||
// Completing a task - also complete any tasks in autoCompletes
|
||
next.add(taskId);
|
||
if (task.autoCompletes && task.autoCompletes.length > 0) {
|
||
task.autoCompletes.forEach(autoId => {
|
||
next.add(autoId);
|
||
});
|
||
}
|
||
}
|
||
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
// Check if a task is locked (prerequisites not met)
|
||
const isTaskLocked = useCallback(task => {
|
||
if (!task.prerequisites || task.prerequisites.length === 0) {
|
||
return false;
|
||
}
|
||
return task.prerequisites.some(prereqId => !completedTasks.has(prereqId));
|
||
}, [completedTasks]);
|
||
|
||
// Calculate total points from completed tasks
|
||
const totalPoints = useMemo(
|
||
() => availableTasks.filter(task => completedTasks.has(task.id)).reduce((sum, task) => sum + task.points, 0),
|
||
[availableTasks, completedTasks]
|
||
);
|
||
|
||
// Get difficulty color class
|
||
const getDifficultyColor = difficulty => {
|
||
const colors = {
|
||
Easy: 'text-green-400',
|
||
Medium: 'text-yellow-400',
|
||
Hard: 'text-orange-400',
|
||
Elite: 'text-red-400',
|
||
Master: 'text-purple-400',
|
||
};
|
||
return colors[difficulty] || 'text-gray-400';
|
||
};
|
||
|
||
return (
|
||
<PageWrapper>
|
||
{/* Toast Notification */}
|
||
{toast && (
|
||
<div
|
||
key={toast.key}
|
||
className='fixed top-4 right-4 z-50 animate-jiggle'
|
||
style={{
|
||
animation: 'jiggle 0.5s ease-in-out',
|
||
}}
|
||
>
|
||
<div className='bg-red-600 text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-2'>
|
||
<span className='text-lg'>⚠️</span>
|
||
<span>{toast.message}</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Jiggle animation styles */}
|
||
<style>{`
|
||
@keyframes jiggle {
|
||
0%, 100% { transform: translateX(0); }
|
||
10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); }
|
||
20%, 40%, 60%, 80% { transform: translateX(5px); }
|
||
}
|
||
`}</style>
|
||
|
||
<Card>
|
||
<Card.Body>
|
||
<div className='p-4'>
|
||
<div className='mb-6'>
|
||
<h1 className='text-2xl font-bold mb-2'>League Planner</h1>
|
||
<p className='text-sm text-secondary'>
|
||
Select regions to see available tasks and items. Completing certain tasks will automatically mark related tasks as complete.
|
||
</p>
|
||
</div>
|
||
|
||
{/* Points Display */}
|
||
<div className='mb-6 p-4 bg-secondary rounded-lg'>
|
||
<div className='text-lg font-semibold'>
|
||
Total Points: <span className='text-accent'>{totalPoints}</span>
|
||
</div>
|
||
<div className='text-sm text-secondary'>
|
||
Tasks Completed: {completedTasks.size} / {availableTasks.length}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Region Selector - Right anchored card with badges, 1 per row */}
|
||
<div className='w-full flex justify-end mb-6'>
|
||
<div className='bg-secondary rounded-lg p-3 border border-gray-700'>
|
||
<div className='text-xs text-secondary mb-2 text-right'>Select Regions</div>
|
||
<div className='text-xs text-secondary mb-1 text-right'>
|
||
{selectedRegions.size} / {MAX_REGIONS} selected
|
||
</div>
|
||
<div className='flex flex-col gap-1'>
|
||
{plannerRegions.regions.map(region => {
|
||
const isSelected = selectedRegions.has(region.id);
|
||
const { isDefault } = region;
|
||
const badge = getRegionBadge(region.name);
|
||
|
||
return (
|
||
<button
|
||
key={region.id}
|
||
type='button'
|
||
onClick={() => toggleRegion(region.id)}
|
||
className={`flex items-center justify-end gap-2 px-2 py-1 rounded transition-all duration-200 ${
|
||
isDefault ? 'cursor-default' : 'hover:bg-gray-700 cursor-pointer'
|
||
} ${isSelected ? 'opacity-100' : 'opacity-40'}`}
|
||
title={isDefault ? 'Default region (cannot be removed)' : region.name}
|
||
>
|
||
<span className={`text-sm ${isSelected ? 'text-white' : 'text-gray-400'}`}>
|
||
{region.name}
|
||
{isDefault && <span className='ml-1 text-yellow-400' title='Default region'>🔒</span>}
|
||
</span>
|
||
<img
|
||
src={badge}
|
||
alt={region.name}
|
||
width='20'
|
||
height='30'
|
||
decoding='async'
|
||
loading='lazy'
|
||
className={`${isSelected ? '' : 'grayscale'}`}
|
||
/>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className='grid grid-cols-1 lg:grid-cols-2 gap-6'>
|
||
{/* Available Tasks */}
|
||
<div>
|
||
<h2 className='text-lg font-semibold mb-3'>
|
||
Available Tasks ({availableTasks.length})
|
||
</h2>
|
||
<div className='space-y-2 max-h-96 overflow-y-auto pr-2'>
|
||
{availableTasks.map(task => {
|
||
const isCompleted = completedTasks.has(task.id);
|
||
const isLocked = isTaskLocked(task);
|
||
const hasAutoCompletes = task.autoCompletes && task.autoCompletes.length > 0;
|
||
|
||
return (
|
||
<div
|
||
key={task.id}
|
||
className={`p-3 rounded-lg border transition-all ${
|
||
isCompleted
|
||
? 'bg-green-900/30 border-green-700'
|
||
: isLocked
|
||
? 'bg-gray-800/50 border-gray-700 opacity-50'
|
||
: 'bg-gray-800 border-gray-700 hover:border-gray-500'
|
||
}`}
|
||
>
|
||
<div className='flex items-start gap-3'>
|
||
<input
|
||
type='checkbox'
|
||
checked={isCompleted}
|
||
disabled={isLocked}
|
||
onChange={() => toggleTaskCompletion(task.id)}
|
||
className='mt-1 h-4 w-4 rounded'
|
||
/>
|
||
<div className='flex-1 min-w-0'>
|
||
<div className='flex items-center gap-2 flex-wrap'>
|
||
<span className={`font-medium ${isCompleted ? 'line-through opacity-70' : ''}`}>
|
||
{task.name}
|
||
</span>
|
||
<span className={`text-xs ${getDifficultyColor(task.difficulty)}`}>
|
||
{task.difficulty}
|
||
</span>
|
||
<span className='text-xs text-accent'>
|
||
{task.points} pts
|
||
</span>
|
||
</div>
|
||
<p className='text-xs text-secondary mt-1'>{task.description}</p>
|
||
{hasAutoCompletes && (
|
||
<p className='text-xs text-blue-400 mt-1'>
|
||
⚡ Auto-completes {task.autoCompletes.length} other task(s)
|
||
</p>
|
||
)}
|
||
{isLocked && (
|
||
<p className='text-xs text-red-400 mt-1'>
|
||
🔒 Requires prerequisite tasks
|
||
</p>
|
||
)}
|
||
<div className='flex gap-1 mt-1 flex-wrap'>
|
||
{task.regions.map(region => (
|
||
<span
|
||
key={region}
|
||
className='text-xs px-1.5 py-0.5 bg-gray-700 rounded'
|
||
>
|
||
{region}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Available Items */}
|
||
<div>
|
||
<h2 className='text-lg font-semibold mb-3'>
|
||
Available Items ({availableItems.length})
|
||
</h2>
|
||
<div className='space-y-2 max-h-96 overflow-y-auto pr-2'>
|
||
{availableItems.map(item => (
|
||
<div
|
||
key={item.id}
|
||
className='p-3 rounded-lg bg-gray-800 border border-gray-700'
|
||
>
|
||
<div className='flex items-center justify-between'>
|
||
<span className='font-medium'>{item.name}</span>
|
||
<span className='text-xs px-2 py-0.5 bg-gray-700 rounded'>
|
||
{item.slot}
|
||
</span>
|
||
</div>
|
||
<p className='text-xs text-secondary mt-1'>
|
||
{item.obtainedFrom}
|
||
</p>
|
||
{item.requirements.length > 0 && (
|
||
<div className='flex gap-1 mt-1 flex-wrap'>
|
||
{item.requirements.map((req, idx) => (
|
||
<span
|
||
key={idx}
|
||
className='text-xs px-1.5 py-0.5 bg-blue-900/50 rounded'
|
||
>
|
||
{req.skill} {req.level}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
{item.relicUnlock && (
|
||
<p className='text-xs text-purple-400 mt-1'>
|
||
🔮 Requires relic unlock
|
||
</p>
|
||
)}
|
||
<div className='flex gap-1 mt-1'>
|
||
{item.regions.map(region => (
|
||
<span
|
||
key={region}
|
||
className='text-xs px-1.5 py-0.5 bg-gray-700 rounded'
|
||
>
|
||
{region}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Relics Section */}
|
||
<div className='mt-6'>
|
||
<h2 className='text-lg font-semibold mb-3'>Relic Tiers</h2>
|
||
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3'>
|
||
{plannerRelics.relicTiers.map(tier => {
|
||
const relicsInTier = plannerRelics.relics.filter(r => r.tier === tier.tier);
|
||
const isUnlocked = totalPoints >= tier.pointsRequired;
|
||
|
||
return (
|
||
<div
|
||
key={tier.tier}
|
||
className={`p-3 rounded-lg border ${
|
||
isUnlocked
|
||
? 'bg-purple-900/30 border-purple-700'
|
||
: 'bg-gray-800/50 border-gray-700 opacity-60'
|
||
}`}
|
||
>
|
||
<div className='flex items-center justify-between mb-2'>
|
||
<span className='font-medium'>Tier {tier.tier}</span>
|
||
<span className='text-xs text-accent'>
|
||
{tier.pointsRequired} pts
|
||
</span>
|
||
</div>
|
||
<p className='text-xs text-secondary mb-2'>{tier.name}</p>
|
||
<div className='space-y-1'>
|
||
{relicsInTier.slice(0, 3).map(relic => (
|
||
<div key={relic.id} className='text-xs text-gray-400'>
|
||
• {relic.name}
|
||
</div>
|
||
))}
|
||
</div>
|
||
{!isUnlocked && (
|
||
<p className='text-xs text-yellow-500 mt-2'>
|
||
Need {tier.pointsRequired - totalPoints} more points
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Card.Body>
|
||
</Card>
|
||
</PageWrapper>
|
||
);
|
||
}
|