commit e33baa3633a94b4ee7c797a66b8bcacbf1c75c27 Author: arul Date: Wed Dec 10 22:17:08 2025 +0530 This is a clone of an solo leveling node app from an git repo diff --git a/README.md b/README.md new file mode 100644 index 0000000..2633c32 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# Solo Leveling - Self-Improvement App + +_A gamified self-improvement application inspired by the "Solo Leveling" manhwa/anime_ + +## Overview + +Solo Leveling - Self-Improvement App is a gamified self-improvement application that transforms personal development into an immersive RPG experience. Set in a dark fantasy world, users can create custom quests, engage in combat, earn rewards, and level up their character as they achieve real-life goals. + +The application uses a character-based progression system where completing tasks and defeating enemies rewards you with experience points, stat points, gold, and items, creating a rewarding feedback loop for self-improvement activities. + +## Screenshots + +_Main dashboard view_ +![Dashboard Screenshot](/public/screenshots/dashboard-screenshot.png) + +_Quests view_ +![Quests Screenshot](/public/screenshots/quests-screenshot.png) + +You can find more screenshots in the [screenshots directory](/public/screenshots/). + +## Main Features + +### Character Development + +- Build your character with 5 core stats (Strength, Vitality, Agility, Intelligence, Perception) +- Gain XP from quests and combat to level up +- Unlock titles and job classes as you progress + +### Quest Management + +- Create personalized quests tied to real-life goals +- Set quest difficulty (E to S rank) with corresponding rewards +- Earn XP, stat points, gold, and items for completing quests + +### AI Integration + +- AI-powered quest generation using OpenAI's GPT and Google's Gemini +- AI analyzes quest descriptions to assign appropriate difficulty and rewards +- Toggle between OpenAI and Gemini AI models + +### Combat System + +- Choose from various enemies with unique stats and rewards +- Engage in strategic turn-based battles +- Utilize skills and items during combat + +### Inventory & Equipment + +- Collect and manage items in your inventory +- Equip items to boost your stats +- Items with different rarity levels (Common to Legendary) + +## Tech Stack + +- **Frontend**: Next.js, React, TypeScript +- **UI**: Tailwind CSS, Shadcn/ui, Radix UI +- **State**: React Context API +- **AI**: OpenAI GPT-3.5 Turbo, Google Gemini 2.0 Flash + +## Getting Started + +1. Clone the repository +2. Install dependencies with `pnpm install` +3. Run the development server with `pnpm dev` +4. Open [http://localhost:3000](http://localhost:3000) in your browser + +## License + +This project is licensed under the MIT License. + +## Acknowledgments + +- Inspired by the "Solo Leveling" manhwa by Chugong +- Powered by OpenAI and Google Gemini AI models +- Deployed on Vercel diff --git a/app/combat/loading.tsx b/app/combat/loading.tsx new file mode 100644 index 0000000..f15322a --- /dev/null +++ b/app/combat/loading.tsx @@ -0,0 +1,3 @@ +export default function Loading() { + return null +} diff --git a/app/combat/page.tsx b/app/combat/page.tsx new file mode 100644 index 0000000..4ac4f71 --- /dev/null +++ b/app/combat/page.tsx @@ -0,0 +1,932 @@ +"use client"; + +import type React from "react"; + +import { useState, useEffect } from "react"; +import Link from "next/link"; +import { + ChevronLeft, + Sword, + Shield, + Zap, + Heart, + Brain, + Eye, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useToast } from "@/hooks/use-toast"; +import { useUser } from "@/context/user-context"; +import { enemies, type Enemy } from "@/data/enemies"; +import { CombatActions } from "@/components/combat-actions"; +import { EnemySelection } from "@/components/enemy-selection"; +import { CombatVisualization } from "@/components/combat-visualization"; + +export default function CombatPage() { + const { + userStats, + setUserStats, + addExp, + addItem, + addGold, + removeItem, + inCombat: contextInCombat, + setInCombat: setContextInCombat, + } = useUser(); + const { toast } = useToast(); + + // Combat states + const [selectedEnemy, setSelectedEnemy] = useState(null); + const [inCombat, setInCombat] = useState(false); + const [playerTurn, setPlayerTurn] = useState(true); + const [playerHp, setPlayerHp] = useState(userStats.hp); + const [playerMp, setPlayerMp] = useState(userStats.mp); + const [enemyHp, setEnemyHp] = useState(0); + const [enemyMaxHp, setEnemyMaxHp] = useState(0); + const [showRewards, setShowRewards] = useState(false); + const [rewards, setRewards] = useState<{ + exp: number; + gold: number; + items: any[]; + }>({ exp: 0, gold: 0, items: [] }); + const [isDefending, setIsDefending] = useState(false); + const [skillCooldowns, setSkillCooldowns] = useState>( + {} + ); + const [combatLog, setCombatLog] = useState([]); + + // Animation states + const [isAttacking, setIsAttacking] = useState(false); + const [isAnimating, setIsAnimating] = useState(false); + const [currentDamage, setCurrentDamage] = useState(0); + const [isCriticalHit, setIsCriticalHit] = useState(false); + const [currentSkill, setCurrentSkill] = useState( + undefined + ); + + // Reset combat state when component unmounts + useEffect(() => { + return () => { + if (inCombat) { + // Save player HP/MP state when leaving combat + setUserStats((prev) => ({ + ...prev, + hp: playerHp, + mp: playerMp, + })); + } + }; + }, [inCombat, playerHp, playerMp, setUserStats]); + + // Sync local combat state with context combat state + useEffect(() => { + setContextInCombat(inCombat); + }, [inCombat, setContextInCombat]); + + // Start combat with selected enemy + const startCombat = (enemy: Enemy) => { + setSelectedEnemy(enemy); + setEnemyHp(calculateEnemyHp(enemy)); + setEnemyMaxHp(calculateEnemyHp(enemy)); + setPlayerHp(userStats.hp); + setPlayerMp(userStats.mp); + setPlayerTurn(true); + setInCombat(true); + setIsDefending(false); + setSkillCooldowns({}); + setCombatLog([]); + }; + + // Calculate enemy HP based on level and vitality + const calculateEnemyHp = (enemy: Enemy) => { + return Math.floor(100 + enemy.level * 10 + enemy.stats.vit * 5); + }; + + // Calculate damage based on attacker's strength and defender's vitality + const calculateDamage = ( + attackerStr: number, + defenderVit: number, + isCritical = false + ) => { + const baseDamage = Math.max( + 1, + Math.floor(attackerStr * 1.5 - defenderVit * 0.5) + ); + const randomFactor = 0.8 + Math.random() * 0.4; // 80% to 120% damage variation + const criticalMultiplier = isCritical ? 1.5 : 1; + return Math.floor(baseDamage * randomFactor * criticalMultiplier); + }; + + // Check for critical hit based on agility + const checkCritical = (agility: number) => { + const critChance = agility * 0.5; // 0.5% per agility point + return Math.random() * 100 < critChance; + }; + + // Player attacks enemy + const playerAttack = () => { + if (!selectedEnemy || !inCombat || !playerTurn || isAnimating) return; + + const isCritical = checkCritical(userStats.stats.agi); + const damage = calculateDamage( + userStats.stats.str, + selectedEnemy.stats.vit, + isCritical + ); + + // Set current damage for visualization + setCurrentDamage(damage); + setIsCriticalHit(isCritical); + setCurrentSkill(undefined); + + setIsAnimating(true); + setIsAttacking(true); + addToCombatLog(`You attacked ${selectedEnemy.name} for ${damage} damage!`); + }; + + // Handle animation completion + const handleAnimationComplete = () => { + setIsAnimating(false); + setIsAttacking(false); + + if (!selectedEnemy || !inCombat) return; + + if (playerTurn) { + // Player attack logic after animation + const newEnemyHp = Math.max(0, enemyHp - currentDamage); + setEnemyHp(newEnemyHp); + + if (newEnemyHp <= 0) { + endCombat(true); + return; // Important: Stop execution here to prevent enemy turn + } else { + setPlayerTurn(false); + // Enemy's turn after a shorter delay + setTimeout(() => enemyTurn(), 300); // Reduced from 1000ms to 300ms + } + } else { + // Enemy attack logic after animation + if (isDefending) { + setIsDefending(false); + } + + // Check if player is defeated + if (playerHp <= 0) { + endCombat(false); + return; // Important: Stop execution here + } + + setPlayerTurn(true); + } + }; + + // Player defends to reduce incoming damage + const playerDefend = () => { + if (!inCombat || !playerTurn || isAnimating) return; + + setIsAnimating(true); + setIsDefending(true); + addToCombatLog("You defended!"); + + setTimeout(() => { + setIsAnimating(false); + setPlayerTurn(false); + // Enemy's turn after a shorter delay + setTimeout(() => enemyTurn(), 300); // Reduced from 500ms to 300ms + }, 600); // Reduced from 1000ms to 600ms + }; + + // Player uses a skill + const playerUseSkill = ( + skillName: string, + mpCost: number, + cooldown: number + ) => { + if (!selectedEnemy || !inCombat || !playerTurn || isAnimating) return; + + if (playerMp < mpCost) { + toast({ + title: "Not enough MP", + description: `You need ${mpCost} MP to use this skill.`, + variant: "destructive", + }); + return; + } + + if (skillCooldowns[skillName] && skillCooldowns[skillName] > 0) { + toast({ + title: "Skill on cooldown", + description: `This skill will be available in ${skillCooldowns[skillName]} turns.`, + variant: "destructive", + }); + return; + } + + // Reduce MP + setPlayerMp((prev) => prev - mpCost); + + // Set cooldown + setSkillCooldowns((prev) => ({ + ...prev, + [skillName]: cooldown, + })); + + let damage = 0; + + // Different skills have different effects + switch (skillName) { + case "Power Strike": + damage = calculateDamage( + userStats.stats.str * 2, + selectedEnemy.stats.vit + ); + break; + case "Double Slash": + const hit1 = calculateDamage( + userStats.stats.str * 0.7, + selectedEnemy.stats.vit + ); + const hit2 = calculateDamage( + userStats.stats.str * 0.7, + selectedEnemy.stats.vit + ); + damage = hit1 + hit2; + break; + case "Fireball": + damage = calculateDamage( + userStats.stats.int * 2, + selectedEnemy.stats.vit + ); + break; + case "Heal": + const healAmount = Math.floor(userStats.stats.int * 1.5); + setPlayerHp((prev) => Math.min(userStats.maxHp, prev + healAmount)); + damage = 0; + break; + default: + damage = 0; + } + + // Set current damage and skill for visualization + setCurrentDamage(damage); + setIsCriticalHit(false); + setCurrentSkill(skillName); + + setIsAnimating(true); + setIsAttacking(true); + addToCombatLog(`You used ${skillName} for ${damage} damage!`); + }; + + // Player uses an item + const playerUseItem = (itemId: string) => { + if (!inCombat || !playerTurn || isAnimating) return; + + const item = userStats.inventory.find((i) => i.id === itemId); + + if (!item) { + toast({ + title: "Item not found", + description: "The selected item could not be found in your inventory.", + variant: "destructive", + }); + return; + } + + let effectApplied = false; + + // Apply item effects based on item type and id + if (item.type === "Consumable") { + // First check for specific item IDs for built-in items + if (item.id === "item-health-potion") { + const healAmount = 100; + setPlayerHp((prev) => Math.min(userStats.maxHp, prev + healAmount)); + toast({ + title: "Health Restored", + description: `You used a Health Potion and restored ${healAmount} HP.`, + }); + effectApplied = true; + } else if (item.id === "item-mana-potion") { + const manaAmount = 50; + setPlayerMp((prev) => Math.min(userStats.maxMp, prev + manaAmount)); + toast({ + title: "Mana Restored", + description: `You used a Mana Potion and restored ${manaAmount} MP.`, + }); + effectApplied = true; + } else if (item.id === "item-greater-health-potion") { + const healAmount = 200; + setPlayerHp((prev) => Math.min(userStats.maxHp, prev + healAmount)); + toast({ + title: "Health Restored", + description: `You used a Greater Health Potion and restored ${healAmount} HP.`, + }); + effectApplied = true; + } else if (item.id === "item-greater-mana-potion") { + const manaAmount = 100; + setPlayerMp((prev) => Math.min(userStats.maxMp, prev + manaAmount)); + toast({ + title: "Mana Restored", + description: `You used a Greater Mana Potion and restored ${manaAmount} MP.`, + }); + effectApplied = true; + } else if (item.id === "item-healing-elixir") { + const healAmount = 350; + setPlayerHp((prev) => Math.min(userStats.maxHp, prev + healAmount)); + toast({ + title: "Health Restored", + description: `You used a Healing Elixir and restored ${healAmount} HP.`, + }); + effectApplied = true; + } else if (item.id === "item-mana-elixir") { + const manaAmount = 175; + setPlayerMp((prev) => Math.min(userStats.maxMp, prev + manaAmount)); + toast({ + title: "Mana Restored", + description: `You used a Mana Elixir and restored ${manaAmount} MP.`, + }); + effectApplied = true; + } + // For other consumables, check name patterns + else if ( + item.name.toLowerCase().includes("health") || + item.name.toLowerCase().includes("healing") || + item.name.toLowerCase().includes("hp") + ) { + // Default healing amount (can be customized based on rarity) + let healAmount = 50; + if (item.rarity === "Uncommon") healAmount = 100; + if (item.rarity === "Rare") healAmount = 200; + if (item.rarity === "Epic") healAmount = 350; + if (item.rarity === "Legendary") healAmount = 500; + + setPlayerHp((prev) => Math.min(userStats.maxHp, prev + healAmount)); + toast({ + title: "Health Restored", + description: `You used ${item.name} and restored ${healAmount} HP.`, + }); + effectApplied = true; + } else if ( + item.name.toLowerCase().includes("mana") || + item.name.toLowerCase().includes("mp") + ) { + // Default mana restoration amount + let manaAmount = 25; + if (item.rarity === "Uncommon") manaAmount = 50; + if (item.rarity === "Rare") manaAmount = 100; + if (item.rarity === "Epic") manaAmount = 175; + if (item.rarity === "Legendary") manaAmount = 250; + + setPlayerMp((prev) => Math.min(userStats.maxMp, prev + manaAmount)); + toast({ + title: "Mana Restored", + description: `You used ${item.name} and restored ${manaAmount} MP.`, + }); + effectApplied = true; + } else { + toast({ + title: "Item Effect Unknown", + description: "This item's effect is not implemented yet.", + variant: "destructive", + }); + } + } else { + toast({ + title: "Cannot Use Item", + description: "Only consumable items can be used in combat.", + variant: "destructive", + }); + return; + } + + if (effectApplied) { + // Remove the item from inventory + removeItem(itemId, 1); + + // End player's turn + setPlayerTurn(false); + setTimeout(() => enemyTurn(), 300); // Reduced from 1000ms to 300ms + } + }; + + // Enemy takes their turn + const enemyTurn = () => { + if (!selectedEnemy || !inCombat) return; + + // Reduce cooldowns + setSkillCooldowns((prev) => { + const newCooldowns = { ...prev }; + Object.keys(newCooldowns).forEach((skill) => { + if (newCooldowns[skill] > 0) { + newCooldowns[skill] -= 1; + } + }); + return newCooldowns; + }); + + // Enemy decides what to do (for now, just basic attack) + const damage = calculateDamage( + selectedEnemy.stats.str, + userStats.stats.vit + ); + + // Apply defense reduction if player is defending + const actualDamage = isDefending ? Math.floor(damage * 0.5) : damage; + + // Set current damage for visualization + setCurrentDamage(actualDamage); + setIsCriticalHit(false); + setCurrentSkill(undefined); + + setIsAnimating(true); + setIsAttacking(true); + + // Update player HP + const newPlayerHp = Math.max(0, playerHp - actualDamage); + setPlayerHp(newPlayerHp); + addToCombatLog( + `${selectedEnemy.name} attacked you for ${actualDamage} damage!` + ); + + // Check if player is defeated - this will be handled in handleAnimationComplete + }; + + // Add message to combat log + const addToCombatLog = (message: string) => { + setCombatLog((prev) => [...prev, message]); + }; + + // End combat and determine rewards or penalties + const endCombat = (victory: boolean) => { + if (!selectedEnemy) return; + + if (victory) { + addToCombatLog(`You have defeated ${selectedEnemy.name}!`); + + // Set rewards + const combatRewards = { + exp: selectedEnemy.rewards.exp, + gold: selectedEnemy.rewards.gold, + items: selectedEnemy.rewards.items, + }; + + setRewards(combatRewards); + setShowRewards(true); + + toast({ + title: "Victory!", + description: `You have defeated ${selectedEnemy.name}!`, + }); + } else { + addToCombatLog(`You have been defeated by ${selectedEnemy.name}.`); + + // Apply defeat penalties (lose some gold, etc.) + const goldLoss = Math.floor(userStats.gold * 0.1); // Lose 10% of gold + + setUserStats((prev) => ({ + ...prev, + hp: Math.max(1, Math.floor(prev.maxHp * 0.1)), // Restore to 10% HP + gold: Math.max(0, prev.gold - goldLoss), + })); + + addToCombatLog( + `You lost ${goldLoss} gold and barely escaped with your life.` + ); + } + + setInCombat(false); + setIsAnimating(false); + setIsAttacking(false); + }; + + // Claim rewards after victory + const claimRewards = () => { + if (!selectedEnemy) return; + + // Add experience + addExp(rewards.exp); + + // Add gold + addGold(rewards.gold); + + // Add items to inventory + rewards.items.forEach((item) => { + addItem(item); + }); + + // Show toast notification + toast({ + title: "Rewards Claimed", + description: `You gained ${rewards.exp} EXP, ${rewards.gold} Gold, and ${rewards.items.length} items.`, + }); + + // Reset combat + setShowRewards(false); + setSelectedEnemy(null); + }; + + // Skip turn (for testing) + const skipTurn = () => { + if (!inCombat) return; + + if (playerTurn) { + setPlayerTurn(false); + setTimeout(() => enemyTurn(), 500); + } else { + setPlayerTurn(true); + } + }; + + // Flee from combat + const fleeCombat = () => { + if (!inCombat || !selectedEnemy || isAnimating) return; + + // 50% chance to successfully flee based on agility difference + const fleeChance = 50 + (userStats.stats.agi - selectedEnemy.stats.agi) * 2; + + if (Math.random() * 100 < fleeChance) { + toast({ + title: "Escaped", + description: "You successfully fled from combat!", + }); + setInCombat(false); + setSelectedEnemy(null); + } else { + toast({ + title: "Failed to Escape", + description: "You failed to flee!", + }); + setPlayerTurn(false); + setTimeout(() => enemyTurn(), 300); // Reduced from 1000ms to 300ms + } + }; + + return ( +
+
+ {/* Header */} +
+
+ + + +

