Files
leagues-tools/os-league-tools-master/src/pages/Planner.js
Sonderau 7b729c5cb2
All checks were successful
Build & Deploy OS League Tools / build-and-deploy (push) Successful in 59s
Fixed object destructuring issue
2026-01-22 00:44:02 +00:00

413 lines
16 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}