New DEV changes committed, introduction of planner, added badges akin to what's on the Wiki.

This commit is contained in:
2026-01-22 00:36:47 +00:00
committed by sonderau
parent 73dde99924
commit d7d89f8d2c
23 changed files with 1366 additions and 14 deletions

Binary file not shown.

View File

@@ -72,7 +72,7 @@ module.exports = function (proxy, allowedHost) {
// Enable custom sockjs pathname for websocket connection to hot reloading server.
// Enable custom sockjs hostname, pathname and port for websocket connection
// to hot reloading server.
protocol: sockProtocol,
protocol: 'wss',
hostname: sockHost,
pathname: sockPath,
port: sockPort,

View File

@@ -18,6 +18,7 @@ import Calculators from './pages/Calculators';
import Faq from './pages/Faq';
import ViewCharacter from './pages/ViewCharacter';
import Groups from './pages/Groups';
import Planner from './pages/Planner';
import { submitRenderError } from './client/feedback-client';
import { ErrorPage } from './components/common/util/ErrorBoundary';
@@ -90,8 +91,10 @@ export default function App() {
<Auth0Provider
domain='login.osleague.tools'
clientId='yfqwKEhQO8FL7MlxWmWo7ekuGgzSrfmh'
redirectUri={window.location.origin}
audience='https://dev-u4mby-kt.us.auth0.com/api/v2/'
authorizationParams={{
redirect_uri: window.location.origin,
audience: 'https://dev-u4mby-kt.us.auth0.com/api/v2/',
}}
>
<ErrorBoundary FallbackComponent={ErrorPage} onError={submitRenderError}>
<Routes>
@@ -104,6 +107,7 @@ export default function App() {
<Route path=':skill' element={<Calculators />} />
</Route>
<Route path='groups' element={<Groups />} />
<Route path='planner' element={<Planner />} />
<Route path='about' element={<About />} />
<Route path='settings' element={<Settings />} />
<Route path='faq' element={<Faq />} />

Binary file not shown.

After

Width:  |  Height:  |  Size: 581 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 573 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 579 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 582 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 619 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 577 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 598 B

View File

@@ -24,6 +24,7 @@ export default function PageWrapper({ children }) {
new NavItem('Trackers', 'primary', 0, 1).withRouterLink('/tracker').withIconFont('checklist_rtl'),
new NavItem('Calculators', 'primary', 0, 2).withRouterLink('/calculators').withIconFont('calculate'),
new NavItem('Groups', 'primary', 0, 3).withRouterLink('/groups').withIconFont('groups'),
new NavItem('Planner', 'primary', 0, 4).withRouterLink('/planner').withIconFont('event_note'),
new NavItem('Character', 'secondary', 1, 0).withCustomRenderFn(
(isCollapsed, onNavigate) => (
<Character.SideBarItem

View File

@@ -1,4 +1,16 @@
[
{
"title": "Demonic Pacts Leagues teaser!!",
"date": "Jan 8 2026",
"thumbnail": "https://i.imgur.com/hkZmhsr.png",
"leadText": "Let's get excited!",
"htmlContent": "<p>Over the coming weeks we will be updating the website with information as we learn about it."
},
{
"type": "separator",
"title": "Legacy Updates",
"subtitle": "From the original OS League Tools project"
},
{
"title": "Raging Echoes Leagues launch!",
"date": "Nov 11 2024",
@@ -51,7 +63,7 @@
{
"title": "Improved mobile support",
"date": "Nov 08 2020",
"thumbnail": "http://cdn.runescape.com/assets/img/external/oldschool/2017/newsposts/2017-06-27/osrsmobilemock.png",
"thumbnail": "https://cdn.runescape.com/assets/img/external/oldschool/2017/newsposts/2017-06-27/osrsmobilemock.png",
"leadText": "OS League Tools is now fully compatible with mobile devices (and screens of all other sizes).",
"htmlContent": "<p>OS League Tools is now fully compatible with mobile devices (and screens of all other sizes).</p> <p>I've been hard at work this weekend improving the site experience on mobile, so you can now track and plan your tasks on the go. While my focus was on mobile, you should see some improvements all across the site for just about any screen size, as many components were revamped completely to be more responsive to the size of your screen.</p> <p>If you come across any parts of the site that still look strange on mobile devices, please make a bug report in the <a href='https://discord.gg/GQ5kVyU'>discord</a>, preferably with a screenshot and information about what device and browser you're using.</p>"
},

View File

@@ -0,0 +1,210 @@
{
"items": [
{
"id": 1,
"name": "Abyssal Whip",
"slot": "Weapon",
"regions": ["Morytania"],
"obtainedFrom": "Abyssal Demons",
"requirements": [
{"skill": "Attack", "level": 70}
],
"relicUnlock": null
},
{
"id": 2,
"name": "Trident of the Seas",
"slot": "Weapon",
"regions": ["Morytania"],
"obtainedFrom": "Kraken",
"requirements": [
{"skill": "Magic", "level": 75}
],
"relicUnlock": null
},
{
"id": 3,
"name": "Blowpipe",
"slot": "Weapon",
"regions": ["Tirannwn"],
"obtainedFrom": "Zulrah",
"requirements": [
{"skill": "Ranged", "level": 75}
],
"relicUnlock": null
},
{
"id": 4,
"name": "Dragon Scimitar",
"slot": "Weapon",
"regions": ["Kandarin"],
"obtainedFrom": "Ape Atoll Shop (after Monkey Madness)",
"requirements": [
{"skill": "Attack", "level": 60}
],
"relicUnlock": null
},
{
"id": 5,
"name": "Fire Cape",
"slot": "Cape",
"regions": ["Karamja"],
"obtainedFrom": "Fight Caves (TzTok-Jad)",
"requirements": [],
"relicUnlock": null
},
{
"id": 6,
"name": "Infernal Cape",
"slot": "Cape",
"regions": ["Karamja"],
"obtainedFrom": "Inferno (TzKal-Zuk)",
"requirements": [],
"relicUnlock": null
},
{
"id": 7,
"name": "Barrows Gloves",
"slot": "Gloves",
"regions": ["Misthalin"],
"obtainedFrom": "Recipe for Disaster completion",
"requirements": [
{"skill": "Defence", "level": 41}
],
"relicUnlock": null
},
{
"id": 8,
"name": "Scythe of Vitur",
"slot": "Weapon",
"regions": ["Morytania"],
"obtainedFrom": "Theatre of Blood",
"requirements": [
{"skill": "Attack", "level": 75},
{"skill": "Strength", "level": 75}
],
"relicUnlock": null
},
{
"id": 9,
"name": "Twisted Bow",
"slot": "Weapon",
"regions": ["Kourend"],
"obtainedFrom": "Chambers of Xeric",
"requirements": [
{"skill": "Ranged", "level": 75}
],
"relicUnlock": null
},
{
"id": 10,
"name": "Blade of Saeldor",
"slot": "Weapon",
"regions": ["Tirannwn"],
"obtainedFrom": "The Gauntlet",
"requirements": [
{"skill": "Attack", "level": 75}
],
"relicUnlock": null
},
{
"id": 11,
"name": "Echo Pickaxe",
"slot": "Tool",
"regions": ["Global"],
"obtainedFrom": "Power Miner Relic",
"requirements": [],
"relicUnlock": "t1-0"
},
{
"id": 12,
"name": "Echo Axe",
"slot": "Tool",
"regions": ["Global"],
"obtainedFrom": "Lumberjack Relic",
"requirements": [],
"relicUnlock": "t1-1"
},
{
"id": 13,
"name": "Echo Harpoon",
"slot": "Tool",
"regions": ["Global"],
"obtainedFrom": "Animal Wrangler Relic",
"requirements": [],
"relicUnlock": "t1-2"
},
{
"id": 14,
"name": "Berserker Ring",
"slot": "Ring",
"regions": ["Fremennik"],
"obtainedFrom": "Dagannoth Rex",
"requirements": [],
"relicUnlock": null
},
{
"id": 15,
"name": "Archers Ring",
"slot": "Ring",
"regions": ["Fremennik"],
"obtainedFrom": "Dagannoth Supreme",
"requirements": [],
"relicUnlock": null
},
{
"id": 16,
"name": "Seers Ring",
"slot": "Ring",
"regions": ["Fremennik"],
"obtainedFrom": "Dagannoth Prime",
"requirements": [],
"relicUnlock": null
},
{
"id": 17,
"name": "Dragon Defender",
"slot": "Shield",
"regions": ["Asgarnia"],
"obtainedFrom": "Warriors Guild",
"requirements": [
{"skill": "Attack", "level": 60},
{"skill": "Defence", "level": 60}
],
"relicUnlock": null
},
{
"id": 18,
"name": "Amulet of Fury",
"slot": "Amulet",
"regions": ["Global"],
"obtainedFrom": "Crafting (onyx)",
"requirements": [
{"skill": "Crafting", "level": 90}
],
"relicUnlock": null
},
{
"id": 19,
"name": "Occult Necklace",
"slot": "Amulet",
"regions": ["Morytania"],
"obtainedFrom": "Smoke Devils",
"requirements": [
{"skill": "Magic", "level": 70}
],
"relicUnlock": null
},
{
"id": 20,
"name": "Necklace of Anguish",
"slot": "Amulet",
"regions": ["Tirannwn"],
"obtainedFrom": "Crafting (zenyte from demonic gorillas)",
"requirements": [
{"skill": "Crafting", "level": 92}
],
"relicUnlock": null
}
]
}

View File

@@ -0,0 +1,92 @@
{
"regions": [
{
"id": 0,
"name": "Misthalin",
"isDefault": true,
"description": "The heart of Gielinor, home to Lumbridge and Varrock",
"keyLocations": ["Lumbridge", "Varrock", "Edgeville", "Barbarian Village"],
"unlocksRegions": []
},
{
"id": 1,
"name": "Karamja",
"isDefault": true,
"description": "A tropical island with jungle dangers and volcanic activity",
"keyLocations": ["Brimhaven", "Tai Bwo Wannai", "Shilo Village"],
"unlocksRegions": []
},
{
"id": 2,
"name": "Asgarnia",
"isDefault": false,
"description": "Home to Falador, the White Knights, and the Dwarven Mines",
"keyLocations": ["Falador", "Port Sarim", "Mudskipper Point", "Ice Mountain"],
"unlocksRegions": []
},
{
"id": 3,
"name": "Desert",
"isDefault": false,
"description": "A harsh desert region with ancient pyramids and hidden cities",
"keyLocations": ["Al Kharid", "Pollnivneach", "Nardah", "Sophanem"],
"unlocksRegions": []
},
{
"id": 4,
"name": "Fremennik",
"isDefault": false,
"description": "Nordic lands of warriors, featuring Rellekka and the Lunar Isle",
"keyLocations": ["Rellekka", "Lunar Isle", "Neitiznot", "Jatizso"],
"unlocksRegions": []
},
{
"id": 5,
"name": "Morytania",
"isDefault": false,
"description": "A dark swampland ruled by vampyres and filled with undead",
"keyLocations": ["Canifis", "Port Phasmatys", "Barrows", "Theatre of Blood"],
"unlocksRegions": []
},
{
"id": 6,
"name": "Kandarin",
"isDefault": false,
"description": "A diverse region featuring Ardougne, Seers Village, and Prifddinas",
"keyLocations": ["Ardougne", "Seers' Village", "Catherby", "Yanille"],
"unlocksRegions": []
},
{
"id": 7,
"name": "Kourend",
"isDefault": false,
"description": "The Great Kourend, a kingdom of five houses with unique cultures",
"keyLocations": ["Hosidius", "Shayzien", "Lovakengj", "Arceuus", "Piscarilius"],
"unlocksRegions": []
},
{
"id": 8,
"name": "Tirannwn",
"isDefault": false,
"description": "The elven lands, featuring dense forests and the crystal city",
"keyLocations": ["Lletya", "Prifddinas", "Zul-Andra"],
"unlocksRegions": []
},
{
"id": 9,
"name": "Wilderness",
"isDefault": false,
"description": "A dangerous PvP zone with powerful bosses and high-risk rewards",
"keyLocations": ["Edgeville Dungeon", "Mage Arena", "Revenant Caves"],
"unlocksRegions": []
},
{
"id": 10,
"name": "Varlamore",
"isDefault": false,
"description": "A newly discovered continent with ancient secrets",
"keyLocations": ["Civitas illa Fortis", "Cam Torum", "Hunter Guild"],
"unlocksRegions": []
}
]
}

View File

@@ -0,0 +1,244 @@
{
"relicTiers": [
{
"tier": 1,
"pointsRequired": 0,
"name": "Gathering Tier"
},
{
"tier": 2,
"pointsRequired": 750,
"name": "Skilling Tier"
},
{
"tier": 3,
"pointsRequired": 1500,
"name": "Utility Tier"
},
{
"tier": 4,
"pointsRequired": 2500,
"name": "Bonus Tier"
},
{
"tier": 5,
"pointsRequired": 5000,
"name": "Mastery Tier"
},
{
"tier": 6,
"pointsRequired": 8000,
"name": "Convenience Tier"
},
{
"tier": 7,
"pointsRequired": 16000,
"name": "Advanced Tier"
},
{
"tier": 8,
"pointsRequired": 25000,
"name": "Combat Tier"
}
],
"relics": [
{
"id": "t1-0",
"tier": 1,
"name": "Power Miner",
"description": "Grants the Echo pickaxe with mining bonuses and auto-smelting",
"effects": [
"50% chance to succeed on failed mining attempts",
"Rock doesn't deplete until 4 ores mined",
"Optional auto-smelt and gem cutting"
],
"unlocksItems": ["Echo Pickaxe"],
"autoCompletes": []
},
{
"id": "t1-1",
"tier": 1,
"name": "Lumberjack",
"description": "Grants the Echo axe with woodcutting bonuses and auto-banking",
"effects": [
"50% chance to succeed on failed chops",
"Items sent directly to bank",
"Optional auto-burn and fletching"
],
"unlocksItems": ["Echo Axe"],
"autoCompletes": []
},
{
"id": "t1-2",
"tier": 1,
"name": "Animal Wrangler",
"description": "Grants the Echo harpoon with fishing and hunter bonuses",
"effects": [
"50% chance to succeed on failed fishing attempts",
"Fish automatically sent to bank",
"50% auto-cook chance",
"Hunter traps never fail"
],
"unlocksItems": ["Echo Harpoon"],
"autoCompletes": []
},
{
"id": "t2-0",
"tier": 2,
"name": "Corner Cutter",
"description": "Agility course bonuses and never fail obstacles",
"effects": [
"Grants Sage's Greaves for passive Agility XP",
"Never fail agility obstacles",
"Double course completion credit"
],
"unlocksItems": ["Sage's Greaves"],
"autoCompletes": []
},
{
"id": "t2-1",
"tier": 2,
"name": "Friendly Forager",
"description": "Herb gathering while skilling and potion bonuses",
"effects": [
"Grants Forager's Pouch that collects herbs",
"90% chance to save secondary ingredients",
"Potions have 4 doses instead of 3"
],
"unlocksItems": ["Forager's Pouch"],
"autoCompletes": []
},
{
"id": "t2-2",
"tier": 2,
"name": "Dodgy Deals",
"description": "Massive thieving upgrades and AoE pickpocketing",
"effects": [
"AoE pickpocketing in 11x11 area",
"100% success rate on thieving",
"Auto re-pickpocket until stopped",
"Noted loot from pickpocketing"
],
"unlocksItems": [],
"autoCompletes": []
},
{
"id": "t3-0",
"tier": 3,
"name": "Clue Compass",
"description": "Teleport to STASH units and clue locations",
"effects": [
"Teleport to any STASH unit",
"Teleport to current clue step",
"Ignores wilderness teleport restrictions"
],
"unlocksItems": ["Clue Compass"],
"autoCompletes": []
},
{
"id": "t3-1",
"tier": 3,
"name": "Bank Heist",
"description": "Teleport to any bank or deposit box",
"effects": [
"Teleport to any deposit box, bank, or bank chest",
"Ignores wilderness teleport restrictions"
],
"unlocksItems": ["Banker's Briefcase"],
"autoCompletes": []
},
{
"id": "t3-2",
"tier": 3,
"name": "Fairy's Flight",
"description": "Teleport to fairy rings, spirit trees, and leprechauns",
"effects": [
"Teleport to any fairy ring or spirit tree",
"Teleport to any tool leprechaun",
"Auto-completes Tree Gnome Village quest"
],
"unlocksItems": ["Fairy Mushroom"],
"autoCompletes": []
},
{
"id": "t5-0",
"tier": 5,
"name": "Treasure Arbiter",
"description": "Enhanced clue scroll drops and rewards",
"effects": [
"1/15 clue drop rate from implings",
"10x more clue geodes/nests/bottles",
"Minimum steps for all clue tiers",
"Maximum reward rolls for all caskets"
],
"unlocksItems": [],
"autoCompletes": []
},
{
"id": "t5-1",
"tier": 5,
"name": "Production Master",
"description": "Process entire inventories at once",
"effects": [
"All items processed in one action",
"Works for smithing, fletching, crafting, cooking, herblore"
],
"unlocksItems": [],
"autoCompletes": []
},
{
"id": "t5-2",
"tier": 5,
"name": "Slayer Master",
"description": "Always on task for all eligible slayer monsters",
"effects": [
"Always on task for all slayer monsters",
"Free slayer perks and skips",
"Bonus XP for killing 100th of each monster"
],
"unlocksItems": [],
"autoCompletes": []
},
{
"id": "t8-0",
"tier": 8,
"name": "Specialist",
"description": "All special attacks cost 20% with enhanced accuracy",
"effects": [
"All specs cost 20%",
"+100% spec accuracy",
"10% spec restore on missed hit",
"15% spec restore on kill"
],
"unlocksItems": [],
"autoCompletes": []
},
{
"id": "t8-1",
"tier": 8,
"name": "Guardian",
"description": "Summon a powerful Guardian Thrall to fight for you",
"effects": [
"Grants Guardian Horn to summon thrall",
"Thrall attacks with target's weakness",
"AoE attacks in multi-combat"
],
"unlocksItems": ["Guardian Horn"],
"autoCompletes": []
},
{
"id": "t8-2",
"tier": 8,
"name": "Last Stand",
"description": "Cheat death with a powerful combat buff",
"effects": [
"Survive fatal damage at 1 HP",
"Combat stats boosted to 255",
"16 tick invulnerability",
"Heal based on damage dealt"
],
"unlocksItems": [],
"autoCompletes": []
}
]
}

View File

@@ -0,0 +1,226 @@
{
"tasks": [
{
"id": 1,
"name": "Achieve Your First Level Up",
"description": "Level up any of your skills for the first time",
"points": 10,
"difficulty": "Easy",
"category": "Milestone",
"regions": ["Global"],
"skillRequirements": [],
"autoCompletes": [],
"prerequisites": []
},
{
"id": 2,
"name": "Achieve Your First Level 5",
"description": "Reach level 5 in any skill",
"points": 10,
"difficulty": "Easy",
"category": "Milestone",
"regions": ["Global"],
"skillRequirements": [],
"autoCompletes": [1],
"prerequisites": []
},
{
"id": 3,
"name": "Achieve Your First Level 10",
"description": "Reach level 10 in any skill",
"points": 10,
"difficulty": "Easy",
"category": "Milestone",
"regions": ["Global"],
"skillRequirements": [],
"autoCompletes": [1, 2],
"prerequisites": []
},
{
"id": 4,
"name": "Achieve Your First Level 20",
"description": "Reach level 20 in any skill",
"points": 25,
"difficulty": "Easy",
"category": "Milestone",
"regions": ["Global"],
"skillRequirements": [],
"autoCompletes": [1, 2, 3],
"prerequisites": []
},
{
"id": 5,
"name": "Achieve Your First Level 30",
"description": "Reach level 30 in any skill",
"points": 25,
"difficulty": "Medium",
"category": "Milestone",
"regions": ["Global"],
"skillRequirements": [],
"autoCompletes": [1, 2, 3, 4],
"prerequisites": []
},
{
"id": 100,
"name": "Complete Dragon Slayer",
"description": "Complete the Dragon Slayer quest",
"points": 50,
"difficulty": "Medium",
"category": "Quest",
"regions": ["Misthalin", "Asgarnia"],
"skillRequirements": [
{"skill": "Quest Points", "level": 32}
],
"autoCompletes": [],
"prerequisites": []
},
{
"id": 101,
"name": "Enter the Chambers of Xeric",
"description": "Enter the Chambers of Xeric raid",
"points": 100,
"difficulty": "Hard",
"category": "Combat",
"regions": ["Kourend"],
"skillRequirements": [],
"autoCompletes": [102],
"prerequisites": []
},
{
"id": 102,
"name": "Complete Any Raid",
"description": "Complete any raid in the game",
"points": 50,
"difficulty": "Medium",
"category": "Combat",
"regions": ["Global"],
"skillRequirements": [],
"autoCompletes": [],
"prerequisites": []
},
{
"id": 103,
"name": "Complete Theatre of Blood",
"description": "Complete the Theatre of Blood raid",
"points": 250,
"difficulty": "Elite",
"category": "Combat",
"regions": ["Morytania"],
"skillRequirements": [],
"autoCompletes": [102],
"prerequisites": []
},
{
"id": 200,
"name": "Catch a Shrimp",
"description": "Catch raw shrimp while fishing",
"points": 10,
"difficulty": "Easy",
"category": "Skilling",
"regions": ["Global"],
"skillRequirements": [],
"autoCompletes": [],
"prerequisites": []
},
{
"id": 201,
"name": "Catch a Lobster",
"description": "Catch a raw lobster while fishing",
"points": 25,
"difficulty": "Easy",
"category": "Skilling",
"regions": ["Global"],
"skillRequirements": [
{"skill": "Fishing", "level": 40}
],
"autoCompletes": [200],
"prerequisites": []
},
{
"id": 202,
"name": "Catch a Shark",
"description": "Catch a raw shark while fishing",
"points": 50,
"difficulty": "Medium",
"category": "Skilling",
"regions": ["Global"],
"skillRequirements": [
{"skill": "Fishing", "level": 76}
],
"autoCompletes": [200, 201],
"prerequisites": []
},
{
"id": 300,
"name": "Enter TzHaar City",
"description": "Enter the TzHaar City beneath Karamja",
"points": 25,
"difficulty": "Easy",
"category": "Exploration",
"regions": ["Karamja"],
"skillRequirements": [],
"autoCompletes": [],
"prerequisites": []
},
{
"id": 301,
"name": "Complete the Fight Caves",
"description": "Defeat TzTok-Jad and obtain a Fire Cape",
"points": 250,
"difficulty": "Elite",
"category": "Combat",
"regions": ["Karamja"],
"skillRequirements": [],
"autoCompletes": [300],
"prerequisites": []
},
{
"id": 302,
"name": "Complete the Inferno",
"description": "Defeat TzKal-Zuk and obtain an Infernal Cape",
"points": 500,
"difficulty": "Master",
"category": "Combat",
"regions": ["Karamja"],
"skillRequirements": [],
"autoCompletes": [300, 301],
"prerequisites": [301]
},
{
"id": 400,
"name": "Visit Prifddinas",
"description": "Enter the elven city of Prifddinas",
"points": 100,
"difficulty": "Hard",
"category": "Exploration",
"regions": ["Tirannwn"],
"skillRequirements": [],
"autoCompletes": [],
"prerequisites": []
},
{
"id": 401,
"name": "Complete The Gauntlet",
"description": "Complete The Gauntlet in Prifddinas",
"points": 150,
"difficulty": "Hard",
"category": "Combat",
"regions": ["Tirannwn"],
"skillRequirements": [],
"autoCompletes": [400],
"prerequisites": [400]
},
{
"id": 402,
"name": "Complete The Corrupted Gauntlet",
"description": "Complete The Corrupted Gauntlet in Prifddinas",
"points": 250,
"difficulty": "Elite",
"category": "Combat",
"regions": ["Tirannwn"],
"skillRequirements": [],
"autoCompletes": [400, 401],
"prerequisites": [401]
}
]
}

View File

@@ -79,16 +79,31 @@ export default function Homepage() {
<p className='text-3xl small-caps ml-1 mt-6'>Updates</p>
{newsPosts.map(newsPost => (
<NewsCard
key={newsPost.title}
title={newsPost.title}
date={newsPost.date}
coverImg={newsPost.thumbnail}
leadText={newsPost.leadText}
htmlContent={newsPost.htmlContent}
/>
))}
{newsPosts.map((newsPost, index) =>
newsPost.type === 'separator' ? (
<div key={`separator-${index}`} className='my-10 mx-auto max-w-5xl'>
<div className='flex items-center gap-4'>
<div className='flex-1 h-px bg-gray-600' />
<div className='text-center'>
<p className='text-xl font-semibold text-gray-400'>{newsPost.title}</p>
{newsPost.subtitle && (
<p className='text-sm text-gray-500 italic'>{newsPost.subtitle}</p>
)}
</div>
<div className='flex-1 h-px bg-gray-600' />
</div>
</div>
) : (
<NewsCard
key={newsPost.title}
title={newsPost.title}
date={newsPost.date}
coverImg={newsPost.thumbnail}
leadText={newsPost.leadText}
htmlContent={newsPost.htmlContent}
/>
)
)}
</PageWrapper>
);
}

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

View File

@@ -2660,9 +2660,15 @@ select[multiple]:focus option:checked {
.right-1 {
right: 0.25rem;
}
.right-4 {
right: 1rem;
}
.top-0 {
top: 0px;
}
.top-4 {
top: 1rem;
}
.z-10 {
z-index: 10;
}
@@ -2709,6 +2715,10 @@ select[multiple]:focus option:checked {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
.my-10 {
margin-top: 2.5rem;
margin-bottom: 2.5rem;
}
.my-2 {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
@@ -2882,6 +2892,9 @@ select[multiple]:focus option:checked {
.max-h-6 {
max-height: 1.5rem;
}
.max-h-96 {
max-height: 24rem;
}
.max-h-\[200px\] {
max-height: 200px;
}
@@ -2951,6 +2964,9 @@ select[multiple]:focus option:checked {
.w-px {
width: 1px;
}
.min-w-0 {
min-width: 0px;
}
.min-w-\[100px\] {
min-width: 100px;
}
@@ -3036,6 +3052,9 @@ select[multiple]:focus option:checked {
.animate-spin {
animation: spin 1s linear infinite;
}
.cursor-default {
cursor: default;
}
.cursor-pointer {
cursor: pointer;
}
@@ -3098,6 +3117,9 @@ select[multiple]:focus option:checked {
.items-stretch {
align-items: stretch;
}
.justify-end {
justify-content: flex-end;
}
.justify-center {
justify-content: center;
}
@@ -3128,6 +3150,9 @@ select[multiple]:focus option:checked {
.gap-5 {
gap: 1.25rem;
}
.gap-6 {
gap: 1.5rem;
}
.gap-px {
gap: 1px;
}
@@ -3160,6 +3185,9 @@ select[multiple]:focus option:checked {
.overflow-hidden {
overflow: hidden;
}
.overflow-y-auto {
overflow-y: auto;
}
.overflow-y-scroll {
overflow-y: scroll;
}
@@ -3267,6 +3295,14 @@ select[multiple]:focus option:checked {
--tw-border-opacity: 1;
border-color: rgb(34 197 94 / var(--tw-border-opacity, 1));
}
.border-green-700 {
--tw-border-opacity: 1;
border-color: rgb(21 128 61 / var(--tw-border-opacity, 1));
}
.border-purple-700 {
--tw-border-opacity: 1;
border-color: rgb(126 34 206 / var(--tw-border-opacity, 1));
}
.border-red-500 {
--tw-border-opacity: 1;
border-color: rgb(239 68 68 / var(--tw-border-opacity, 1));
@@ -3275,6 +3311,13 @@ select[multiple]:focus option:checked {
--tw-bg-opacity: 1;
background-color: rgb(37 99 235 / var(--tw-bg-opacity, 1));
}
.bg-blue-900\/50 {
background-color: rgb(30 58 138 / 0.5);
}
.bg-gray-600 {
--tw-bg-opacity: 1;
background-color: rgb(75 85 99 / var(--tw-bg-opacity, 1));
}
.bg-gray-700 {
--tw-bg-opacity: 1;
background-color: rgb(55 65 81 / var(--tw-bg-opacity, 1));
@@ -3283,6 +3326,9 @@ select[multiple]:focus option:checked {
--tw-bg-opacity: 1;
background-color: rgb(31 41 55 / var(--tw-bg-opacity, 1));
}
.bg-gray-800\/50 {
background-color: rgb(31 41 55 / 0.5);
}
.bg-gray-900 {
--tw-bg-opacity: 1;
background-color: rgb(17 24 39 / var(--tw-bg-opacity, 1));
@@ -3291,10 +3337,20 @@ select[multiple]:focus option:checked {
--tw-bg-opacity: 1;
background-color: rgb(34 197 94 / var(--tw-bg-opacity, 1));
}
.bg-green-900\/30 {
background-color: rgb(20 83 45 / 0.3);
}
.bg-purple-900\/30 {
background-color: rgb(88 28 135 / 0.3);
}
.bg-red-500 {
--tw-bg-opacity: 1;
background-color: rgb(239 68 68 / var(--tw-bg-opacity, 1));
}
.bg-red-600 {
--tw-bg-opacity: 1;
background-color: rgb(220 38 38 / var(--tw-bg-opacity, 1));
}
.bg-red-900\/90 {
background-color: rgb(127 29 29 / 0.9);
}
@@ -3332,6 +3388,10 @@ select[multiple]:focus option:checked {
padding-left: 0.25rem;
padding-right: 0.25rem;
}
.px-1\.5 {
padding-left: 0.375rem;
padding-right: 0.375rem;
}
.px-2 {
padding-left: 0.5rem;
padding-right: 0.5rem;
@@ -3348,6 +3408,10 @@ select[multiple]:focus option:checked {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.py-0\.5 {
padding-top: 0.125rem;
padding-bottom: 0.125rem;
}
.py-1 {
padding-top: 0.25rem;
padding-bottom: 0.25rem;
@@ -3405,6 +3469,9 @@ select[multiple]:focus option:checked {
.text-center {
text-align: center;
}
.text-right {
text-align: right;
}
.indent-8 {
text-indent: 2rem;
}
@@ -3491,6 +3558,10 @@ select[multiple]:focus option:checked {
.tracking-widest {
letter-spacing: 0.1em;
}
.text-blue-400 {
--tw-text-opacity: 1;
color: rgb(96 165 250 / var(--tw-text-opacity, 1));
}
.text-gray-300 {
--tw-text-opacity: 1;
color: rgb(209 213 219 / var(--tw-text-opacity, 1));
@@ -3499,6 +3570,10 @@ select[multiple]:focus option:checked {
--tw-text-opacity: 1;
color: rgb(156 163 175 / var(--tw-text-opacity, 1));
}
.text-gray-500 {
--tw-text-opacity: 1;
color: rgb(107 114 128 / var(--tw-text-opacity, 1));
}
.text-gray-600 {
--tw-text-opacity: 1;
color: rgb(75 85 99 / var(--tw-text-opacity, 1));
@@ -3507,6 +3582,14 @@ select[multiple]:focus option:checked {
--tw-text-opacity: 1;
color: rgb(74 222 128 / var(--tw-text-opacity, 1));
}
.text-orange-400 {
--tw-text-opacity: 1;
color: rgb(251 146 60 / var(--tw-text-opacity, 1));
}
.text-purple-400 {
--tw-text-opacity: 1;
color: rgb(192 132 252 / var(--tw-text-opacity, 1));
}
.text-red-100 {
--tw-text-opacity: 1;
color: rgb(254 226 226 / var(--tw-text-opacity, 1));
@@ -3523,16 +3606,42 @@ select[multiple]:focus option:checked {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity, 1));
}
.text-yellow-400 {
--tw-text-opacity: 1;
color: rgb(250 204 21 / var(--tw-text-opacity, 1));
}
.text-yellow-500 {
--tw-text-opacity: 1;
color: rgb(234 179 8 / var(--tw-text-opacity, 1));
}
.underline {
text-decoration-line: underline;
}
.line-through {
text-decoration-line: line-through;
}
.placeholder-gray-400::placeholder {
--tw-placeholder-opacity: 1;
color: rgb(156 163 175 / var(--tw-placeholder-opacity, 1));
}
.opacity-100 {
opacity: 1;
}
.opacity-25 {
opacity: 0.25;
}
.opacity-40 {
opacity: 0.4;
}
.opacity-50 {
opacity: 0.5;
}
.opacity-60 {
opacity: 0.6;
}
.opacity-70 {
opacity: 0.7;
}
.opacity-75 {
opacity: 0.75;
}
@@ -3556,6 +3665,10 @@ select[multiple]:focus option:checked {
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
}
.grayscale {
--tw-grayscale: grayscale(100%);
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
}
.filter {
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
}
@@ -3564,11 +3677,22 @@ select[multiple]:focus option:checked {
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.transition-all {
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.transition-colors {
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.duration-200 {
transition-duration: 200ms;
}
.ease-in-out {
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
.odd\:bg-primary:nth-child(odd) {
--tw-bg-opacity: 1;
background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1));
@@ -3634,6 +3758,10 @@ select[multiple]:focus option:checked {
border-bottom-right-radius: 0.25rem;
border-bottom-left-radius: 0.25rem;
}
.hover\:border-gray-500:hover {
--tw-border-opacity: 1;
border-color: rgb(107 114 128 / var(--tw-border-opacity, 1));
}
.hover\:bg-blue-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(29 78 216 / var(--tw-bg-opacity, 1));
@@ -3642,6 +3770,10 @@ select[multiple]:focus option:checked {
--tw-bg-opacity: 1;
background-color: rgb(75 85 99 / var(--tw-bg-opacity, 1));
}
.hover\:bg-gray-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(55 65 81 / var(--tw-bg-opacity, 1));
}
.hover\:bg-gray-800:hover {
--tw-bg-opacity: 1;
background-color: rgb(31 41 55 / var(--tw-bg-opacity, 1));
@@ -3697,6 +3829,8 @@ select[multiple]:focus option:checked {
max-width: 75%;
}.md\:flex-shrink-0 {
flex-shrink: 0;
}.md\:grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}.md\:grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}.md\:flex-row {
@@ -3733,6 +3867,8 @@ select[multiple]:focus option:checked {
flex-basis: 50%;
}.lg\:basis-1\/4 {
flex-basis: 25%;
}.lg\:grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}.lg\:grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}.lg\:grid-cols-4 {