+ Combat Arena +

+
+
+
+ Gold: + + {userStats.gold} + +
+
+
+ + {/* Recovery notification card */} +
+ +
+ +
+ +
+

+ Automatic Recovery System +

+

+ Your character naturally recovers 10% of maximum HP and MP + every 5 minutes when not in combat. This recovery continues + even when you're offline, allowing you to return stronger + after a break. +

+
+
+
+
+
+ + {/* Main Combat Area */} +
+ {/* Left Column - Combat Area */} +
+ {!selectedEnemy && !inCombat ? ( + + ) : ( + <> + {/* Combat Visualization */} + {inCombat && selectedEnemy && ( + + )} + + {!inCombat && selectedEnemy && ( + +
+ +
+ + {selectedEnemy.name} + + + Level {selectedEnemy.level} + +
+ + {selectedEnemy.description} + +
+ + {/* Enemy Stats */} +
+
+ + STR + {selectedEnemy.stats.str} +
+
+ + VIT + {selectedEnemy.stats.vit} +
+
+ + AGI + {selectedEnemy.stats.agi} +
+
+ + INT + {selectedEnemy.stats.int} +
+
+ + PER + {selectedEnemy.stats.per} +
+
+
+ {!inCombat && !showRewards && ( + + + + )} + {showRewards && ( + + + + )} +
+ )} + + )} +
+ + {/* Right Column - Player Stats (hidden during combat) */} + {!inCombat && ( +
+ +
+ + Your Stats + + Level {userStats.level} Hunter + + + + {/* HP/MP Bars */} +
+
+
+ + + HP + + + {playerHp}/{userStats.maxHp} + +
+ +
+ +
+ +
+
+ + + MP + + + {playerMp}/{userStats.maxMp} + +
+ +
+ +
+
+ + + + {/* Stats */} +
+
+ + STR: + {userStats.stats.str} +
+
+ + VIT: + {userStats.stats.vit} +
+
+ + AGI: + {userStats.stats.agi} +
+
+ + INT: + {userStats.stats.int} +
+
+ + PER: + {userStats.stats.per} +
+
+ + +
+ )} +
+ + {/* Combat Actions */} + {inCombat && playerTurn && ( +
+ +
+ )} + + {/* Rewards Dialog */} + + + + + Victory Rewards + + + You have defeated {selectedEnemy?.name}! + + +
+
+
+ Experience: + + {rewards.exp} EXP + +
+
+ Gold: + + {rewards.gold} Gold + +
+ +
+

