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

This commit is contained in:
2026-01-22 00:36:47 +00:00
parent 15f054d291
commit 7a32d0e874
23 changed files with 1366 additions and 14 deletions

View 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>
);
}