New DEV changes committed, introduction of planner, added badges akin to what's on the Wiki.
Some checks failed
Build & Deploy OS League Tools / build-and-deploy (push) Failing after 34s
Some checks failed
Build & Deploy OS League Tools / build-and-deploy (push) Failing after 34s
This commit is contained in:
412
os-league-tools-master/src/pages/Planner.js
Normal file
412
os-league-tools-master/src/pages/Planner.js
Normal file
@@ -0,0 +1,412 @@
|
||||
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.isDefault;
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user