Items:

+
+ {rewards.items.map((item, index) => ( +
+
+
+ {item.name} +
+ + {item.type} + +
+ ))} +
+
+
+
+ + + +
+
+
+
+ ); +} diff --git a/app/equipment/page.tsx b/app/equipment/page.tsx new file mode 100644 index 0000000..0625984 --- /dev/null +++ b/app/equipment/page.tsx @@ -0,0 +1,457 @@ +"use client" + +import type React from "react" +import Link from "next/link" +import { ChevronLeft, Shield, Zap, Eye, Brain, Heart } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Separator } from "@/components/ui/separator" +import { useUser } from "@/context/user-context" + +export default function EquipmentPage() { + const { userStats } = useUser() + + // Sample equipment data - in a real app, this would come from the user context + const equippedItems = [ + { + id: "e1", + name: "Shadow Monarch's Helmet", + rarity: "Legendary" as const, + stats: ["+15% Intelligence", "+10% Perception"], + setBonus: "Shadow Monarch Set (2/5)", + slot: "Head", + equipped: true, + }, + { + id: "e2", + name: "Gauntlets of Strength", + rarity: "Epic" as const, + stats: ["+12 Strength", "+8% Critical Rate"], + setBonus: "Warrior's Set (1/5)", + slot: "Hands", + equipped: true, + }, + { + id: "e3", + name: "Boots of Agility", + rarity: "Rare" as const, + stats: ["+10 Agility", "+5% Movement Speed"], + setBonus: "None", + slot: "Feet", + equipped: true, + }, + { + id: "e4", + name: "Shadow Monarch's Armor", + rarity: "Legendary" as const, + stats: ["+20 Defense", "+15% Vitality"], + setBonus: "Shadow Monarch Set (2/5)", + slot: "Chest", + equipped: true, + }, + { + id: "e5", + name: "Empty Slot", + rarity: "Common" as const, + stats: [], + setBonus: "None", + slot: "Accessory", + equipped: false, + }, + { + id: "e6", + name: "Empty Slot", + rarity: "Common" as const, + stats: [], + setBonus: "None", + slot: "Weapon", + equipped: false, + }, + ] + + const inventoryItems = [ + { + id: "i1", + name: "Amulet of Wisdom", + rarity: "Epic" as const, + stats: ["+15 Intelligence", "+10% Mana Regeneration"], + setBonus: "Scholar's Set (1/3)", + slot: "Accessory", + }, + { + id: "i2", + name: "Dagger of Precision", + rarity: "Rare" as const, + stats: ["+8 Perception", "+12% Critical Damage"], + setBonus: "Assassin's Set (1/4)", + slot: "Weapon", + }, + { + id: "i3", + name: "Bracers of Vitality", + rarity: "Uncommon" as const, + stats: ["+6 Vitality", "+5% Health Regeneration"], + setBonus: "None", + slot: "Hands", + }, + { + id: "i4", + name: "Shadow Monarch's Greaves", + rarity: "Legendary" as const, + stats: ["+12 Agility", "+15% Movement Speed"], + setBonus: "Shadow Monarch Set (1/5)", + slot: "Feet", + }, + ] + + // Calculate stat bonuses from equipment + const calculateStatBonuses = () => { + // In a real app, this would parse the equipment stats and calculate actual bonuses + return { + str: 18, + agi: 10, + per: 10, + int: 10, + vit: 10, + } + } + + const statBonuses = calculateStatBonuses() + + return ( +
+
+ {/* Header */} +
+ + + +

Equipment

+
+ + {/* Equipment Stats */} +
+
+
+
+
+
+
+ +
+

Equipment Stats

+
+ } + name="STR" + baseValue={userStats.stats.str} + bonusValue={statBonuses.str} + /> + } + name="AGI" + baseValue={userStats.stats.agi} + bonusValue={statBonuses.agi} + /> + } + name="PER" + baseValue={userStats.stats.per} + bonusValue={statBonuses.per} + /> + } + name="INT" + baseValue={userStats.stats.int} + bonusValue={statBonuses.int} + /> + } + name="VIT" + baseValue={userStats.stats.vit} + bonusValue={statBonuses.vit} + /> +
+
+
+ + {/* Equipment Slots */} +
+ + + + Equipped + + + Inventory + + + +
+ {equippedItems.map((item) => ( + + ))} +
+
+ +
+ {inventoryItems.map((item) => ( + + ))} +
+
+
+
+ + {/* Set Bonuses */} +
+

Set Bonuses

+
+ +
+ + Shadow Monarch Set (2/5) + Legendary Set + + +
+
+
+ (2) Set: +15% Intelligence, +10% Perception +
+
+
+ (3) Set: +20% Mana Regeneration +
+
+
+ (4) Set: +25% Skill Damage +
+
+
+ (5) Set: Unlock Shadow Extraction Ability +
+
+
+
+ + +
+ + Warrior's Set (1/5) + Epic Set + + +
+
+
+ (2) Set: +15% Strength, +10% Vitality +
+
+
+ (3) Set: +20% Physical Damage +
+
+
+ (4) Set: +25% Defense +
+
+
+ (5) Set: Unlock Berserker Rage Ability +
+
+
+
+
+
+
+
+ ) +} + +function StatBonus({ + icon, + name, + baseValue, + bonusValue, +}: { + icon: React.ReactNode + name: string + baseValue: number + bonusValue: number +}) { + return ( +
+
{icon}
+
{name}
+
+ {baseValue} +{bonusValue} +
+
+ ) +} + +function EquipmentSlotCard({ + name, + rarity, + stats, + setBonus, + slot, + equipped, +}: { + name: string + rarity: "Common" | "Uncommon" | "Rare" | "Epic" | "Legendary" + stats: string[] + setBonus: string + slot: string + equipped: boolean +}) { + const rarityColors = { + Common: "text-gray-400", + Uncommon: "text-green-400", + Rare: "text-[#4cc9ff]", + Epic: "text-purple-400", + Legendary: "text-yellow-400", + } + + return ( + +
+
+ +
+ {name} + {slot} +
+ {rarity} +
+ +
+ {stats.length > 0 ? ( + stats.map((stat, index) => ( +
+ {stat} +
+ )) + ) : ( +
No item equipped
+ )} + {stats.length > 0 && ( + <> + +
{setBonus}
+ + )} +
+
+ {equipped && ( + + + + )} +
+ ) +} + +function EquipmentCard({ + name, + rarity, + stats, + setBonus, + slot, +}: { + name: string + rarity: "Common" | "Uncommon" | "Rare" | "Epic" | "Legendary" + stats: string[] + setBonus: string + slot: string +}) { + const rarityColors = { + Common: "text-gray-400", + Uncommon: "text-green-400", + Rare: "text-[#4cc9ff]", + Epic: "text-purple-400", + Legendary: "text-yellow-400", + } + + return ( + +
+
+ +
+ {name} + {slot} +
+ {rarity} +
+ +
+ {stats.map((stat, index) => ( +
+ {stat} +
+ ))} + +
{setBonus}
+
+
+ + + +
+ ) +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..c6b23f6 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,116 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + + --radius: 0.5rem; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} + +/* Solo Leveling Animation */ +@keyframes soloModalExpand { + 0% { + transform: scale(0); + opacity: 0; + } + 50% { + transform: scale(1, 0.1); + opacity: 1; + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +.animate-solo-modal { + animation: soloModalExpand var(--solo-expand-duration, 0.5s) + var(--solo-expand-easing, cubic-bezier(0.16, 1, 0.3, 1)) forwards; + transform-origin: center; +} + +/* Fade in animation for combat messages */ +@keyframes fadeIn { + 0% { + opacity: 0; + transform: translateY(-10px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + +.animate-fadeIn { + animation: fadeIn 0.3s ease-out forwards; +} diff --git a/app/inventory/loading.tsx b/app/inventory/loading.tsx new file mode 100644 index 0000000..f15322a --- /dev/null +++ b/app/inventory/loading.tsx @@ -0,0 +1,3 @@ +export default function Loading() { + return null +} diff --git a/app/inventory/page.tsx b/app/inventory/page.tsx new file mode 100644 index 0000000..8d43f50 --- /dev/null +++ b/app/inventory/page.tsx @@ -0,0 +1,483 @@ +"use client" + +import { useState, useCallback, useRef } from "react" +import Link from "next/link" +import { ChevronLeft, Search, Info } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Badge } from "@/components/ui/badge" +import { Separator } from "@/components/ui/separator" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { useUser } from "@/context/user-context" +import type { InventoryItem } from "@/data/enemies" + +export default function InventoryPage() { + const { userStats, useItem, removeItem } = useUser() + const [searchTerm, setSearchTerm] = useState("") + const [selectedItem, setSelectedItem] = useState(null) + const [itemDetailsOpen, setItemDetailsOpen] = useState(false) + const [itemToUse, setItemToUse] = useState(null) + const itemToUseRef = useRef(null) + + // Filter items based on search term and type + const filterItems = (items: InventoryItem[], type?: string) => { + return items.filter( + (item) => + (type ? item.type === type : true) && + (item.name.toLowerCase().includes(searchTerm.toLowerCase()) || + item.description.toLowerCase().includes(searchTerm.toLowerCase())), + ) + } + + // Group items by type + const materials = filterItems(userStats.inventory, "Material") + const consumables = filterItems(userStats.inventory, "Consumable") + const equipment = filterItems(userStats.inventory, ["Weapon", "Armor", "Accessory", "Rune"] as unknown as string) + const allItems = filterItems(userStats.inventory) + + // Handle item use + const handleUseItem = useCallback(() => { + if (itemToUseRef.current) { + useItem(itemToUseRef.current.id) + setItemDetailsOpen(false) + itemToUseRef.current = null + } + }, [useItem]) + + // Get rarity color + const getRarityColor = (rarity: string) => { + switch (rarity) { + case "Common": + return "text-gray-400" + case "Uncommon": + return "text-green-400" + case "Rare": + return "text-[#4cc9ff]" + case "Epic": + return "text-purple-400" + case "Legendary": + return "text-yellow-400" + default: + return "text-gray-400" + } + } + + // Get type color + const getTypeColor = (type: string) => { + switch (type) { + case "Material": + return "bg-amber-900 text-amber-200" + case "Weapon": + return "bg-red-900 text-red-200" + case "Armor": + return "bg-blue-900 text-blue-200" + case "Accessory": + return "bg-purple-900 text-purple-200" + case "Consumable": + return "bg-green-900 text-green-200" + case "Rune": + return "bg-indigo-900 text-indigo-200" + case "Quest": + return "bg-yellow-900 text-yellow-200" + default: + return "bg-gray-900 text-gray-200" + } + } + + return ( +
+
+ {/* Header */} +
+
+ + + +

Inventory

+
+
+
+ Gold: + {userStats.gold} +
+
+
+ + {/* Search and Filter */} +
+
+
+
+ + setSearchTerm(e.target.value)} + /> +
+
+
+ + {/* Inventory Tabs */} + + + + All + + + Materials + + + Consumables + + + Equipment + + + + {/* All Items */} + +
+ {allItems.length > 0 ? ( + allItems.map((item) => ( + { + setSelectedItem(item) + setItemDetailsOpen(true) + }} + /> + )) + ) : ( +
+ {searchTerm ? "No items match your search." : "Your inventory is empty."} +
+ )} +
+
+ + {/* Materials */} + +
+ {materials.length > 0 ? ( + materials.map((item) => ( + { + setSelectedItem(item) + setItemDetailsOpen(true) + }} + /> + )) + ) : ( +
+ {searchTerm ? "No materials match your search." : "You don't have any materials."} +
+ )} +
+
+ + {/* Consumables */} + +
+ {consumables.length > 0 ? ( + consumables.map((item) => ( + { + setSelectedItem(item) + setItemDetailsOpen(true) + }} + /> + )) + ) : ( +
+ {searchTerm ? "No consumables match your search." : "You don't have any consumables."} +
+ )} +
+
+ + {/* Equipment */} + +
+ {equipment.length > 0 ? ( + equipment.map((item) => ( + { + setSelectedItem(item) + setItemDetailsOpen(true) + }} + /> + )) + ) : ( +
+ {searchTerm ? "No equipment matches your search." : "You don't have any equipment."} +
+ )} +
+
+
+ + {/* Item Details Dialog */} + + {selectedItem && ( + + + {selectedItem.name} + + {selectedItem.type} + {selectedItem.rarity} + + +
+

{selectedItem.description}

+ + {selectedItem.stats && Object.keys(selectedItem.stats).length > 0 && ( + <> + +
+

Stats:

+
    + {selectedItem.stats.str && ( +
  • + Strength + +{selectedItem.stats.str} +
  • + )} + {selectedItem.stats.agi && ( +
  • + Agility + +{selectedItem.stats.agi} +
  • + )} + {selectedItem.stats.per && ( +
  • + Perception + +{selectedItem.stats.per} +
  • + )} + {selectedItem.stats.int && ( +
  • + Intelligence + +{selectedItem.stats.int} +
  • + )} + {selectedItem.stats.vit && ( +
  • + Vitality + +{selectedItem.stats.vit} +
  • + )} + {selectedItem.stats.resistance && Object.keys(selectedItem.stats.resistance).length > 0 && ( + <> +
  • Resistances:
  • + {selectedItem.stats.resistance.fire && ( +
  • + Fire + +{selectedItem.stats.resistance.fire} +
  • + )} + {selectedItem.stats.resistance.ice && ( +
  • + Ice + +{selectedItem.stats.resistance.ice} +
  • + )} + {selectedItem.stats.resistance.lightning && ( +
  • + Lightning + +{selectedItem.stats.resistance.lightning} +
  • + )} + {selectedItem.stats.resistance.poison && ( +
  • + Poison + +{selectedItem.stats.resistance.poison} +
  • + )} + {selectedItem.stats.resistance.dark && ( +
  • + Dark + +{selectedItem.stats.resistance.dark} +
  • + )} + + )} +
+
+ + )} + + {selectedItem.quantity && ( +
+ Quantity: + {selectedItem.quantity} +
+ )} +
+ + {selectedItem.type === "Consumable" && ( + + )} + {(selectedItem.type === "Weapon" || + selectedItem.type === "Armor" || + selectedItem.type === "Accessory") && ( + + )} + + +
+ )} +
+
+
+ ) +} + +function InventoryItemCard({ + item, + onSelect, +}: { + item: InventoryItem + onSelect: () => void +}) { + // Get rarity color + const getRarityColor = (rarity: string) => { + switch (rarity) { + case "Common": + return "text-gray-400 border-gray-700" + case "Uncommon": + return "text-green-400 border-green-900" + case "Rare": + return "text-[#4cc9ff] border-[#4cc9ff]/30" + case "Epic": + return "text-purple-400 border-purple-900" + case "Legendary": + return "text-yellow-400 border-yellow-900" + default: + return "text-gray-400 border-gray-700" + } + } + + // Get type color + const getTypeColor = (type: string) => { + switch (type) { + case "Material": + return "bg-amber-900 text-amber-200" + case "Weapon": + return "bg-red-900 text-red-200" + case "Armor": + return "bg-blue-900 text-blue-200" + case "Accessory": + return "bg-purple-900 text-purple-200" + case "Consumable": + return "bg-green-900 text-green-200" + case "Rune": + return "bg-indigo-900 text-indigo-200" + case "Quest": + return "bg-yellow-900 text-yellow-200" + default: + return "bg-gray-900 text-gray-200" + } + } + + return ( + +
+
+ +
+ {item.name} + {item.quantity && x{item.quantity}} +
+
+ {item.type} +
+
+ +

{item.description}

+ + {item.stats && + Object.keys(item.stats).some((key) => key !== "resistance" && item.stats[key as keyof typeof item.stats]) && ( +
+ {item.stats.str &&
+{item.stats.str} STR
} + {item.stats.agi &&
+{item.stats.agi} AGI
} + {item.stats.per &&
+{item.stats.per} PER
} + {item.stats.int &&
+{item.stats.int} INT
} + {item.stats.vit &&
+{item.stats.vit} VIT
} +
+ )} +
+ + + +
+ ) +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..2247f1a --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,42 @@ +import type React from "react"; +import "@/app/globals.css"; +import { ThemeProvider } from "@/components/theme-provider"; +import { UserProvider } from "@/context/user-context"; +import { MobileNav } from "@/components/mobile-nav"; +import { LevelUpNotification } from "@/components/level-up-notification"; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + Solo Level Up - Self-Improvement App + + + + + + {children} + + + + + + + ); +} + +export const metadata = { + generator: "v0.dev", +}; diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..cf77174 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,540 @@ +"use client"; + +import type React from "react"; +import Link from "next/link"; +import { Shield, Zap, Eye, Brain, Heart, Menu, X, Award } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; +import { useUser } from "@/context/user-context"; +// Import the NameInputModal component +import { MobileNav } from "@/components/mobile-nav"; +import { NameInputModal } from "@/components/name-input-modal"; + +export default function Dashboard() { + const { userStats, addExp, completeQuest } = useUser(); + + // Calculate progress percentage for XP bar + const expPercentage = (userStats.exp / userStats.expToNextLevel) * 100; + + return ( +
+ {/* Add the NameInputModal component */} + + +
+ {/* Header */} +
+
+ +

+ SOLO LEVEL UP +

+
+
+
+ + Quests + + + Inventory + + + Equipment + + + Combat + + + Skills + + + Profile + +
+
+ + + + + +
+

Menu

+ + + +
+ +
+
+
+
+
+ + {/* Main Status Panel */} +
+
+
+
+
+
+
+ +
+
+

+ STATUS +

+
+ +
+ {/* Level and Title */} +
+
+ {userStats.level} +
+
+ LEVEL +
+ + {/* Add name display above job */} +
+
NAME:
+
+ {userStats.name || "Unnamed"} +
+
+ +
+
JOB:
+
{userStats.job || "None"}
+
+
+
TITLE:
+
{userStats.title || "None"}
+
+
+ + {/* HP/MP Bars */} +
+
+
+ + HP + + + {userStats.hp}/{userStats.maxHp} + +
+ +
+ +
+ +
+
+ + MP + + + {userStats.mp}/{userStats.maxMp} + +
+ +
+ +
+ +
+
+ + EXP + + + {userStats.exp}/{userStats.expToNextLevel} + +
+ +
+ +
+ +
+
+ + FATIGUE + + {userStats.fatigue} +
+ +
+ +
+
+
+ + {/* Stats Grid */} +
+
+
+
+ } + name="STR" + value={userStats.stats.str} + /> + } + name="VIT" + value={userStats.stats.vit} + /> + } + name="AGI" + value={userStats.stats.agi} + /> + } + name="INT" + value={userStats.stats.int} + /> + } + name="PER" + value={userStats.stats.per} + /> +
+
+
+ Available Points +
+
+ {userStats.statPoints} +
+
+ + + +
+
+
+
+
+
+ + {/* Equipment Section */} +
+
+
+
+
+
+
+ +
+
+

+ EQUIPMENT +

+ + + +
+ +
+ + + +
+
+
+ + {/* Active Quests Section */} +
+
+
+
+
+
+
+ +
+
+

+ ACTIVE QUESTS +

+ + + +
+ +
+ {userStats.quests + .filter((q) => !q.completed) + .slice(0, 4) + .map((quest) => ( + completeQuest(quest.id)} + /> + ))} + {userStats.quests.filter((q) => !q.completed).length === 0 && ( +
+ No active quests. Create some quests to start leveling up! +
+ )} +
+
+
+
+ + {/* Add the mobile navigation bar */} + +
+ ); +} + +function StatDisplay({ + icon, + name, + value, +}: { + icon: React.ReactNode; + name: string; + value: number; +}) { + return ( +
+
{icon}
+
+
{name}
+
{value}
+
+
+ ); +} + +function EquipmentCard({ + name, + rarity, + stats, + setBonus, + slot, +}: { + name: string; + rarity: "Common" | "Uncommon" | "Rare" | "Epic" | "Legendary"; + stats: string[]; + setBonus: string; + slot: string; +}) { + const rarityColors = { + Common: "text-gray-400", + Uncommon: "text-green-400", + Rare: "text-[#4cc9ff]", + Epic: "text-purple-400", + Legendary: "text-yellow-400", + }; + + return ( + +
+
+ +
+ + {name} + + {slot} +
+ + {rarity} + +
+ +
+ {stats.map((stat, index) => ( +
+ {stat} +
+ ))} + +
{setBonus}
+
+
+
+ ); +} + +function QuestCard({ + title, + description, + reward, + progress, + difficulty, + onComplete, +}: { + title: string; + description: string; + reward: string; + progress: number; + difficulty: "S" | "A" | "B" | "C" | "D" | "E"; + onComplete: () => void; +}) { + const difficultyColors = { + S: "bg-red-500", + A: "bg-orange-500", + B: "bg-yellow-500", + C: "bg-green-500", + D: "bg-blue-500", + E: "bg-purple-500", + }; + + return ( + +
+ +
+ {title} +
+ {difficulty} +
+
+ {description} +
+ +
+
+ Progress + {progress}% +
+ +
+ +
+ Reward: + {reward} +
+
+ + + ); +} diff --git a/app/quests/loading.tsx b/app/quests/loading.tsx new file mode 100644 index 0000000..f15322a --- /dev/null +++ b/app/quests/loading.tsx @@ -0,0 +1,3 @@ +export default function Loading() { + return null +} diff --git a/app/quests/page.tsx b/app/quests/page.tsx new file mode 100644 index 0000000..2697a2c --- /dev/null +++ b/app/quests/page.tsx @@ -0,0 +1,660 @@ +"use client"; + +import type React from "react"; + +import Link from "next/link"; +import { + ChevronLeft, + Search, + Trash2, + MoreVertical, + Edit, + ArrowUpDown, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Progress } from "@/components/ui/progress"; +import { useUser } from "@/context/user-context"; +import { useState, useEffect } from "react"; +import { AddQuestForm } from "@/components/add-quest-form"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogFooter, +} from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Quest } from "@/interface/quest.interface"; + +export default function QuestsPage() { + const { + userStats, + completeQuest, + updateQuestProgress, + deleteQuest, + updateQuest, + } = useUser(); + const [searchTerm, setSearchTerm] = useState(""); + const [sortBy, setSortBy] = useState<"latest" | "priority">("latest"); + + // Load sorting preference from localStorage on component mount + useEffect(() => { + const savedSortBy = localStorage.getItem("questsSortBy"); + if ( + savedSortBy && + (savedSortBy === "latest" || savedSortBy === "priority") + ) { + setSortBy(savedSortBy); + } + }, []); + + // Save sorting preference to localStorage whenever it changes + const handleSortChange = (value: "latest" | "priority") => { + setSortBy(value); + localStorage.setItem("questsSortBy", value); + }; + + // Sort function + const sortQuests = (quests: Quest[], isCompleted = false) => { + return quests.sort((a, b) => { + if (sortBy === "priority") { + // Sort by priority: High > Medium > Low (descending) + const priorityOrder = { High: 3, Medium: 2, Low: 1 }; + const priorityA = priorityOrder[a.priority] || 0; + const priorityB = priorityOrder[b.priority] || 0; + + // If priorities are the same, sort by creation time (newest first) + if (priorityA === priorityB) { + const timeA = a.createdAt || 0; + const timeB = b.createdAt || 0; + return timeB - timeA; + } + + return priorityB - priorityA; + } else { + // Sort by time (newest first) + if (isCompleted) { + // For completed quests, sort by completion time + const timeA = a.completedAt || 0; + const timeB = b.completedAt || 0; + return timeB - timeA; + } else { + // For active quests, sort by creation time + const timeA = a.createdAt || 0; + const timeB = b.createdAt || 0; + return timeB - timeA; + } + } + }); + }; + + // Filter and sort quests based on search term and status + const activeQuests = sortQuests( + userStats.quests.filter( + (quest) => + !quest.completed && + (quest.title.toLowerCase().includes(searchTerm.toLowerCase()) || + quest.description.toLowerCase().includes(searchTerm.toLowerCase())) + ), + false + ); + + const completedQuests = sortQuests( + userStats.quests.filter( + (quest) => + quest.completed && + (quest.title.toLowerCase().includes(searchTerm.toLowerCase()) || + quest.description.toLowerCase().includes(searchTerm.toLowerCase())) + ), + true + ); + + const handleDeleteQuest = (questId: string) => { + deleteQuest(questId); + }; + + return ( +
+
+ {/* Header */} +
+ + + +

+ Quests +

+
{" "} + {/* Search and Filter */} +
+
+
+
+
+ + setSearchTerm(e.target.value)} + /> +
+
+
+ + {/* Sorting Controls */} +
+
+
+
+ + Sort by:{" "} + +
+
+
+
+ {/* Quests Tabs */} + + + + Active + + + Completed + + + + {/* Active Quests */} + +
+ +
+
+ {activeQuests.length > 0 ? ( + activeQuests.map((quest) => ( + completeQuest(quest.id)} + onProgress={(progress) => + updateQuestProgress(quest.id, progress) + } + onDelete={() => handleDeleteQuest(quest.id)} + /> + )) + ) : ( +
+ {searchTerm + ? "No active quests match your search." + : "No active quests available."} +
+ )} +
+
+ + {/* Completed Quests */} + +
+ {completedQuests.length > 0 ? ( + completedQuests.map((quest) => ( + handleDeleteQuest(quest.id)} + /> + )) + ) : ( +
+ {searchTerm + ? "No completed quests match your search." + : "No completed quests yet."} +
+ )} +
+
+
+
+
+ ); +} + +function QuestCard({ + quest, + onComplete, + onProgress, + onDelete, +}: { + quest: Quest; + onComplete?: () => void; + onProgress?: (progress: number) => void; + onDelete?: () => void; +}) { + const { updateQuest } = useUser(); + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + const [editForm, setEditForm] = useState({ + title: quest.title, + description: quest.description, + reward: quest.reward, + difficulty: quest.difficulty, + priority: quest.priority || "Medium", + expiry: quest.expiry, + expReward: quest.expReward, + statPointsReward: quest.statPointsReward, + }); + + // Format date from timestamp + const formatDate = (timestamp?: number) => { + if (!timestamp) return ""; + const date = new Date(timestamp); + return date.toLocaleString("en-US", { + month: "short", + day: "numeric", + year: "2-digit", + hour: "numeric", + minute: "2-digit", + }); + }; + + const timeDisplay = quest.completed + ? formatDate(quest.completedAt) + : formatDate(quest.createdAt); + + const handleEditSubmit = (e: React.FormEvent) => { + e.preventDefault(); + updateQuest(quest.id, editForm); + setIsEditDialogOpen(false); + }; + + const difficultyColors = { + S: "bg-red-500", + A: "bg-orange-500", + B: "bg-yellow-500", + C: "bg-green-500", + D: "bg-blue-500", + E: "bg-purple-500", + }; + + const handleButtonClick = () => { + if (quest.completed) return; + + if (quest.progress === 100 && onComplete) { + onComplete(); + } else if (quest.progress < 100 && onProgress) { + // Increment progress by 25% each time + const newProgress = Math.min(100, quest.progress + 25); + onProgress(newProgress); + } + }; + + return ( + +
+ + {/* Action bar with difficulty, priority and menu - positioned above the title */} +
+
+
+ {quest.difficulty} +
+ {quest.priority && ( +
+ {quest.priority} +
+ )} +
+ + {/* Dropdown menu for actions */} + {onDelete && ( + + + + + + {quest.isCustom && !quest.completed && ( + { + e.preventDefault(); + setIsEditDialogOpen(true); + }} + > + + Edit Quest + + )} + + + e.preventDefault()} + > + + Delete Quest + + + + + + Delete Quest + + + Are you sure you want to delete this quest? This action + cannot be undone. + + + + + Cancel + + + Delete + + + + + + + )} + + {/* Edit Dialog */} + + + + Edit Quest + +
+
+ + + setEditForm({ ...editForm, title: e.target.value }) + } + className="bg-[#0a0e14] border-[#1e2a3a]" + /> +
+
+ +