This is a clone of an solo leveling node app from an git repo

This commit is contained in:
arul 2025-12-10 22:17:08 +05:30
commit e33baa3633
100 changed files with 17367 additions and 0 deletions

75
README.md Normal file
View File

@ -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

3
app/combat/loading.tsx Normal file
View File

@ -0,0 +1,3 @@
export default function Loading() {
return null
}

932
app/combat/page.tsx Normal file
View File

@ -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<Enemy | null>(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<Record<string, number>>(
{}
);
const [combatLog, setCombatLog] = useState<string[]>([]);
// 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<string | undefined>(
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 (
<div className="min-h-screen bg-[#0a0e14] text-[#e0f2ff] pb-16 md:pb-0">
<div className="container mx-auto px-4 py-6">
{/* Header */}
<header className="flex items-center justify-between mb-8">
<div className="flex items-center">
<Link href="/" className="mr-4">
<Button
variant="ghost"
size="icon"
className="hover:bg-[#1e2a3a]"
>
<ChevronLeft className="h-5 w-5" />
<span className="sr-only">Back</span>
</Button>
</Link>
<h1 className="text-2xl font-bold tracking-tight text-[#4cc9ff]">
Combat Arena
</h1>
</div>
<div className="flex items-center">
<div className="bg-[#1e2a3a] px-3 py-1 rounded-lg flex items-center">
<span className="text-[#8bacc1] mr-2">Gold:</span>
<span className="text-yellow-400 font-bold">
{userStats.gold}
</span>
</div>
</div>
</header>
{/* Recovery notification card */}
<div className="mb-6">
<Card className="bg-[#0a0e14]/80 border-[#1e2a3a] relative">
<div className="absolute inset-0 border border-[#4cc9ff]/10"></div>
<CardContent className="p-4 relative z-10">
<div className="flex items-start gap-3">
<Heart className="h-5 w-5 text-red-400 mt-0.5 flex-shrink-0" />
<div>
<h3 className="text-sm font-medium text-[#4cc9ff] mb-1">
Automatic Recovery System
</h3>
<p className="text-xs text-[#8bacc1]">
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.
</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Main Combat Area */}
<div className="grid grid-cols-1 gap-6">
{/* Left Column - Combat Area */}
<div className="lg:col-span-1">
{!selectedEnemy && !inCombat ? (
<EnemySelection enemies={enemies} onSelectEnemy={startCombat} />
) : (
<>
{/* Combat Visualization */}
{inCombat && selectedEnemy && (
<CombatVisualization
playerName={userStats.name || "Hunter"}
enemyName={selectedEnemy.name}
isPlayerTurn={playerTurn}
isAttacking={isAttacking}
isDefending={isDefending}
attackDamage={currentDamage}
isCritical={isCriticalHit}
skillName={currentSkill}
playerHp={playerHp}
playerMaxHp={userStats.maxHp}
playerMp={playerMp}
playerMaxMp={userStats.maxMp}
playerLevel={userStats.level}
enemyHp={enemyHp}
enemyMaxHp={enemyMaxHp}
playerStats={userStats.stats}
onAnimationComplete={handleAnimationComplete}
/>
)}
{!inCombat && selectedEnemy && (
<Card className="bg-[#0a0e14]/80 border-[#1e2a3a] relative mb-4">
<div className="absolute inset-0 border border-[#4cc9ff]/10"></div>
<CardHeader className="pb-2 relative z-10">
<div className="flex justify-between items-start">
<CardTitle className="text-[#4cc9ff]">
{selectedEnemy.name}
</CardTitle>
<Badge className="bg-[#1e2a3a]">
Level {selectedEnemy.level}
</Badge>
</div>
<CardDescription>
{selectedEnemy.description}
</CardDescription>
</CardHeader>
<CardContent className="relative z-10">
{/* Enemy Stats */}
<div className="grid grid-cols-3 gap-2 text-xs mb-4">
<div className="flex flex-col items-center p-2 bg-[#1e2a3a] rounded-md">
<Sword className="h-3 w-3 text-[#4cc9ff] mb-1" />
<span className="text-[#8bacc1]">STR</span>
<span>{selectedEnemy.stats.str}</span>
</div>
<div className="flex flex-col items-center p-2 bg-[#1e2a3a] rounded-md">
<Shield className="h-3 w-3 text-[#4cc9ff] mb-1" />
<span className="text-[#8bacc1]">VIT</span>
<span>{selectedEnemy.stats.vit}</span>
</div>
<div className="flex flex-col items-center p-2 bg-[#1e2a3a] rounded-md">
<Zap className="h-3 w-3 text-[#4cc9ff] mb-1" />
<span className="text-[#8bacc1]">AGI</span>
<span>{selectedEnemy.stats.agi}</span>
</div>
<div className="flex flex-col items-center p-2 bg-[#1e2a3a] rounded-md">
<Brain className="h-3 w-3 text-[#4cc9ff] mb-1" />
<span className="text-[#8bacc1]">INT</span>
<span>{selectedEnemy.stats.int}</span>
</div>
<div className="flex flex-col items-center p-2 bg-[#1e2a3a] rounded-md">
<Eye className="h-3 w-3 text-[#4cc9ff] mb-1" />
<span className="text-[#8bacc1]">PER</span>
<span>{selectedEnemy.stats.per}</span>
</div>
</div>
</CardContent>
{!inCombat && !showRewards && (
<CardFooter className="relative z-10">
<Button
className="w-full bg-transparent border border-[#4cc9ff] hover:bg-[#4cc9ff]/10 text-[#4cc9ff]"
onClick={() => startCombat(selectedEnemy)}
>
Start Combat
</Button>
</CardFooter>
)}
{showRewards && (
<CardFooter className="relative z-10">
<Button
className="w-full bg-transparent border border-[#4cc9ff] hover:bg-[#4cc9ff]/10 text-[#4cc9ff]"
onClick={claimRewards}
>
Claim Rewards
</Button>
</CardFooter>
)}
</Card>
)}
</>
)}
</div>
{/* Right Column - Player Stats (hidden during combat) */}
{!inCombat && (
<div className="lg:col-span-1">
<Card className="bg-[#0a0e14]/80 border-[#1e2a3a] relative h-full">
<div className="absolute inset-0 border border-[#4cc9ff]/10"></div>
<CardHeader className="pb-2 relative z-10">
<CardTitle className="text-[#4cc9ff]">Your Stats</CardTitle>
<CardDescription>
Level {userStats.level} Hunter
</CardDescription>
</CardHeader>
<CardContent className="relative z-10">
{/* HP/MP Bars */}
<div className="space-y-4">
<div>
<div className="flex justify-between text-xs mb-1">
<span className="flex items-center">
<Heart className="h-3 w-3 text-red-400 mr-1" />
<span>HP</span>
</span>
<span>
{playerHp}/{userStats.maxHp}
</span>
</div>
<Progress
value={(playerHp / userStats.maxHp) * 100}
className="h-2 bg-[#1e2a3a]"
>
<div className="h-full bg-gradient-to-r from-red-500 to-red-600 rounded-full" />
</Progress>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span className="flex items-center">
<Zap className="h-3 w-3 text-blue-400 mr-1" />
<span>MP</span>
</span>
<span>
{playerMp}/{userStats.maxMp}
</span>
</div>
<Progress
value={(playerMp / userStats.maxMp) * 100}
className="h-2 bg-[#1e2a3a]"
>
<div className="h-full bg-gradient-to-r from-blue-500 to-blue-600 rounded-full" />
</Progress>
</div>
</div>
<Separator className="my-4 bg-[#1e2a3a]" />
{/* Stats */}
<div className="grid grid-cols-2 gap-y-2 text-sm">
<div className="flex items-center">
<Sword className="h-4 w-4 text-[#4cc9ff] mr-2" />
<span className="text-[#8bacc1] mr-1">STR:</span>
<span>{userStats.stats.str}</span>
</div>
<div className="flex items-center">
<Shield className="h-4 w-4 text-[#4cc9ff] mr-2" />
<span className="text-[#8bacc1] mr-1">VIT:</span>
<span>{userStats.stats.vit}</span>
</div>
<div className="flex items-center">
<Zap className="h-4 w-4 text-[#4cc9ff] mr-2" />
<span className="text-[#8bacc1] mr-1">AGI:</span>
<span>{userStats.stats.agi}</span>
</div>
<div className="flex items-center">
<Brain className="h-4 w-4 text-[#4cc9ff] mr-2" />
<span className="text-[#8bacc1] mr-1">INT:</span>
<span>{userStats.stats.int}</span>
</div>
<div className="flex items-center">
<Eye className="h-4 w-4 text-[#4cc9ff] mr-2" />
<span className="text-[#8bacc1] mr-1">PER:</span>
<span>{userStats.stats.per}</span>
</div>
</div>
</CardContent>
</Card>
</div>
)}
</div>
{/* Combat Actions */}
{inCombat && playerTurn && (
<div className="mt-6">
<CombatActions
onAttack={playerAttack}
onDefend={playerDefend}
onUseSkill={playerUseSkill}
onUseItem={playerUseItem}
onFlee={fleeCombat}
playerStats={userStats}
playerMp={playerMp}
skillCooldowns={skillCooldowns}
/>
</div>
)}
{/* Rewards Dialog */}
<Dialog open={showRewards} onOpenChange={setShowRewards}>
<DialogContent
className="bg-[#0a0e14] border-[#1e2a3a] text-[#e0f2ff] w-[90%] sm:max-w-md animate-solo-modal"
style={
{
"--solo-expand-duration": "0.5s",
"--solo-expand-easing": "cubic-bezier(0.16, 1, 0.3, 1)",
} as React.CSSProperties
}
>
<DialogHeader>
<DialogTitle className="text-[#4cc9ff]">
Victory Rewards
</DialogTitle>
<DialogDescription className="text-[#8bacc1]">
You have defeated {selectedEnemy?.name}!
</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className="space-y-4">
<div className="flex justify-between items-center">
<span>Experience:</span>
<span className="text-[#4cc9ff] font-bold">
{rewards.exp} EXP
</span>
</div>
<div className="flex justify-between items-center">
<span>Gold:</span>
<span className="text-yellow-400 font-bold">
{rewards.gold} Gold
</span>
</div>
<Separator className="bg-[#1e2a3a]" />
<div>
<h4 className="mb-2 font-medium">Items:</h4>
<div className="space-y-2">
{rewards.items.map((item, index) => (
<div
key={index}
className="flex justify-between items-center p-2 bg-[#1e2a3a] rounded-md"
>
<div className="flex items-center">
<div
className={`w-2 h-2 rounded-full mr-2 ${
item.rarity === "Common"
? "bg-gray-400"
: item.rarity === "Uncommon"
? "bg-green-400"
: item.rarity === "Rare"
? "bg-[#4cc9ff]"
: item.rarity === "Epic"
? "bg-purple-400"
: "bg-yellow-400"
}`}
></div>
<span>{item.name}</span>
</div>
<Badge
className={
item.type === "Material"
? "bg-amber-900 text-amber-200"
: item.type === "Weapon"
? "bg-red-900 text-red-200"
: item.type === "Armor"
? "bg-blue-900 text-blue-200"
: item.type === "Accessory"
? "bg-purple-900 text-purple-200"
: "bg-green-900 text-green-200"
}
>
{item.type}
</Badge>
</div>
))}
</div>
</div>
</div>
</div>
<DialogFooter>
<Button
className="w-full bg-transparent border border-[#4cc9ff] hover:bg-[#4cc9ff]/10 text-[#4cc9ff]"
onClick={claimRewards}
>
Claim Rewards
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
);
}

457
app/equipment/page.tsx Normal file
View File

@ -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 (
<div className="min-h-screen bg-[#0a0e14] text-[#e0f2ff] pb-16 md:pb-0">
<div className="container mx-auto px-4 py-6">
{/* Header */}
<header className="flex items-center mb-8">
<Link href="/" className="mr-4">
<Button variant="ghost" size="icon" className="hover:bg-[#1e2a3a]">
<ChevronLeft className="h-5 w-5" />
<span className="sr-only">Back</span>
</Button>
</Link>
<h1 className="text-2xl font-bold tracking-tight text-[#4cc9ff]">Equipment</h1>
</header>
{/* Equipment Stats */}
<div className="mb-8 relative">
<div className="absolute inset-0 border border-[#4cc9ff]/30 rounded-lg shadow-[0_0_15px_rgba(76,201,255,0.15)]"></div>
<div className="absolute inset-0 border-t-2 border-l-2 border-r-2 border-b-2 border-[#4cc9ff]/20 rounded-lg"></div>
<div className="absolute top-0 left-0 w-[20px] h-[20px] border-t-2 border-l-2 border-[#4cc9ff]"></div>
<div className="absolute top-0 right-0 w-[20px] h-[20px] border-t-2 border-r-2 border-[#4cc9ff]"></div>
<div className="absolute bottom-0 left-0 w-[20px] h-[20px] border-b-2 border-l-2 border-[#4cc9ff]"></div>
<div className="absolute bottom-0 right-0 w-[20px] h-[20px] border-b-2 border-r-2 border-[#4cc9ff]"></div>
<div className="p-6 relative z-10">
<h2 className="text-xl uppercase tracking-wider text-[#4cc9ff] mb-6">Equipment Stats</h2>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<StatBonus
icon={<Shield className="h-5 w-5 text-[#4cc9ff]" />}
name="STR"
baseValue={userStats.stats.str}
bonusValue={statBonuses.str}
/>
<StatBonus
icon={<Zap className="h-5 w-5 text-[#4cc9ff]" />}
name="AGI"
baseValue={userStats.stats.agi}
bonusValue={statBonuses.agi}
/>
<StatBonus
icon={<Eye className="h-5 w-5 text-[#4cc9ff]" />}
name="PER"
baseValue={userStats.stats.per}
bonusValue={statBonuses.per}
/>
<StatBonus
icon={<Brain className="h-5 w-5 text-[#4cc9ff]" />}
name="INT"
baseValue={userStats.stats.int}
bonusValue={statBonuses.int}
/>
<StatBonus
icon={<Heart className="h-5 w-5 text-[#4cc9ff]" />}
name="VIT"
baseValue={userStats.stats.vit}
bonusValue={statBonuses.vit}
/>
</div>
</div>
</div>
{/* Equipment Slots */}
<div className="mb-8">
<Tabs defaultValue="equipped">
<TabsList className="grid w-full grid-cols-2 bg-[#1e2a3a] border border-[#1e2a3a]">
<TabsTrigger
value="equipped"
className="data-[state=active]:bg-[#0a0e14] data-[state=active]:border-t-2 data-[state=active]:border-[#4cc9ff]"
>
Equipped
</TabsTrigger>
<TabsTrigger
value="inventory"
className="data-[state=active]:bg-[#0a0e14] data-[state=active]:border-t-2 data-[state=active]:border-[#4cc9ff]"
>
Inventory
</TabsTrigger>
</TabsList>
<TabsContent value="equipped" className="mt-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{equippedItems.map((item) => (
<EquipmentSlotCard
key={item.id}
name={item.name}
rarity={item.rarity}
stats={item.stats}
setBonus={item.setBonus}
slot={item.slot}
equipped={item.equipped}
/>
))}
</div>
</TabsContent>
<TabsContent value="inventory" className="mt-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{inventoryItems.map((item) => (
<EquipmentCard
key={item.id}
name={item.name}
rarity={item.rarity}
stats={item.stats}
setBonus={item.setBonus}
slot={item.slot}
/>
))}
</div>
</TabsContent>
</Tabs>
</div>
{/* Set Bonuses */}
<div>
<h2 className="text-xl uppercase tracking-wider text-[#4cc9ff] mb-4">Set Bonuses</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card className="bg-[#0a0e14]/80 border-[#1e2a3a] relative">
<div className="absolute inset-0 border border-[#4cc9ff]/10"></div>
<CardHeader className="pb-2 relative z-10">
<CardTitle className="text-base text-yellow-400">Shadow Monarch Set (2/5)</CardTitle>
<CardDescription>Legendary Set</CardDescription>
</CardHeader>
<CardContent className="relative z-10">
<div className="space-y-2 text-sm">
<div className="flex items-center">
<div className="w-2 h-2 rounded-full bg-[#4cc9ff] mr-2"></div>
<span className="text-[#4cc9ff]">(2) Set: +15% Intelligence, +10% Perception</span>
</div>
<div className="flex items-center">
<div className="w-2 h-2 rounded-full bg-[#1e2a3a] mr-2"></div>
<span className="text-[#8bacc1]">(3) Set: +20% Mana Regeneration</span>
</div>
<div className="flex items-center">
<div className="w-2 h-2 rounded-full bg-[#1e2a3a] mr-2"></div>
<span className="text-[#8bacc1]">(4) Set: +25% Skill Damage</span>
</div>
<div className="flex items-center">
<div className="w-2 h-2 rounded-full bg-[#1e2a3a] mr-2"></div>
<span className="text-[#8bacc1]">(5) Set: Unlock Shadow Extraction Ability</span>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-[#0a0e14]/80 border-[#1e2a3a] relative">
<div className="absolute inset-0 border border-[#4cc9ff]/10"></div>
<CardHeader className="pb-2 relative z-10">
<CardTitle className="text-base text-purple-400">Warrior's Set (1/5)</CardTitle>
<CardDescription>Epic Set</CardDescription>
</CardHeader>
<CardContent className="relative z-10">
<div className="space-y-2 text-sm">
<div className="flex items-center">
<div className="w-2 h-2 rounded-full bg-[#1e2a3a] mr-2"></div>
<span className="text-[#8bacc1]">(2) Set: +15% Strength, +10% Vitality</span>
</div>
<div className="flex items-center">
<div className="w-2 h-2 rounded-full bg-[#1e2a3a] mr-2"></div>
<span className="text-[#8bacc1]">(3) Set: +20% Physical Damage</span>
</div>
<div className="flex items-center">
<div className="w-2 h-2 rounded-full bg-[#1e2a3a] mr-2"></div>
<span className="text-[#8bacc1]">(4) Set: +25% Defense</span>
</div>
<div className="flex items-center">
<div className="w-2 h-2 rounded-full bg-[#1e2a3a] mr-2"></div>
<span className="text-[#8bacc1]">(5) Set: Unlock Berserker Rage Ability</span>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
)
}
function StatBonus({
icon,
name,
baseValue,
bonusValue,
}: {
icon: React.ReactNode
name: string
baseValue: number
bonusValue: number
}) {
return (
<div className="flex flex-col items-center p-3 rounded-lg bg-[#0a0e14] border border-[#1e2a3a]">
<div className="flex items-center justify-center mb-2">{icon}</div>
<div className="text-xs text-[#8bacc1] mb-1">{name}</div>
<div className="text-lg font-bold flex items-center">
{baseValue} <span className="text-[#4cc9ff] text-sm ml-1">+{bonusValue}</span>
</div>
</div>
)
}
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 (
<Card className={`bg-[#0a0e14]/80 border-[#1e2a3a] overflow-hidden relative ${!equipped ? "opacity-50" : ""}`}>
<div className="absolute inset-0 border border-[#4cc9ff]/10"></div>
<div
className={`h-1 w-full bg-gradient-to-r ${
rarity === "Common"
? "from-gray-500 to-gray-600"
: rarity === "Uncommon"
? "from-green-500 to-green-600"
: rarity === "Rare"
? "from-[#4cc9ff] to-[#4cc9ff]/60"
: rarity === "Epic"
? "from-purple-500 to-purple-600"
: "from-yellow-500 to-yellow-600"
}`}
></div>
<CardHeader className="pb-2 relative z-10">
<div className="flex justify-between items-start">
<CardTitle className={`text-base ${rarityColors[rarity]}`}>{name}</CardTitle>
<span className="text-xs text-[#8bacc1]">{slot}</span>
</div>
<CardDescription className={rarityColors[rarity]}>{rarity}</CardDescription>
</CardHeader>
<CardContent className="relative z-10">
<div className="space-y-1">
{stats.length > 0 ? (
stats.map((stat, index) => (
<div key={index} className="text-xs">
{stat}
</div>
))
) : (
<div className="text-xs text-[#8bacc1]">No item equipped</div>
)}
{stats.length > 0 && (
<>
<Separator className="my-2 bg-[#1e2a3a]" />
<div className="text-xs text-[#8bacc1]">{setBonus}</div>
</>
)}
</div>
</CardContent>
{equipped && (
<CardFooter className="relative z-10">
<Button variant="outline" className="w-full border-[#1e2a3a] hover:bg-[#1e2a3a] hover:text-white">
Unequip
</Button>
</CardFooter>
)}
</Card>
)
}
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 (
<Card className="bg-[#0a0e14]/80 border-[#1e2a3a] overflow-hidden relative">
<div className="absolute inset-0 border border-[#4cc9ff]/10"></div>
<div
className={`h-1 w-full bg-gradient-to-r ${
rarity === "Common"
? "from-gray-500 to-gray-600"
: rarity === "Uncommon"
? "from-green-500 to-green-600"
: rarity === "Rare"
? "from-[#4cc9ff] to-[#4cc9ff]/60"
: rarity === "Epic"
? "from-purple-500 to-purple-600"
: "from-yellow-500 to-yellow-600"
}`}
></div>
<CardHeader className="pb-2 relative z-10">
<div className="flex justify-between items-start">
<CardTitle className={`text-base ${rarityColors[rarity]}`}>{name}</CardTitle>
<span className="text-xs text-[#8bacc1]">{slot}</span>
</div>
<CardDescription className={rarityColors[rarity]}>{rarity}</CardDescription>
</CardHeader>
<CardContent className="relative z-10">
<div className="space-y-1">
{stats.map((stat, index) => (
<div key={index} className="text-xs">
{stat}
</div>
))}
<Separator className="my-2 bg-[#1e2a3a]" />
<div className="text-xs text-[#8bacc1]">{setBonus}</div>
</div>
</CardContent>
<CardFooter className="relative z-10">
<Button className="w-full bg-transparent border border-[#4cc9ff] hover:bg-[#4cc9ff]/10 text-[#4cc9ff]">
Equip
</Button>
</CardFooter>
</Card>
)
}

116
app/globals.css Normal file
View File

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

View File

@ -0,0 +1,3 @@
export default function Loading() {
return null
}

483
app/inventory/page.tsx Normal file
View File

@ -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<InventoryItem | null>(null)
const [itemDetailsOpen, setItemDetailsOpen] = useState(false)
const [itemToUse, setItemToUse] = useState<InventoryItem | null>(null)
const itemToUseRef = useRef<InventoryItem | null>(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 (
<div className="min-h-screen bg-[#0a0e14] text-[#e0f2ff] pb-16 md:pb-0">
<div className="container mx-auto px-4 py-6">
{/* Header */}
<header className="flex items-center justify-between mb-8">
<div className="flex items-center">
<Link href="/" className="mr-4">
<Button variant="ghost" size="icon" className="hover:bg-[#1e2a3a]">
<ChevronLeft className="h-5 w-5" />
<span className="sr-only">Back</span>
</Button>
</Link>
<h1 className="text-2xl font-bold tracking-tight text-[#4cc9ff]">Inventory</h1>
</div>
<div className="flex items-center">
<div className="bg-[#1e2a3a] px-3 py-1 rounded-lg flex items-center">
<span className="text-[#8bacc1] mr-2">Gold:</span>
<span className="text-yellow-400 font-bold">{userStats.gold}</span>
</div>
</div>
</header>
{/* Search and Filter */}
<div className="mb-6 relative">
<div className="absolute inset-0 border border-[#4cc9ff]/30 rounded-lg shadow-[0_0_15px_rgba(76,201,255,0.15)]"></div>
<div className="p-4 relative z-10">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#8bacc1]" />
<Input
placeholder="Search items..."
className="pl-9 bg-[#0a0e14] border-[#1e2a3a] focus-visible:ring-[#4cc9ff]"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
</div>
{/* Inventory Tabs */}
<Tabs defaultValue="all">
<TabsList className="grid w-full grid-cols-4 bg-[#1e2a3a] border border-[#1e2a3a]">
<TabsTrigger
value="all"
className="data-[state=active]:bg-[#0a0e14] data-[state=active]:border-t-2 data-[state=active]:border-[#4cc9ff]"
>
All
</TabsTrigger>
<TabsTrigger
value="materials"
className="data-[state=active]:bg-[#0a0e14] data-[state=active]:border-t-2 data-[state=active]:border-[#4cc9ff]"
>
Materials
</TabsTrigger>
<TabsTrigger
value="consumables"
className="data-[state=active]:bg-[#0a0e14] data-[state=active]:border-t-2 data-[state=active]:border-[#4cc9ff]"
>
Consumables
</TabsTrigger>
<TabsTrigger
value="equipment"
className="data-[state=active]:bg-[#0a0e14] data-[state=active]:border-t-2 data-[state=active]:border-[#4cc9ff]"
>
Equipment
</TabsTrigger>
</TabsList>
{/* All Items */}
<TabsContent value="all" className="mt-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{allItems.length > 0 ? (
allItems.map((item) => (
<InventoryItemCard
key={item.id}
item={item}
onSelect={() => {
setSelectedItem(item)
setItemDetailsOpen(true)
}}
/>
))
) : (
<div className="col-span-3 text-center py-8 text-[#8bacc1]">
{searchTerm ? "No items match your search." : "Your inventory is empty."}
</div>
)}
</div>
</TabsContent>
{/* Materials */}
<TabsContent value="materials" className="mt-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{materials.length > 0 ? (
materials.map((item) => (
<InventoryItemCard
key={item.id}
item={item}
onSelect={() => {
setSelectedItem(item)
setItemDetailsOpen(true)
}}
/>
))
) : (
<div className="col-span-3 text-center py-8 text-[#8bacc1]">
{searchTerm ? "No materials match your search." : "You don't have any materials."}
</div>
)}
</div>
</TabsContent>
{/* Consumables */}
<TabsContent value="consumables" className="mt-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{consumables.length > 0 ? (
consumables.map((item) => (
<InventoryItemCard
key={item.id}
item={item}
onSelect={() => {
setSelectedItem(item)
setItemDetailsOpen(true)
}}
/>
))
) : (
<div className="col-span-3 text-center py-8 text-[#8bacc1]">
{searchTerm ? "No consumables match your search." : "You don't have any consumables."}
</div>
)}
</div>
</TabsContent>
{/* Equipment */}
<TabsContent value="equipment" className="mt-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{equipment.length > 0 ? (
equipment.map((item) => (
<InventoryItemCard
key={item.id}
item={item}
onSelect={() => {
setSelectedItem(item)
setItemDetailsOpen(true)
}}
/>
))
) : (
<div className="col-span-3 text-center py-8 text-[#8bacc1]">
{searchTerm ? "No equipment matches your search." : "You don't have any equipment."}
</div>
)}
</div>
</TabsContent>
</Tabs>
{/* Item Details Dialog */}
<Dialog open={itemDetailsOpen} onOpenChange={setItemDetailsOpen}>
{selectedItem && (
<DialogContent className="bg-[#0a0e14] border-[#1e2a3a] text-[#e0f2ff] max-w-md">
<DialogHeader>
<DialogTitle className={`${getRarityColor(selectedItem.rarity)}`}>{selectedItem.name}</DialogTitle>
<DialogDescription className="flex items-center gap-2">
<Badge className={`${getTypeColor(selectedItem.type)}`}>{selectedItem.type}</Badge>
<span className={`${getRarityColor(selectedItem.rarity)}`}>{selectedItem.rarity}</span>
</DialogDescription>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-[#8bacc1] mb-4">{selectedItem.description}</p>
{selectedItem.stats && Object.keys(selectedItem.stats).length > 0 && (
<>
<Separator className="my-2 bg-[#1e2a3a]" />
<div className="mt-2">
<h4 className="text-sm font-medium mb-2">Stats:</h4>
<ul className="space-y-1 text-sm">
{selectedItem.stats.str && (
<li className="flex justify-between">
<span>Strength</span>
<span className="text-[#4cc9ff]">+{selectedItem.stats.str}</span>
</li>
)}
{selectedItem.stats.agi && (
<li className="flex justify-between">
<span>Agility</span>
<span className="text-[#4cc9ff]">+{selectedItem.stats.agi}</span>
</li>
)}
{selectedItem.stats.per && (
<li className="flex justify-between">
<span>Perception</span>
<span className="text-[#4cc9ff]">+{selectedItem.stats.per}</span>
</li>
)}
{selectedItem.stats.int && (
<li className="flex justify-between">
<span>Intelligence</span>
<span className="text-[#4cc9ff]">+{selectedItem.stats.int}</span>
</li>
)}
{selectedItem.stats.vit && (
<li className="flex justify-between">
<span>Vitality</span>
<span className="text-[#4cc9ff]">+{selectedItem.stats.vit}</span>
</li>
)}
{selectedItem.stats.resistance && Object.keys(selectedItem.stats.resistance).length > 0 && (
<>
<li className="mt-1 font-medium">Resistances:</li>
{selectedItem.stats.resistance.fire && (
<li className="flex justify-between pl-2">
<span>Fire</span>
<span className="text-red-400">+{selectedItem.stats.resistance.fire}</span>
</li>
)}
{selectedItem.stats.resistance.ice && (
<li className="flex justify-between pl-2">
<span>Ice</span>
<span className="text-blue-400">+{selectedItem.stats.resistance.ice}</span>
</li>
)}
{selectedItem.stats.resistance.lightning && (
<li className="flex justify-between pl-2">
<span>Lightning</span>
<span className="text-yellow-400">+{selectedItem.stats.resistance.lightning}</span>
</li>
)}
{selectedItem.stats.resistance.poison && (
<li className="flex justify-between pl-2">
<span>Poison</span>
<span className="text-green-400">+{selectedItem.stats.resistance.poison}</span>
</li>
)}
{selectedItem.stats.resistance.dark && (
<li className="flex justify-between pl-2">
<span>Dark</span>
<span className="text-purple-400">+{selectedItem.stats.resistance.dark}</span>
</li>
)}
</>
)}
</ul>
</div>
</>
)}
{selectedItem.quantity && (
<div className="mt-4 flex justify-between items-center">
<span className="text-sm text-[#8bacc1]">Quantity:</span>
<span className="font-medium">{selectedItem.quantity}</span>
</div>
)}
</div>
<DialogFooter className="flex gap-2">
{selectedItem.type === "Consumable" && (
<Button
className="bg-transparent border border-[#4cc9ff] hover:bg-[#4cc9ff]/10 text-[#4cc9ff]"
onClick={() => {
itemToUseRef.current = selectedItem
handleUseItem()
}}
>
Use
</Button>
)}
{(selectedItem.type === "Weapon" ||
selectedItem.type === "Armor" ||
selectedItem.type === "Accessory") && (
<Button className="bg-transparent border border-[#4cc9ff] hover:bg-[#4cc9ff]/10 text-[#4cc9ff]">
Equip
</Button>
)}
<Button
variant="destructive"
className="bg-red-900 hover:bg-red-800 text-white"
onClick={() => {
removeItem(selectedItem.id, 1)
if ((selectedItem.quantity || 0) <= 1) {
setItemDetailsOpen(false)
}
}}
>
Discard
</Button>
</DialogFooter>
</DialogContent>
)}
</Dialog>
</div>
</div>
)
}
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 (
<Card
className={`bg-[#0a0e14]/80 border-[#1e2a3a] relative cursor-pointer hover:border-[#4cc9ff]/30 transition-colors`}
onClick={onSelect}
>
<div className="absolute inset-0 border border-[#4cc9ff]/10"></div>
<div
className={`h-1 w-full ${getRarityColor(item.rarity).replace("text-", "bg-").replace("border-", "bg-")}`}
></div>
<CardHeader className="pb-2 relative z-10">
<div className="flex justify-between items-start">
<CardTitle className={`text-base ${getRarityColor(item.rarity)}`}>{item.name}</CardTitle>
{item.quantity && <span className="text-xs text-[#8bacc1]">x{item.quantity}</span>}
</div>
<div className="flex items-center gap-2 mt-1">
<Badge className={`${getTypeColor(item.type)}`}>{item.type}</Badge>
</div>
</CardHeader>
<CardContent className="relative z-10">
<p className="text-xs text-[#8bacc1] line-clamp-2">{item.description}</p>
{item.stats &&
Object.keys(item.stats).some((key) => key !== "resistance" && item.stats[key as keyof typeof item.stats]) && (
<div className="mt-2 text-xs">
{item.stats.str && <div>+{item.stats.str} STR</div>}
{item.stats.agi && <div>+{item.stats.agi} AGI</div>}
{item.stats.per && <div>+{item.stats.per} PER</div>}
{item.stats.int && <div>+{item.stats.int} INT</div>}
{item.stats.vit && <div>+{item.stats.vit} VIT</div>}
</div>
)}
</CardContent>
<CardFooter className="relative z-10 pt-0">
<Button
variant="ghost"
size="sm"
className="w-full text-xs hover:bg-[#1e2a3a] text-[#4cc9ff]"
onClick={(e) => {
e.stopPropagation()
onSelect()
}}
>
<Info className="h-3 w-3 mr-1" /> Details
</Button>
</CardFooter>
</Card>
)
}

42
app/layout.tsx Normal file
View File

@ -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 (
<html lang="en" suppressHydrationWarning>
<head>
<title>Solo Level Up - Self-Improvement App</title>
<meta
name="description"
content="A self-improvement app inspired by Solo Leveling"
/>
</head>
<body className="bg-[#0a0e14]">
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem={false}
disableTransitionOnChange
>
<UserProvider>
{children}
<MobileNav />
<LevelUpNotification />
</UserProvider>
</ThemeProvider>
</body>
</html>
);
}
export const metadata = {
generator: "v0.dev",
};

540
app/page.tsx Normal file
View File

@ -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 (
<div className="min-h-screen bg-[#0a0e14] text-[#e0f2ff] pb-16 md:pb-0">
{/* Add the NameInputModal component */}
<NameInputModal />
<div className="container mx-auto px-4 py-6">
{/* Header */}
<header className="flex items-center justify-between mb-8">
<div className="flex items-center gap-2">
<Award className="h-8 w-8 text-[#4cc9ff]" />
<h1 className="text-2xl font-bold tracking-tight text-[#4cc9ff]">
SOLO LEVEL UP
</h1>
</div>
<div className="flex items-center gap-4">
<div className="hidden md:flex items-center gap-6">
<Link
href="/quests"
className="text-sm font-medium hover:text-[#4cc9ff] transition-colors"
>
Quests
</Link>
<Link
href="/inventory"
className="text-sm font-medium hover:text-[#4cc9ff] transition-colors"
>
Inventory
</Link>
<Link
href="/equipment"
className="text-sm font-medium hover:text-[#4cc9ff] transition-colors"
>
Equipment
</Link>
<Link
href="/combat"
className="text-sm font-medium hover:text-[#4cc9ff] transition-colors"
>
Combat
</Link>
<Link
href="/skills"
className="text-sm font-medium hover:text-[#4cc9ff] transition-colors"
>
Skills
</Link>
<Link
href="/profile"
className="text-sm font-medium hover:text-[#4cc9ff] transition-colors"
>
Profile
</Link>
</div>
<div className="hidden">
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="md:hidden">
<Menu className="h-5 w-5" />
<span className="sr-only">Toggle menu</span>
</Button>
</SheetTrigger>
<SheetContent
side="right"
className="bg-[#0a0e14] border-[#1e2a3a]"
>
<div className="flex items-center justify-between mb-8">
<h2 className="text-xl font-bold text-[#4cc9ff]">Menu</h2>
<SheetTrigger asChild>
<Button variant="ghost" size="icon">
<X className="h-5 w-5" />
<span className="sr-only">Close menu</span>
</Button>
</SheetTrigger>
</div>
<nav className="flex flex-col gap-4">
<Link
href="/quests"
className="text-base font-medium hover:text-[#4cc9ff] transition-colors"
>
Quests
</Link>
<Link
href="/inventory"
className="text-base font-medium hover:text-[#4cc9ff] transition-colors"
>
Inventory
</Link>
<Link
href="/equipment"
className="text-base font-medium hover:text-[#4cc9ff] transition-colors"
>
Equipment
</Link>
<Link
href="/combat"
className="text-base font-medium hover:text-[#4cc9ff] transition-colors"
>
Combat
</Link>
<Link
href="/skills"
className="text-base font-medium hover:text-[#4cc9ff] transition-colors"
>
Skills
</Link>
</nav>
</SheetContent>
</Sheet>
</div>
</div>
</header>
{/* Main Status Panel */}
<div className="mb-8 relative">
<div className="absolute inset-0 border border-[#4cc9ff]/30 rounded-lg shadow-[0_0_15px_rgba(76,201,255,0.15)]"></div>
<div className="absolute inset-0 border-t-2 border-l-2 border-r-2 border-b-2 border-[#4cc9ff]/20 rounded-lg"></div>
<div className="absolute top-0 left-0 w-[20px] h-[20px] border-t-2 border-l-2 border-[#4cc9ff]"></div>
<div className="absolute top-0 right-0 w-[20px] h-[20px] border-t-2 border-r-2 border-[#4cc9ff]"></div>
<div className="absolute bottom-0 left-0 w-[20px] h-[20px] border-b-2 border-l-2 border-[#4cc9ff]"></div>
<div className="absolute bottom-0 right-0 w-[20px] h-[20px] border-b-2 border-r-2 border-[#4cc9ff]"></div>
<div className="p-6 relative z-10">
<div className="text-center mb-6">
<h2 className="text-xl uppercase tracking-wider text-[#4cc9ff] border-b border-[#4cc9ff]/30 pb-2 inline-block">
STATUS
</h2>
</div>
<div className="flex flex-col md:flex-row gap-8 items-center justify-center">
{/* Level and Title */}
<div className="text-center">
<div className="text-7xl font-bold text-[#4cc9ff] mb-1">
{userStats.level}
</div>
<div className="text-sm uppercase tracking-wider text-[#8bacc1]">
LEVEL
</div>
{/* Add name display above job */}
<div className="mt-4">
<div className="text-xs text-[#8bacc1]">NAME:</div>
<div className="text-sm font-medium">
{userStats.name || "Unnamed"}
</div>
</div>
<div className="mt-2">
<div className="text-xs text-[#8bacc1]">JOB:</div>
<div className="text-sm">{userStats.job || "None"}</div>
</div>
<div className="mt-2">
<div className="text-xs text-[#8bacc1]">TITLE:</div>
<div className="text-sm">{userStats.title || "None"}</div>
</div>
</div>
{/* HP/MP Bars */}
<div className="w-full max-w-md">
<div className="mb-4">
<div className="flex justify-between text-xs mb-1">
<span className="flex items-center">
<span className="text-[#4cc9ff] mr-1">HP</span>
</span>
<span>
{userStats.hp}/{userStats.maxHp}
</span>
</div>
<Progress
value={(userStats.hp / userStats.maxHp) * 100}
className="h-2 bg-[#1e2a3a]"
>
<div className="h-full bg-gradient-to-r from-[#4cc9ff] to-[#4cc9ff]/70 rounded-full" />
</Progress>
</div>
<div className="mb-4">
<div className="flex justify-between text-xs mb-1">
<span className="flex items-center">
<span className="text-[#4cc9ff] mr-1">MP</span>
</span>
<span>
{userStats.mp}/{userStats.maxMp}
</span>
</div>
<Progress
value={(userStats.mp / userStats.maxMp) * 100}
className="h-2 bg-[#1e2a3a]"
>
<div className="h-full bg-gradient-to-r from-[#4cc9ff] to-[#4cc9ff]/70 rounded-full" />
</Progress>
</div>
<div className="mb-4">
<div className="flex justify-between text-xs mb-1">
<span className="flex items-center">
<span className="text-[#4cc9ff] mr-1">EXP</span>
</span>
<span>
{userStats.exp}/{userStats.expToNextLevel}
</span>
</div>
<Progress value={expPercentage} className="h-2 bg-[#1e2a3a]">
<div className="h-full bg-gradient-to-r from-[#4cc9ff] to-[#4cc9ff]/70 rounded-full" />
</Progress>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span className="flex items-center">
<span className="text-[#4cc9ff] mr-1">FATIGUE</span>
</span>
<span>{userStats.fatigue}</span>
</div>
<Progress
value={userStats.fatigue}
className="h-2 bg-[#1e2a3a]"
>
<div className="h-full bg-gradient-to-r from-[#ff4c4c] to-[#ff4c4c]/70 rounded-full" />
</Progress>
</div>
</div>
</div>
{/* Stats Grid */}
<div className="mt-8 relative">
<div className="absolute inset-0 border border-[#4cc9ff]/20 rounded-lg"></div>
<div className="p-6 relative z-10">
<div className="grid grid-cols-2 md:grid-cols-3 gap-y-6 gap-x-12">
<StatDisplay
icon={<Shield className="h-5 w-5 text-[#4cc9ff]" />}
name="STR"
value={userStats.stats.str}
/>
<StatDisplay
icon={<Heart className="h-5 w-5 text-[#4cc9ff]" />}
name="VIT"
value={userStats.stats.vit}
/>
<StatDisplay
icon={<Zap className="h-5 w-5 text-[#4cc9ff]" />}
name="AGI"
value={userStats.stats.agi}
/>
<StatDisplay
icon={<Brain className="h-5 w-5 text-[#4cc9ff]" />}
name="INT"
value={userStats.stats.int}
/>
<StatDisplay
icon={<Eye className="h-5 w-5 text-[#4cc9ff]" />}
name="PER"
value={userStats.stats.per}
/>
<div className="flex flex-col sm:flex-row sm:items-center">
<div>
<div className="text-xs text-[#8bacc1] mb-1">
Available Points
</div>
<div className="text-3xl font-bold text-[#4cc9ff]">
{userStats.statPoints}
</div>
</div>
<Link href="/stats" className="mt-2 sm:mt-0 sm:ml-4">
<Button className="w-full sm:w-auto bg-transparent border border-[#4cc9ff] hover:bg-[#4cc9ff]/10 text-[#4cc9ff]">
Allocate
</Button>
</Link>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Equipment Section */}
<div className="mb-8 relative">
<div className="absolute inset-0 border border-[#4cc9ff]/30 rounded-lg shadow-[0_0_15px_rgba(76,201,255,0.15)]"></div>
<div className="absolute inset-0 border-t-2 border-l-2 border-r-2 border-b-2 border-[#4cc9ff]/20 rounded-lg"></div>
<div className="absolute top-0 left-0 w-[20px] h-[20px] border-t-2 border-l-2 border-[#4cc9ff]"></div>
<div className="absolute top-0 right-0 w-[20px] h-[20px] border-t-2 border-r-2 border-[#4cc9ff]"></div>
<div className="absolute bottom-0 left-0 w-[20px] h-[20px] border-b-2 border-l-2 border-[#4cc9ff]"></div>
<div className="absolute bottom-0 right-0 w-[20px] h-[20px] border-b-2 border-r-2 border-[#4cc9ff]"></div>
<div className="p-6 relative z-10">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl uppercase tracking-wider text-[#4cc9ff]">
EQUIPMENT
</h2>
<Link href="/equipment">
<Button className="bg-transparent border border-[#4cc9ff] hover:bg-[#4cc9ff]/10 text-[#4cc9ff]">
View All
</Button>
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<EquipmentCard
name="Shadow Monarch's Helmet"
rarity="Legendary"
stats={["+15% Intelligence", "+10% Perception"]}
setBonus="Shadow Monarch Set (2/5)"
slot="Head"
/>
<EquipmentCard
name="Gauntlets of Strength"
rarity="Epic"
stats={["+12 Strength", "+8% Critical Rate"]}
setBonus="Warrior's Set (1/5)"
slot="Hands"
/>
<EquipmentCard
name="Boots of Agility"
rarity="Rare"
stats={["+10 Agility", "+5% Movement Speed"]}
setBonus="None"
slot="Feet"
/>
</div>
</div>
</div>
{/* Active Quests Section */}
<div className="relative">
<div className="absolute inset-0 border border-[#4cc9ff]/30 rounded-lg shadow-[0_0_15px_rgba(76,201,255,0.15)]"></div>
<div className="absolute inset-0 border-t-2 border-l-2 border-r-2 border-b-2 border-[#4cc9ff]/20 rounded-lg"></div>
<div className="absolute top-0 left-0 w-[20px] h-[20px] border-t-2 border-l-2 border-[#4cc9ff]"></div>
<div className="absolute top-0 right-0 w-[20px] h-[20px] border-t-2 border-r-2 border-[#4cc9ff]"></div>
<div className="absolute bottom-0 left-0 w-[20px] h-[20px] border-b-2 border-l-2 border-[#4cc9ff]"></div>
<div className="absolute bottom-0 right-0 w-[20px] h-[20px] border-b-2 border-r-2 border-[#4cc9ff]"></div>
<div className="p-6 relative z-10">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl uppercase tracking-wider text-[#4cc9ff]">
ACTIVE QUESTS
</h2>
<Link href="/quests">
<Button className="bg-transparent border border-[#4cc9ff] hover:bg-[#4cc9ff]/10 text-[#4cc9ff]">
View All
</Button>
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{userStats.quests
.filter((q) => !q.completed)
.slice(0, 4)
.map((quest) => (
<QuestCard
key={quest.id}
title={quest.title}
description={quest.description}
reward={quest.reward}
progress={quest.progress}
difficulty={quest.difficulty}
onComplete={() => completeQuest(quest.id)}
/>
))}
{userStats.quests.filter((q) => !q.completed).length === 0 && (
<div className="col-span-2 text-center py-8 text-[#8bacc1]">
No active quests. Create some quests to start leveling up!
</div>
)}
</div>
</div>
</div>
</div>
{/* Add the mobile navigation bar */}
<MobileNav />
</div>
);
}
function StatDisplay({
icon,
name,
value,
}: {
icon: React.ReactNode;
name: string;
value: number;
}) {
return (
<div className="flex items-center">
<div className="mr-3">{icon}</div>
<div>
<div className="text-xs text-[#8bacc1] mb-1">{name}</div>
<div className="text-3xl font-bold text-[#4cc9ff]">{value}</div>
</div>
</div>
);
}
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 (
<Card className="bg-[#0a0e14]/80 border-[#1e2a3a] overflow-hidden relative">
<div className="absolute inset-0 border border-[#4cc9ff]/10"></div>
<div
className={`h-1 w-full bg-gradient-to-r ${
rarity === "Common"
? "from-gray-500 to-gray-600"
: rarity === "Uncommon"
? "from-green-500 to-green-600"
: rarity === "Rare"
? "from-[#4cc9ff] to-[#4cc9ff]/60"
: rarity === "Epic"
? "from-purple-500 to-purple-600"
: "from-yellow-500 to-yellow-600"
}`}
></div>
<CardHeader className="pb-2 relative z-10">
<div className="flex justify-between items-start">
<CardTitle className={`text-base ${rarityColors[rarity]}`}>
{name}
</CardTitle>
<span className="text-xs text-[#8bacc1]">{slot}</span>
</div>
<CardDescription className={rarityColors[rarity]}>
{rarity}
</CardDescription>
</CardHeader>
<CardContent className="relative z-10">
<div className="space-y-1">
{stats.map((stat, index) => (
<div key={index} className="text-xs">
{stat}
</div>
))}
<Separator className="my-2 bg-[#1e2a3a]" />
<div className="text-xs text-[#8bacc1]">{setBonus}</div>
</div>
</CardContent>
</Card>
);
}
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 (
<Card className="bg-[#0a0e14]/80 border-[#1e2a3a] relative">
<div className="absolute inset-0 border border-[#4cc9ff]/10"></div>
<CardHeader className="pb-2 relative z-10">
<div className="flex justify-between items-start">
<CardTitle className="text-base">{title}</CardTitle>
<div
className={`${difficultyColors[difficulty]} w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold`}
>
{difficulty}
</div>
</div>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent className="relative z-10">
<div className="space-y-2">
<div className="flex justify-between text-xs">
<span>Progress</span>
<span>{progress}%</span>
</div>
<Progress value={progress} className="h-2 bg-[#1e2a3a]">
<div className="h-full bg-gradient-to-r from-[#4cc9ff] to-[#4cc9ff]/60 rounded-full" />
</Progress>
<div className="text-xs mt-2">
<span className="text-[#8bacc1]">Reward: </span>
<span className="text-[#4cc9ff]">{reward}</span>
</div>
</div>
</CardContent>
</Card>
);
}

3
app/quests/loading.tsx Normal file
View File

@ -0,0 +1,3 @@
export default function Loading() {
return null
}

660
app/quests/page.tsx Normal file
View File

@ -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 (
<div className="min-h-screen bg-[#0a0e14] text-[#e0f2ff] pb-16 md:pb-0">
<div className="container mx-auto px-4 py-6">
{/* Header */}
<header className="flex items-center mb-8">
<Link href="/" className="mr-4">
<Button variant="ghost" size="icon" className="hover:bg-[#1e2a3a]">
<ChevronLeft className="h-5 w-5" />
<span className="sr-only">Back</span>
</Button>
</Link>
<h1 className="text-2xl font-bold tracking-tight text-[#4cc9ff]">
Quests
</h1>
</header>{" "}
{/* Search and Filter */}
<div className="mb-6 space-y-4">
<div className="relative">
<div className="absolute inset-0 border border-[#4cc9ff]/30 rounded-lg shadow-[0_0_15px_rgba(76,201,255,0.15)]"></div>
<div className="p-4 relative z-10">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#8bacc1]" />
<Input
placeholder="Search quests..."
className="pl-9 bg-[#0a0e14] border-[#1e2a3a] focus-visible:ring-[#4cc9ff]"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
</div>
{/* Sorting Controls */}
<div className="relative">
<div className="absolute inset-0 border border-[#4cc9ff]/30 rounded-lg shadow-[0_0_15px_rgba(76,201,255,0.15)]"></div>
<div className="p-4 relative z-10">
<div className="flex items-center gap-2">
<ArrowUpDown className="h-4 w-4 text-[#8bacc1]" />
<span className="text-sm text-[#8bacc1]">Sort by:</span>{" "}
<Select value={sortBy} onValueChange={handleSortChange}>
<SelectTrigger className="w-[180px] bg-[#0a0e14] border-[#1e2a3a] focus-visible:ring-[#4cc9ff]">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#0a0e14] border-[#1e2a3a] text-[#e0f2ff]">
<SelectItem value="latest">Latest Created</SelectItem>
<SelectItem value="priority">
Priority (High to Low)
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
{/* Quests Tabs */}
<Tabs defaultValue="active">
<TabsList className="grid w-full grid-cols-2 bg-[#1e2a3a] border border-[#1e2a3a]">
<TabsTrigger
value="active"
className="data-[state=active]:bg-[#0a0e14] data-[state=active]:border-t-2 data-[state=active]:border-[#4cc9ff]"
>
Active
</TabsTrigger>
<TabsTrigger
value="completed"
className="data-[state=active]:bg-[#0a0e14] data-[state=active]:border-t-2 data-[state=active]:border-[#4cc9ff]"
>
Completed
</TabsTrigger>
</TabsList>
{/* Active Quests */}
<TabsContent value="active" className="mt-6">
<div className="mb-6 flex justify-center">
<AddQuestForm />
</div>
<div className="grid grid-cols-1 gap-4">
{activeQuests.length > 0 ? (
activeQuests.map((quest) => (
<QuestCard
key={quest.id}
quest={quest}
onComplete={() => completeQuest(quest.id)}
onProgress={(progress) =>
updateQuestProgress(quest.id, progress)
}
onDelete={() => handleDeleteQuest(quest.id)}
/>
))
) : (
<div className="text-center py-8 text-[#8bacc1]">
{searchTerm
? "No active quests match your search."
: "No active quests available."}
</div>
)}
</div>
</TabsContent>
{/* Completed Quests */}
<TabsContent value="completed" className="mt-6">
<div className="grid grid-cols-1 gap-4">
{completedQuests.length > 0 ? (
completedQuests.map((quest) => (
<QuestCard
key={quest.id}
quest={quest}
onDelete={() => handleDeleteQuest(quest.id)}
/>
))
) : (
<div className="text-center py-8 text-[#8bacc1]">
{searchTerm
? "No completed quests match your search."
: "No completed quests yet."}
</div>
)}
</div>
</TabsContent>
</Tabs>
</div>
</div>
);
}
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 (
<Card
className={`bg-[#0a0e14]/80 border-[#1e2a3a] relative ${
quest.completed ? "opacity-70" : ""
}`}
>
<div className="absolute inset-0 border border-[#4cc9ff]/10"></div>
<CardHeader className="pb-2 relative z-10">
{/* Action bar with difficulty, priority and menu - positioned above the title */}
<div className="flex justify-between items-center mb-2">
<div className="flex items-center gap-2">
<div
className={`${
difficultyColors[quest.difficulty]
} w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold`}
>
{quest.difficulty}
</div>
{quest.priority && (
<div
className={`${
quest.priority === "High"
? "bg-red-500/20 text-red-400 border-red-500/30"
: quest.priority === "Medium"
? "bg-yellow-500/20 text-yellow-400 border-yellow-500/30"
: "bg-green-500/20 text-green-400 border-green-500/30"
} px-2 py-0.5 rounded-full text-xs font-medium border`}
>
{quest.priority}
</div>
)}
</div>
{/* Dropdown menu for actions */}
{onDelete && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 p-0">
<MoreVertical className="h-4 w-4" />
<span className="sr-only">Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="bg-[#0a0e14] border-[#1e2a3a] text-[#e0f2ff]"
>
{quest.isCustom && !quest.completed && (
<DropdownMenuItem
className="focus:bg-[#1e2a3a]"
onSelect={(e) => {
e.preventDefault();
setIsEditDialogOpen(true);
}}
>
<Edit className="h-4 w-4 mr-2" />
Edit Quest
</DropdownMenuItem>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<DropdownMenuItem
className="text-red-400 focus:text-red-400 focus:bg-red-900/20"
onSelect={(e) => e.preventDefault()}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Quest
</DropdownMenuItem>
</AlertDialogTrigger>
<AlertDialogContent
className="bg-[#0a0e14] border-[#1e2a3a] text-[#e0f2ff] w-[90%] sm:max-w-md animate-solo-modal"
style={
{
"--solo-expand-duration": "0.5s",
"--solo-expand-easing": "cubic-bezier(0.16, 1, 0.3, 1)",
} as React.CSSProperties
}
>
<AlertDialogHeader>
<AlertDialogTitle className="text-[#4cc9ff]">
Delete Quest
</AlertDialogTitle>
<AlertDialogDescription className="text-[#8bacc1]">
Are you sure you want to delete this quest? This action
cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="bg-[#1e2a3a] text-[#e0f2ff] hover:bg-[#2a3a4a]">
Cancel
</AlertDialogCancel>
<AlertDialogAction
className="bg-red-900 text-[#e0f2ff] hover:bg-red-800"
onClick={onDelete}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Edit Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent
className="bg-[#0a0e14] border-[#1e2a3a] text-[#e0f2ff] w-[90%] sm:max-w-md max-h-[90vh] overflow-y-auto animate-solo-modal"
style={
{
"--solo-expand-duration": "0.5s",
"--solo-expand-easing": "cubic-bezier(0.16, 1, 0.3, 1)",
} as React.CSSProperties
}
>
<DialogHeader>
<DialogTitle className="text-[#4cc9ff]">Edit Quest</DialogTitle>
</DialogHeader>
<form onSubmit={handleEditSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
value={editForm.title}
onChange={(e) =>
setEditForm({ ...editForm, title: e.target.value })
}
className="bg-[#0a0e14] border-[#1e2a3a]"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={editForm.description}
onChange={(e) =>
setEditForm({ ...editForm, description: e.target.value })
}
className="bg-[#0a0e14] border-[#1e2a3a]"
/>
</div>
<div className="space-y-2">
<Label htmlFor="reward">Reward</Label>
<Input
id="reward"
value={editForm.reward}
onChange={(e) =>
setEditForm({ ...editForm, reward: e.target.value })
}
className="bg-[#0a0e14] border-[#1e2a3a]"
/>
</div>
<div className="space-y-2">
<Label htmlFor="difficulty">Difficulty</Label>
<Select
value={editForm.difficulty}
onValueChange={(value) =>
setEditForm({
...editForm,
difficulty: value as "S" | "A" | "B" | "C" | "D" | "E",
})
}
>
<SelectTrigger className="bg-[#0a0e14] border-[#1e2a3a]">
<SelectValue placeholder="Select difficulty" />
</SelectTrigger>
<SelectContent className="bg-[#0a0e14] border-[#1e2a3a]">
{["S", "A", "B", "C", "D", "E"].map((diff) => (
<SelectItem key={diff} value={diff}>
{diff}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="priority">Priority</Label>
<Select
value={editForm.priority}
onValueChange={(value) =>
setEditForm({
...editForm,
priority: value as "High" | "Medium" | "Low",
})
}
>
<SelectTrigger className="bg-[#0a0e14] border-[#1e2a3a]">
<SelectValue placeholder="Select priority" />
</SelectTrigger>
<SelectContent className="bg-[#0a0e14] border-[#1e2a3a]">
<SelectItem value="High">High</SelectItem>
<SelectItem value="Medium">Medium</SelectItem>
<SelectItem value="Low">Low</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="expiry">Expiry</Label>
<Input
id="expiry"
value={editForm.expiry}
onChange={(e) =>
setEditForm({ ...editForm, expiry: e.target.value })
}
className="bg-[#0a0e14] border-[#1e2a3a]"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="expReward">EXP Reward</Label>
<Input
id="expReward"
type="number"
value={editForm.expReward}
onChange={(e) =>
setEditForm({
...editForm,
expReward: parseInt(e.target.value),
})
}
className="bg-[#0a0e14] border-[#1e2a3a]"
/>
</div>
<div className="space-y-2">
<Label htmlFor="statPointsReward">Stat Points</Label>
<Input
id="statPointsReward"
type="number"
value={editForm.statPointsReward}
onChange={(e) =>
setEditForm({
...editForm,
statPointsReward: parseInt(e.target.value),
})
}
className="bg-[#0a0e14] border-[#1e2a3a]"
/>
</div>
</div>
<DialogFooter>
<Button
type="submit"
className="bg-[#4cc9ff] text-[#0a0e14] hover:bg-[#4cc9ff]/90"
>
Save Changes
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
{/* Title and custom tag */}
<div className="flex items-center">
<CardTitle className="text-base">{quest.title}</CardTitle>
{quest.isCustom && (
<span className="ml-2 text-xs bg-[#1e2a3a] text-[#8bacc1] px-2 py-0.5 rounded-full">
Custom
</span>
)}
</div>
<CardDescription>{quest.description}</CardDescription>
</CardHeader>
<CardContent className="relative z-10">
<div className="space-y-2">
<div className="flex justify-between text-xs">
<span>Progress</span>
<span>{quest.progress}%</span>
</div>
<Progress value={quest.progress} className="h-2 bg-[#1e2a3a]">
<div className="h-full bg-gradient-to-r from-[#4cc9ff] to-[#4cc9ff]/60 rounded-full" />
</Progress>
<div className="flex justify-between text-xs mt-2">
<div>
<span className="text-[#8bacc1]">Reward: </span>
<span className="text-[#4cc9ff]">{quest.reward}</span>
</div>
<div className="text-[#8bacc1]">{quest.expiry}</div>
</div>
<div className="flex justify-between text-xs">
<div>
<span className="text-[#8bacc1]">EXP: </span>
<span className="text-[#4cc9ff]">{quest.expReward}</span>
</div>
<div>
<span className="text-[#8bacc1]">Stat Points: </span>
<span className="text-[#4cc9ff]">{quest.statPointsReward}</span>
</div>
</div>
<div className="text-xs text-[#8bacc1]">
{quest.completed ? "Completed At: " : "Created At: "}
<span className="text-[#4cc9ff]">{timeDisplay}</span>
</div>
</div>
</CardContent>
<CardFooter className="relative z-10">
{!quest.completed && (
<Button
className="w-full bg-transparent border border-[#4cc9ff] hover:bg-[#4cc9ff]/10 text-[#4cc9ff]"
onClick={handleButtonClick}
>
{quest.progress === 0
? "Start Quest"
: quest.progress === 100
? "Claim Reward"
: "Update Progress"}
</Button>
)}
{quest.completed && (
<Button className="w-full bg-[#1e2a3a] hover:bg-[#2a3a4a]" disabled>
Completed
</Button>
)}
</CardFooter>
</Card>
);
}

315
app/stats/page.tsx Normal file
View File

@ -0,0 +1,315 @@
"use client"
import type React from "react"
import Link from "next/link"
import { Shield, Zap, Eye, Brain, Heart, ChevronLeft, Plus, Minus, HelpCircle } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { useUser } from "@/context/user-context"
import type { UserStats } from "@/utils/storage"
export default function StatsPage() {
const { userStats, allocateStatPoint, deallocateStatPoint } = useUser()
return (
<div className="min-h-screen bg-[#0a0e14] text-[#e0f2ff] pb-16 md:pb-0">
<div className="container mx-auto px-4 py-6">
{/* Header */}
<header className="flex items-center mb-8">
<Link href="/" className="mr-4">
<Button variant="ghost" size="icon" className="hover:bg-[#1e2a3a]">
<ChevronLeft className="h-5 w-5" />
<span className="sr-only">Back</span>
</Button>
</Link>
<h1 className="text-2xl font-bold tracking-tight text-[#4cc9ff]">Stat Allocation</h1>
</header>
{/* Stats Overview */}
<div className="mb-8 relative">
<div className="absolute inset-0 border border-[#4cc9ff]/30 rounded-lg shadow-[0_0_15px_rgba(76,201,255,0.15)]"></div>
<div className="absolute inset-0 border-t-2 border-l-2 border-r-2 border-b-2 border-[#4cc9ff]/20 rounded-lg"></div>
<div className="absolute top-0 left-0 w-[20px] h-[20px] border-t-2 border-l-2 border-[#4cc9ff]"></div>
<div className="absolute top-0 right-0 w-[20px] h-[20px] border-t-2 border-r-2 border-[#4cc9ff]"></div>
<div className="absolute bottom-0 left-0 w-[20px] h-[20px] border-b-2 border-l-2 border-[#4cc9ff]"></div>
<div className="absolute bottom-0 right-0 w-[20px] h-[20px] border-b-2 border-r-2 border-[#4cc9ff]"></div>
<div className="p-6 relative z-10">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl uppercase tracking-wider text-[#4cc9ff]">Available Points</h2>
<div className="text-3xl font-bold text-[#4cc9ff]">{userStats.statPoints}</div>
</div>
<CardContent className="space-y-6 p-0">
<StatAllocationRow
icon={<Shield className="h-5 w-5 text-[#4cc9ff]" />}
name="str"
displayName="STR"
value={userStats.stats.str}
description="Enhances physical power and attack damage. Ideal for physical tasks and strength training."
onIncrease={() => allocateStatPoint("str")}
onDecrease={() => deallocateStatPoint("str")}
canIncrease={userStats.statPoints > 0}
canDecrease={userStats.stats.str > 10}
/>
<StatAllocationRow
icon={<Zap className="h-5 w-5 text-[#4cc9ff]" />}
name="agi"
displayName="AGI"
value={userStats.stats.agi}
description="Boosts speed, reflexes, and critical hit rate. Represents quickness and dexterity."
onIncrease={() => allocateStatPoint("agi")}
onDecrease={() => deallocateStatPoint("agi")}
canIncrease={userStats.statPoints > 0}
canDecrease={userStats.stats.agi > 10}
/>
<StatAllocationRow
icon={<Eye className="h-5 w-5 text-[#4cc9ff]" />}
name="per"
displayName="PER"
value={userStats.stats.per}
description="Increases precision and awareness. Reflects focus, attention to detail, and consistency."
onIncrease={() => allocateStatPoint("per")}
onDecrease={() => deallocateStatPoint("per")}
canIncrease={userStats.statPoints > 0}
canDecrease={userStats.stats.per > 10}
/>
<StatAllocationRow
icon={<Brain className="h-5 w-5 text-[#4cc9ff]" />}
name="int"
displayName="INT"
value={userStats.stats.int}
description="Enhances cognitive abilities and reduces skill cooldowns. Suitable for learning and problem-solving."
onIncrease={() => allocateStatPoint("int")}
onDecrease={() => deallocateStatPoint("int")}
canIncrease={userStats.statPoints > 0}
canDecrease={userStats.stats.int > 10}
/>
<StatAllocationRow
icon={<Heart className="h-5 w-5 text-[#4cc9ff]" />}
name="vit"
displayName="VIT"
value={userStats.stats.vit}
description="Increases health and defense. Represents endurance, resilience, and overall wellbeing."
onIncrease={() => allocateStatPoint("vit")}
onDecrease={() => deallocateStatPoint("vit")}
canIncrease={userStats.statPoints > 0}
canDecrease={userStats.stats.vit > 10}
/>
</CardContent>
<CardFooter className="px-0 pt-6">
<Link href="/" className="w-full">
<Button className="w-full bg-transparent border border-[#4cc9ff] hover:bg-[#4cc9ff]/10 text-[#4cc9ff]">
Return to Dashboard
</Button>
</Link>
</CardFooter>
</div>
</div>
{/* Stat Effects */}
<div>
<h2 className="text-xl uppercase tracking-wider text-[#4cc9ff] mb-4">Stat Effects</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card className="bg-[#0a0e14]/80 border-[#1e2a3a] relative">
<div className="absolute inset-0 border border-[#4cc9ff]/10"></div>
<CardHeader className="pb-2 relative z-10">
<CardTitle className="text-base text-[#4cc9ff]">Strength Effects</CardTitle>
</CardHeader>
<CardContent className="relative z-10">
<ul className="space-y-2 text-sm">
<li className="flex justify-between">
<span>Base Attack Power</span>
<span className="font-medium">{userStats.stats.str * 2}</span>
</li>
<li className="flex justify-between">
<span>Physical Task Efficiency</span>
<span className="font-medium">+{userStats.stats.str}%</span>
</li>
<li className="flex justify-between">
<span>Carry Capacity</span>
<span className="font-medium">{userStats.stats.str * 3} units</span>
</li>
</ul>
</CardContent>
</Card>
<Card className="bg-[#0a0e14]/80 border-[#1e2a3a] relative">
<div className="absolute inset-0 border border-[#4cc9ff]/10"></div>
<CardHeader className="pb-2 relative z-10">
<CardTitle className="text-base text-[#4cc9ff]">Agility Effects</CardTitle>
</CardHeader>
<CardContent className="relative z-10">
<ul className="space-y-2 text-sm">
<li className="flex justify-between">
<span>Critical Hit Rate</span>
<span className="font-medium">{(userStats.stats.agi * 0.5).toFixed(1)}%</span>
</li>
<li className="flex justify-between">
<span>Reaction Time</span>
<span className="font-medium">-{(userStats.stats.agi * 0.4).toFixed(1)}%</span>
</li>
<li className="flex justify-between">
<span>Movement Speed</span>
<span className="font-medium">+{userStats.stats.agi}%</span>
</li>
</ul>
</CardContent>
</Card>
<Card className="bg-[#0a0e14]/80 border-[#1e2a3a] relative">
<div className="absolute inset-0 border border-[#4cc9ff]/10"></div>
<CardHeader className="pb-2 relative z-10">
<CardTitle className="text-base text-[#4cc9ff]">Perception Effects</CardTitle>
</CardHeader>
<CardContent className="relative z-10">
<ul className="space-y-2 text-sm">
<li className="flex justify-between">
<span>Accuracy</span>
<span className="font-medium">+{userStats.stats.per}%</span>
</li>
<li className="flex justify-between">
<span>Detection Range</span>
<span className="font-medium">{userStats.stats.per * 2} meters</span>
</li>
<li className="flex justify-between">
<span>Minimum Damage</span>
<span className="font-medium">{userStats.stats.per} points</span>
</li>
</ul>
</CardContent>
</Card>
<Card className="bg-[#0a0e14]/80 border-[#1e2a3a] relative">
<div className="absolute inset-0 border border-[#4cc9ff]/10"></div>
<CardHeader className="pb-2 relative z-10">
<CardTitle className="text-base text-[#4cc9ff]">Intelligence Effects</CardTitle>
</CardHeader>
<CardContent className="relative z-10">
<ul className="space-y-2 text-sm">
<li className="flex justify-between">
<span>Mana Points (MP)</span>
<span className="font-medium">{userStats.maxMp}</span>
</li>
<li className="flex justify-between">
<span>Skill Cooldown Reduction</span>
<span className="font-medium">-{(userStats.stats.int * 0.5).toFixed(1)}%</span>
</li>
<li className="flex justify-between">
<span>Learning Efficiency</span>
<span className="font-medium">+{userStats.stats.int}%</span>
</li>
</ul>
</CardContent>
</Card>
<Card className="bg-[#0a0e14]/80 border-[#1e2a3a] relative">
<div className="absolute inset-0 border border-[#4cc9ff]/10"></div>
<CardHeader className="pb-2 relative z-10">
<CardTitle className="text-base text-[#4cc9ff]">Vitality Effects</CardTitle>
</CardHeader>
<CardContent className="relative z-10">
<ul className="space-y-2 text-sm">
<li className="flex justify-between">
<span>Health Points (HP)</span>
<span className="font-medium">{userStats.maxHp}</span>
</li>
<li className="flex justify-between">
<span>Defense</span>
<span className="font-medium">{userStats.stats.vit * 2}</span>
</li>
<li className="flex justify-between">
<span>Stamina Recovery</span>
<span className="font-medium">+{(userStats.stats.vit * 0.5).toFixed(1)}%</span>
</li>
</ul>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
)
}
// Update the StatAllocationRow component to make it more responsive
function StatAllocationRow({
icon,
name,
displayName,
value,
description,
onIncrease,
onDecrease,
canIncrease,
canDecrease,
}: {
icon: React.ReactNode
name: keyof UserStats["stats"]
displayName: string
value: number
description: string
onIncrease: () => void
onDecrease: () => void
canIncrease: boolean
canDecrease: boolean
}) {
return (
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-0 mb-4 sm:mb-0">
<div className="flex items-center w-full sm:w-1/2">
<div className="flex items-center justify-center mr-3">{icon}</div>
<div className="flex-1">
<div className="flex items-center">
<div className="text-sm font-medium">{displayName}</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6 ml-1">
<HelpCircle className="h-3 w-3" />
<span className="sr-only">Info</span>
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-xs bg-[#0a0e14] border-[#1e2a3a]">
<p className="text-xs">{description}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="w-full mt-1">
<div
className="h-1 rounded-full bg-gradient-to-r from-[#4cc9ff] to-[#4cc9ff]/60"
style={{ width: `${Math.min(100, value * 2)}%` }}
></div>
</div>
</div>
</div>
<div className="flex items-center justify-between sm:justify-end w-full sm:w-1/2 mt-2 sm:mt-0">
<Button
variant="outline"
size="icon"
className="h-7 w-7 rounded-full border-[#1e2a3a] hover:bg-[#1e2a3a]"
onClick={onDecrease}
disabled={!canDecrease}
>
<Minus className="h-3 w-3" />
<span className="sr-only">Decrease</span>
</Button>
<div className="text-lg font-bold w-10 text-center">{value}</div>
<Button
variant="outline"
size="icon"
className="h-7 w-7 rounded-full border-[#1e2a3a] hover:bg-[#1e2a3a]"
onClick={onIncrease}
disabled={!canIncrease}
>
<Plus className="h-3 w-3" />
<span className="sr-only">Increase</span>
</Button>
</div>
</div>
)
}

21
components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@ -0,0 +1,759 @@
"use client";
import type React from "react";
import { useState, useEffect } from "react";
import { useUser } from "@/context/user-context";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Plus, Trash2, Sparkles, Loader2 } from "lucide-react";
import { APIKeyModal } from "@/components/api-key-modal";
import {
hasAPIKey,
generateQuestData,
getAIProvider,
} from "@/utils/ai-service";
import { useToast } from "@/hooks/use-toast";
import { predefinedConsumables } from "@/data/items";
export function AddQuestForm() {
const { addCustomQuest } = useUser();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [apiKeyModalOpen, setApiKeyModalOpen] = useState(false);
const [isGeneratingWithAI, setIsGeneratingWithAI] = useState(false);
const [hasAIKey, setHasAIKey] = useState(false);
const [formData, setFormData] = useState({
title: "",
description: "",
difficulty: "C" as "S" | "A" | "B" | "C" | "D" | "E",
priority: "Medium" as "High" | "Medium" | "Low",
expiry: "One-time", // Changed default to One-time
expReward: 30,
statPointsReward: 1,
goldReward: 0,
strReward: 0,
agiReward: 0,
perReward: 0,
intReward: 0,
vitReward: 0,
itemRewards: [] as {
id?: string;
name: string;
type: string;
description: string;
}[],
});
// Check if AI API key exists on component mount
useEffect(() => {
setHasAIKey(hasAPIKey());
}, []);
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]:
name.includes("Reward") && name !== "expReward"
? Number.parseInt(value) || 0
: value,
}));
};
const handleSelectChange = (name: string, value: string) => {
setFormData((prev) => ({
...prev,
[name]: value,
}));
};
const addItemReward = () => {
setFormData((prev) => ({
...prev,
itemRewards: [
...prev.itemRewards,
{
name: "",
type: "Material",
description: "",
},
],
}));
};
const removeItemReward = (index: number) => {
setFormData((prev) => ({
...prev,
itemRewards: prev.itemRewards.filter((_, i) => i !== index),
}));
};
const updateItemReward = (index: number, field: string, value: string) => {
setFormData((prev) => ({
...prev,
itemRewards: prev.itemRewards.map((item, i) =>
i === index ? { ...item, [field]: value } : item
),
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Calculate reward string based on stat rewards
const rewardParts = [];
if (formData.goldReward > 0)
rewardParts.push(`${formData.goldReward} Gold`);
if (formData.strReward > 0)
rewardParts.push(`+${formData.strReward} Strength`);
if (formData.agiReward > 0)
rewardParts.push(`+${formData.agiReward} Agility`);
if (formData.perReward > 0)
rewardParts.push(`+${formData.perReward} Perception`);
if (formData.intReward > 0)
rewardParts.push(`+${formData.intReward} Intelligence`);
if (formData.vitReward > 0)
rewardParts.push(`+${formData.vitReward} Vitality`);
if (formData.itemRewards.length > 0) {
formData.itemRewards.forEach((item) => {
rewardParts.push(`${item.name} (${item.type})`);
});
}
const rewardString =
rewardParts.length > 0 ? rewardParts.join(", ") : "Experience only";
// Create the quest object
const newQuest = {
title: formData.title,
description: formData.description,
reward: rewardString,
difficulty: formData.difficulty,
priority: formData.priority,
expiry: formData.expiry,
expReward: Number.parseInt(formData.expReward.toString()),
statPointsReward: formData.statPointsReward,
goldReward: formData.goldReward,
statRewards: {
str: formData.strReward,
agi: formData.agiReward,
per: formData.perReward,
int: formData.intReward,
vit: formData.vitReward,
},
itemRewards: formData.itemRewards.map((item, index) => {
// Check if it's a predefined consumable (has an id property)
if (item.id && predefinedConsumables.some((p) => p.id === item.id)) {
// Find the predefined consumable to get all its properties
const predefined = predefinedConsumables.find(
(p) => p.id === item.id
);
if (predefined) {
return {
id: predefined.id,
name: predefined.name,
type: predefined.type,
rarity: predefined.rarity as
| "Common"
| "Uncommon"
| "Rare"
| "Epic"
| "Legendary",
description: predefined.description,
quantity: 1,
};
}
}
// Default for custom items
return {
id: `custom-item-${Date.now()}-${index}`,
name: item.name,
type: item.type as any,
rarity: "Common" as
| "Common"
| "Uncommon"
| "Rare"
| "Epic"
| "Legendary",
description: item.description,
quantity: 1,
};
}),
};
// Add the quest
addCustomQuest(newQuest);
// Reset form and close dialog
setFormData({
title: "",
description: "",
difficulty: "C",
priority: "Medium",
expiry: "One-time", // Reset to One-time
expReward: 30,
statPointsReward: 1,
goldReward: 0,
strReward: 0,
agiReward: 0,
perReward: 0,
intReward: 0,
vitReward: 0,
itemRewards: [],
});
setOpen(false);
};
const processAIGeneratedItems = (items: any[]) => {
return items.map((item, index) => {
// First, check if the AI provided an ID for a consumable
if (item.type === "Consumable" && item.id) {
// Check if the ID matches any predefined consumable
const predefinedById = predefinedConsumables.find(
(p) => p.id === item.id
);
if (predefinedById) {
// Use the predefined consumable properties with matching ID
return {
id: predefinedById.id,
name: predefinedById.name,
type: predefinedById.type,
description: predefinedById.description,
rarity: predefinedById.rarity,
};
}
}
// If no ID match or ID not provided, fall back to name matching for consumables
if (item.type === "Consumable") {
// Try to find a matching predefined consumable by name (case-insensitive)
const predefined = predefinedConsumables.find(
(p) => p.name.toLowerCase() === item.name.toLowerCase()
);
if (predefined) {
// Use the predefined consumable properties
return {
id: predefined.id,
name: predefined.name,
type: predefined.type,
description: predefined.description,
rarity: predefined.rarity,
};
}
// Also try partial name matching for potions
const potionMatch = predefinedConsumables.find(
(p) =>
(item.name.toLowerCase().includes("health") &&
p.name.toLowerCase().includes("health")) ||
(item.name.toLowerCase().includes("mana") &&
p.name.toLowerCase().includes("mana")) ||
(item.name.toLowerCase().includes("focus") &&
p.name.toLowerCase().includes("focus"))
);
if (potionMatch) {
return {
id: potionMatch.id,
name: potionMatch.name,
type: potionMatch.type,
description: potionMatch.description,
rarity: potionMatch.rarity,
};
}
}
// For non-consumables or consumables that don't match predefined ones
return {
id: `custom-item-${Date.now()}-${index}`,
name: item.name,
type: item.type,
description: item.description || "",
rarity: "Common",
};
});
};
const handleGenerateWithAI = async () => {
// Check if description is empty
if (!formData.description.trim()) {
toast({
title: "Description Required",
description:
"Please enter a quest description before using AI generation.",
variant: "destructive",
});
return;
}
// Check if API key exists
if (!hasAIKey) {
setApiKeyModalOpen(true);
return;
}
// Generate quest data with AI
setIsGeneratingWithAI(true);
try {
const questData = await generateQuestData(formData.description);
const provider = getAIProvider() || "AI";
// Update form data with AI-generated data, but keep the current expiry
setFormData((prev) => ({
...prev,
title: questData.title || prev.title,
description: questData.description || prev.description,
difficulty: questData.difficulty || prev.difficulty,
priority: questData.priority || prev.priority,
// Don't update expiry from AI
expReward: questData.expReward || prev.expReward,
statPointsReward: questData.statPointsReward || prev.statPointsReward,
goldReward: questData.goldReward || prev.goldReward,
strReward: questData.statRewards?.str || 0,
agiReward: questData.statRewards?.agi || 0,
perReward: questData.statRewards?.per || 0,
intReward: questData.statRewards?.int || 0,
vitReward: questData.statRewards?.vit || 0,
itemRewards: processAIGeneratedItems(questData.itemRewards || []),
}));
toast({
title: `${provider} Generation Complete`,
description:
"Quest details have been generated. Feel free to make any adjustments.",
});
} catch (error) {
toast({
title: "AI Generation Failed",
description:
error instanceof Error ? error.message : "An unknown error occurred",
variant: "destructive",
});
} finally {
setIsGeneratingWithAI(false);
}
};
const handleAPIKeySubmit = (provider: string) => {
setHasAIKey(true);
// Automatically trigger AI generation after API key is submitted
setTimeout(() => {
handleGenerateWithAI();
}, 500);
};
return (
<>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="bg-transparent border border-[#4cc9ff] hover:bg-[#4cc9ff]/10 text-[#4cc9ff]">
<Plus className="mr-2 h-4 w-4" /> Create Custom Quest
</Button>
</DialogTrigger>
<DialogContent
className="bg-[#0a0e14] border-[#1e2a3a] text-[#e0f2ff] w-[90%] sm:max-w-md max-h-[90vh] overflow-y-auto animate-solo-modal"
style={
{
"--solo-expand-duration": "0.5s",
"--solo-expand-easing": "cubic-bezier(0.16, 1, 0.3, 1)",
} as React.CSSProperties
}
>
<DialogHeader>
<DialogTitle className="text-[#4cc9ff]">
Create New Quest
</DialogTitle>
<DialogDescription className="text-[#8bacc1]">
Add a new quest to track your real-life progress
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="title">Quest Title</Label>
<Input
id="title"
name="title"
value={formData.title}
onChange={handleChange}
className="bg-[#0a0e14] border-[#1e2a3a]"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
name="description"
value={formData.description}
onChange={handleChange}
className="bg-[#0a0e14] border-[#1e2a3a] min-h-[80px]"
required
/>
</div>
{/* Moved AI button here, outside of the textarea */}
<Button
type="button"
className="bg-[#1e2a3a] hover:bg-[#2a3a4a] text-[#4cc9ff] w-full"
onClick={handleGenerateWithAI}
disabled={isGeneratingWithAI || !formData.description.trim()}
>
{isGeneratingWithAI ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Generating with AI...
</>
) : (
<>
<Sparkles className="h-4 w-4 mr-2" />
Enhance with AI
</>
)}
</Button>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="difficulty">Difficulty</Label>
<Select
value={formData.difficulty}
onValueChange={(value) =>
handleSelectChange("difficulty", value)
}
>
<SelectTrigger className="bg-[#0a0e14] border-[#1e2a3a]">
<SelectValue placeholder="Select difficulty" />
</SelectTrigger>
<SelectContent className="bg-[#0a0e14] border-[#1e2a3a]">
<SelectItem value="S">S (Hardest)</SelectItem>
<SelectItem value="A">A</SelectItem>
<SelectItem value="B">B</SelectItem>
<SelectItem value="C">C (Medium)</SelectItem>
<SelectItem value="D">D</SelectItem>
<SelectItem value="E">E (Easiest)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="priority">Priority</Label>
<Select
value={formData.priority}
onValueChange={(value) =>
handleSelectChange("priority", value)
}
>
<SelectTrigger className="bg-[#0a0e14] border-[#1e2a3a]">
<SelectValue placeholder="Select priority" />
</SelectTrigger>
<SelectContent className="bg-[#0a0e14] border-[#1e2a3a]">
<SelectItem value="High">High</SelectItem>
<SelectItem value="Medium">Medium</SelectItem>
<SelectItem value="Low">Low</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="expiry">Frequency</Label>
<Select
value={formData.expiry}
onValueChange={(value) => handleSelectChange("expiry", value)}
>
<SelectTrigger className="bg-[#0a0e14] border-[#1e2a3a]">
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent className="bg-[#0a0e14] border-[#1e2a3a]">
<SelectItem value="One-time">One-time</SelectItem>
<SelectItem value="Daily">Daily</SelectItem>
<SelectItem value="Weekly">Weekly</SelectItem>
<SelectItem value="Monthly">Monthly</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="expReward">EXP Reward</Label>
<Input
id="expReward"
name="expReward"
type="number"
min="1"
value={formData.expReward}
onChange={handleChange}
className="bg-[#0a0e14] border-[#1e2a3a]"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="statPointsReward">Stat Points</Label>
<Input
id="statPointsReward"
name="statPointsReward"
type="number"
min="0"
value={formData.statPointsReward}
onChange={handleChange}
className="bg-[#0a0e14] border-[#1e2a3a]"
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="goldReward">Gold Reward</Label>
<Input
id="goldReward"
name="goldReward"
type="number"
min="0"
value={formData.goldReward}
onChange={handleChange}
className="bg-[#0a0e14] border-[#1e2a3a]"
/>
</div>
<div className="grid gap-2">
<Label>Stat Rewards</Label>
<div className="grid grid-cols-5 gap-2">
<div>
<Label htmlFor="strReward" className="text-xs">
STR
</Label>
<Input
id="strReward"
name="strReward"
type="number"
min="0"
value={formData.strReward}
onChange={handleChange}
className="bg-[#0a0e14] border-[#1e2a3a] h-8"
/>
</div>
<div>
<Label htmlFor="agiReward" className="text-xs">
AGI
</Label>
<Input
id="agiReward"
name="agiReward"
type="number"
min="0"
value={formData.agiReward}
onChange={handleChange}
className="bg-[#0a0e14] border-[#1e2a3a] h-8"
/>
</div>
<div>
<Label htmlFor="perReward" className="text-xs">
PER
</Label>
<Input
id="perReward"
name="perReward"
type="number"
min="0"
value={formData.perReward}
onChange={handleChange}
className="bg-[#0a0e14] border-[#1e2a3a] h-8"
/>
</div>
<div>
<Label htmlFor="intReward" className="text-xs">
INT
</Label>
<Input
id="intReward"
name="intReward"
type="number"
min="0"
value={formData.intReward}
onChange={handleChange}
className="bg-[#0a0e14] border-[#1e2a3a] h-8"
/>
</div>
<div>
<Label htmlFor="vitReward" className="text-xs">
VIT
</Label>
<Input
id="vitReward"
name="vitReward"
type="number"
min="0"
value={formData.vitReward}
onChange={handleChange}
className="bg-[#0a0e14] border-[#1e2a3a] h-8"
/>
</div>
</div>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label>Item Rewards</Label>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 border-[#4cc9ff]/50 hover:bg-[#4cc9ff]/10 text-[#4cc9ff]"
onClick={addItemReward}
>
<Plus className="h-3 w-3 mr-1" /> Add Item
</Button>
</div>
{formData.itemRewards.length > 0 ? (
<div className="space-y-3">
{formData.itemRewards.map((item, index) => (
<div
key={index}
className="grid gap-2 p-3 border border-[#1e2a3a] rounded-md relative"
>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute top-2 right-2 h-6 w-6 text-red-400 hover:text-red-300 hover:bg-red-900/20"
onClick={() => removeItemReward(index)}
>
<Trash2 className="h-3 w-3" />
<span className="sr-only">Remove</span>
</Button>
<div>
<Label
htmlFor={`item-name-${index}`}
className="text-xs"
>
Item Name
</Label>
<Input
id={`item-name-${index}`}
value={item.name}
onChange={(e) =>
updateItemReward(index, "name", e.target.value)
}
className="bg-[#0a0e14] border-[#1e2a3a] h-8"
required
/>
</div>
<div>
<Label
htmlFor={`item-type-${index}`}
className="text-xs"
>
Type
</Label>
<Select
value={item.type}
onValueChange={(value) =>
updateItemReward(index, "type", value)
}
>
<SelectTrigger className="bg-[#0a0e14] border-[#1e2a3a] h-8">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent className="bg-[#0a0e14] border-[#1e2a3a]">
<SelectItem value="Material">Material</SelectItem>
<SelectItem value="Consumable">
Consumable
</SelectItem>
<SelectItem value="Weapon">Weapon</SelectItem>
<SelectItem value="Armor">Armor</SelectItem>
<SelectItem value="Accessory">
Accessory
</SelectItem>
<SelectItem value="Rune">Rune</SelectItem>
</SelectContent>
</Select>
</div>
{/* Show preset consumables dropdown when Consumable type is selected */}
{item.type === "Consumable" && (
<div>
<Label
htmlFor={`item-preset-${index}`}
className="text-xs"
>
Preset Potions
</Label>
<Select
onValueChange={(presetId) => {
const preset = predefinedConsumables.find(
(p) => p.id === presetId
);
if (preset) {
updateItemReward(index, "name", preset.name);
updateItemReward(
index,
"description",
preset.description
);
// Store the ID for later use
updateItemReward(index, "id", presetId);
}
}}
>
<SelectTrigger className="bg-[#0a0e14] border-[#1e2a3a] h-8">
<SelectValue placeholder="Select a preset potion" />
</SelectTrigger>
<SelectContent className="bg-[#0a0e14] border-[#1e2a3a]">
{predefinedConsumables.map((potion) => (
<SelectItem key={potion.id} value={potion.id}>
{potion.name} ({potion.rarity})
</SelectItem>
))}
<SelectItem value="custom">
Custom Consumable
</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
))}
</div>
) : (
<div className="text-center py-3 text-[#8bacc1] text-sm border border-dashed border-[#1e2a3a] rounded-md">
No item rewards added
</div>
)}
</div>
</div>
<DialogFooter>
<Button
type="submit"
className="bg-transparent border border-[#4cc9ff] hover:bg-[#4cc9ff]/10 text-[#4cc9ff]"
>
Create Quest
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* API Key Modal */}
<APIKeyModal
open={apiKeyModalOpen}
onOpenChange={setApiKeyModalOpen}
onKeySubmit={handleAPIKeySubmit}
/>
</>
);
}

View File

@ -0,0 +1,171 @@
"use client"
import type React from "react"
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { storeAPIKey, getAPIKey } from "@/utils/ai-service"
interface APIKeyModalProps {
open: boolean
onOpenChange: (open: boolean) => void
onKeySubmit: (provider: string) => void
}
export function APIKeyModal({ open, onOpenChange, onKeySubmit }: APIKeyModalProps) {
const [apiKey, setApiKey] = useState("")
const [provider, setProvider] = useState<"openai" | "gemini">("openai")
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Check if API key already exists when modal opens
useEffect(() => {
if (open) {
const existingKey = getAPIKey()
if (existingKey) {
// If key exists, auto-submit and close modal
onKeySubmit(provider)
onOpenChange(false)
}
}
}, [open, provider, onKeySubmit, onOpenChange])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
setError(null)
try {
if (!apiKey.trim()) {
throw new Error("API key is required")
}
// Simple validation based on provider
if (provider === "openai" && !apiKey.trim().startsWith("sk-")) {
throw new Error("Invalid OpenAI API key format. OpenAI keys typically start with 'sk-'")
}
// Store the API key with the selected provider
storeAPIKey(provider, apiKey.trim())
// Call the onKeySubmit callback with the provider
onKeySubmit(provider)
// Close the modal
onOpenChange(false)
} catch (error) {
setError(error instanceof Error ? error.message : "An unknown error occurred")
} finally {
setIsSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="bg-[#0a0e14] border-[#1e2a3a] text-[#e0f2ff] w-[90%] sm:max-w-md animate-solo-modal"
style={
{
"--solo-expand-duration": "0.5s",
"--solo-expand-easing": "cubic-bezier(0.16, 1, 0.3, 1)",
} as React.CSSProperties
}
>
<DialogHeader>
<DialogTitle className="text-[#4cc9ff]">AI API Key Required</DialogTitle>
<DialogDescription className="text-[#8bacc1]">
To use AI features, please enter your API key. This key will be stored securely in your browser for future
use.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>Select AI Provider</Label>
<RadioGroup value={provider} onValueChange={(value) => setProvider(value as "openai" | "gemini")}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="openai" id="openai" />
<Label htmlFor="openai" className="cursor-pointer">
OpenAI
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="gemini" id="gemini" />
<Label htmlFor="gemini" className="cursor-pointer">
Gemini
</Label>
</div>
</RadioGroup>
</div>
<div className="grid gap-2">
<Label htmlFor="apiKey">{provider === "openai" ? "OpenAI" : "Gemini"} API Key</Label>
<Input
id="apiKey"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={provider === "openai" ? "sk-..." : "Enter your Gemini API key"}
className="bg-[#0a0e14] border-[#1e2a3a] focus-visible:ring-[#4cc9ff]"
required
/>
{error && <p className="text-red-500 text-sm">{error}</p>}
<p className="text-[#8bacc1] text-xs mt-1">
Your API key is stored locally in your browser and is never sent to our servers. You can get an API key
from{" "}
{provider === "openai" ? (
<a
href="https://platform.openai.com/api-keys"
target="_blank"
rel="noopener noreferrer"
className="text-[#4cc9ff] hover:underline"
>
OpenAI's website
</a>
) : (
<a
href="https://ai.google.dev/tutorials/setup"
target="_blank"
rel="noopener noreferrer"
className="text-[#4cc9ff] hover:underline"
>
Google AI Studio
</a>
)}
.
</p>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
className="border-[#1e2a3a] hover:bg-[#1e2a3a]"
>
Cancel
</Button>
<Button
type="submit"
className="bg-transparent border border-[#4cc9ff] hover:bg-[#4cc9ff]/10 text-[#4cc9ff]"
disabled={isSubmitting}
>
{isSubmitting ? "Saving..." : "Save API Key"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,261 @@
"use client"
import { useState } from "react"
import { Sword, Shield, Zap, Flame, Heart, Backpack, SkipForward } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import type { UserStats } from "@/utils/storage"
interface CombatActionsProps {
onAttack: () => void
onDefend: () => void
onUseSkill: (skillName: string, mpCost: number, cooldown: number) => void
onUseItem: (itemId: string) => void
onFlee: () => void
playerStats: UserStats
playerMp: number
skillCooldowns: Record<string, number>
}
export function CombatActions({
onAttack,
onDefend,
onUseSkill,
onUseItem,
onFlee,
playerStats,
playerMp,
skillCooldowns,
}: CombatActionsProps) {
const [activeTab, setActiveTab] = useState("basic")
// Define available skills based on player stats
const availableSkills = [
{
name: "Power Strike",
description: "A powerful strike that deals double damage.",
icon: <Sword className="h-4 w-4" />,
mpCost: 10,
cooldown: 2,
requiredStat: "str",
requiredValue: 15,
available: playerStats.stats.str >= 15,
},
{
name: "Double Slash",
description: "Strike twice in quick succession.",
icon: <Sword className="h-4 w-4" />,
mpCost: 15,
cooldown: 3,
requiredStat: "agi",
requiredValue: 15,
available: playerStats.stats.agi >= 15,
},
{
name: "Fireball",
description: "Cast a ball of fire that deals magical damage.",
icon: <Flame className="h-4 w-4" />,
mpCost: 20,
cooldown: 3,
requiredStat: "int",
requiredValue: 15,
available: playerStats.stats.int >= 15,
},
{
name: "Heal",
description: "Restore HP based on your intelligence.",
icon: <Heart className="h-4 w-4" />,
mpCost: 25,
cooldown: 4,
requiredStat: "int",
requiredValue: 15,
available: playerStats.stats.int >= 15,
},
]
// Filter consumable items from inventory
const consumableItems = playerStats.inventory.filter((item) => item.type === "Consumable")
return (
<Card className="bg-[#0a0e14]/80 border-[#1e2a3a] relative">
<div className="absolute inset-0 border border-[#4cc9ff]/10"></div>
<CardContent className="p-4 relative z-10">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3 bg-[#1e2a3a] border border-[#1e2a3a]">
<TabsTrigger
value="basic"
className="data-[state=active]:bg-[#0a0e14] data-[state=active]:border-t-2 data-[state=active]:border-[#4cc9ff]"
>
Basic Actions
</TabsTrigger>
<TabsTrigger
value="skills"
className="data-[state=active]:bg-[#0a0e14] data-[state=active]:border-t-2 data-[state=active]:border-[#4cc9ff]"
>
Skills
</TabsTrigger>
<TabsTrigger
value="items"
className="data-[state=active]:bg-[#0a0e14] data-[state=active]:border-t-2 data-[state=active]:border-[#4cc9ff]"
>
Items
</TabsTrigger>
</TabsList>
<TabsContent value="basic" className="mt-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
className="w-full bg-transparent border border-[#4cc9ff] hover:bg-[#4cc9ff]/10 text-[#4cc9ff] flex items-center justify-center"
onClick={onAttack}
>
<Sword className="h-4 w-4 mr-2" />
Attack
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Basic attack using your strength.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
className="w-full bg-transparent border border-[#4cc9ff] hover:bg-[#4cc9ff]/10 text-[#4cc9ff] flex items-center justify-center"
onClick={onDefend}
>
<Shield className="h-4 w-4 mr-2" />
Defend
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Take a defensive stance, reducing incoming damage by 50%.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
className="w-full bg-red-900/50 border border-red-700 hover:bg-red-800/50 text-red-200 flex items-center justify-center"
onClick={onFlee}
>
<SkipForward className="h-4 w-4 mr-2" />
Flee
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Attempt to flee from combat. Success chance based on agility.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</TabsContent>
<TabsContent value="skills" className="mt-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{availableSkills.map((skill) => (
<TooltipProvider key={skill.name}>
<Tooltip>
<TooltipTrigger asChild>
<Button
className={`w-full flex items-center justify-between ${
skill.available &&
playerMp >= skill.mpCost &&
(!skillCooldowns[skill.name] || skillCooldowns[skill.name] <= 0)
? "bg-transparent border border-[#4cc9ff] hover:bg-[#4cc9ff]/10 text-[#4cc9ff]"
: "bg-[#1e2a3a] border border-[#1e2a3a] text-[#8bacc1] cursor-not-allowed"
}`}
onClick={() => {
if (
skill.available &&
playerMp >= skill.mpCost &&
(!skillCooldowns[skill.name] || skillCooldowns[skill.name] <= 0)
) {
onUseSkill(skill.name, skill.mpCost, skill.cooldown)
}
}}
disabled={
!skill.available ||
playerMp < skill.mpCost ||
(skillCooldowns[skill.name] && skillCooldowns[skill.name] > 0)
}
>
<div className="flex items-center">
{skill.icon}
<span className="ml-2">{skill.name}</span>
</div>
<div className="flex items-center text-xs">
<Zap className="h-3 w-3 mr-1 text-blue-400" />
{skill.mpCost} MP
{skillCooldowns[skill.name] && skillCooldowns[skill.name] > 0 && (
<span className="ml-2 bg-[#0a0e14] px-1 rounded">CD: {skillCooldowns[skill.name]}</span>
)}
</div>
</Button>
</TooltipTrigger>
<TooltipContent>
<div className="space-y-1">
<p>{skill.description}</p>
{!skill.available && (
<p className="text-red-400">
Requires {skill.requiredStat.toUpperCase()} {skill.requiredValue}
</p>
)}
<p className="text-xs">
MP Cost: {skill.mpCost} | Cooldown: {skill.cooldown} turns
</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
{availableSkills.filter((skill) => skill.available).length === 0 && (
<div className="col-span-2 text-center py-4 text-[#8bacc1]">
No skills available. Increase your stats to unlock skills.
</div>
)}
</div>
</TabsContent>
<TabsContent value="items" className="mt-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{consumableItems.length > 0 ? (
consumableItems.map((item) => (
<TooltipProvider key={item.id}>
<Tooltip>
<TooltipTrigger asChild>
<Button
className="w-full bg-transparent border border-green-600 hover:bg-green-900/20 text-green-400 flex items-center justify-between"
onClick={() => onUseItem(item.id)}
>
<div className="flex items-center">
<Backpack className="h-4 w-4 mr-2" />
<span>{item.name}</span>
</div>
<span className="text-xs">x{item.quantity}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{item.description}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
))
) : (
<div className="col-span-2 text-center py-4 text-[#8bacc1]">No consumable items in your inventory.</div>
)}
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
)
}

42
components/combat-log.tsx Normal file
View File

@ -0,0 +1,42 @@
"use client"
import { useEffect, useRef } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
interface CombatLogProps {
messages: string[]
}
export function CombatLog({ messages }: CombatLogProps) {
const logEndRef = useRef<HTMLDivElement>(null)
// Auto-scroll to the bottom when new messages are added
useEffect(() => {
if (logEndRef.current) {
logEndRef.current.scrollIntoView({ behavior: "smooth" })
}
}, [messages])
return (
<Card className="bg-[#0a0e14]/80 border-[#1e2a3a] relative h-full">
<div className="absolute inset-0 border border-[#4cc9ff]/10"></div>
<CardHeader className="pb-2 relative z-10">
<CardTitle className="text-[#4cc9ff]">Combat Log</CardTitle>
</CardHeader>
<CardContent className="relative z-10 h-[400px] overflow-y-auto scrollbar-thin scrollbar-thumb-[#1e2a3a] scrollbar-track-transparent">
<div className="space-y-2">
{messages.length === 0 ? (
<div className="text-center text-[#8bacc1] py-4">No combat activity yet.</div>
) : (
messages.map((message, index) => (
<div key={index} className="text-sm border-l-2 border-[#1e2a3a] pl-2 py-1">
{message}
</div>
))
)}
<div ref={logEndRef} />
</div>
</CardContent>
</Card>
)
}

View File

@ -0,0 +1,587 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Heart, Zap, Info, Shield } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
interface CombatVisualizationProps {
playerName: string;
enemyName: string;
isPlayerTurn: boolean;
isAttacking: boolean;
isDefending: boolean;
attackDamage?: number;
isCritical?: boolean;
skillName?: string;
playerHp: number;
playerMaxHp: number;
playerMp: number;
playerMaxMp: number;
playerLevel: number;
enemyHp: number;
enemyMaxHp: number;
playerStats: {
str: number;
agi: number;
per: number;
int: number;
vit: number;
};
onAnimationComplete: () => void;
}
export function CombatVisualization({
playerName,
enemyName,
isPlayerTurn,
isAttacking,
isDefending,
attackDamage = 0,
isCritical = false,
skillName,
playerHp,
playerMaxHp,
playerMp,
playerMaxMp,
playerLevel,
enemyHp,
enemyMaxHp,
playerStats,
onAnimationComplete,
}: CombatVisualizationProps) {
// Base canvas dimensions (will be scaled)
const baseWidth = 400;
const baseHeight = 300;
// State for canvas size
const [canvasSize, setCanvasSize] = useState({
width: baseWidth,
height: baseHeight,
});
// Container ref to measure available width
const containerRef = useRef<HTMLDivElement>(null);
const [playerPosition, setPlayerPosition] = useState({ x: 100, y: 200 });
const [enemyPosition, setEnemyPosition] = useState({ x: 300, y: 200 });
const [playerColor, setPlayerColor] = useState("#4cc9ff");
const [enemyColor, setEnemyColor] = useState("#ff4c4c");
const [effectPosition, setEffectPosition] = useState({ x: 0, y: 0 });
const [showEffect, setShowEffect] = useState(false);
const [effectType, setEffectType] = useState<"attack" | "defend" | "damage">(
"attack"
);
const [damageText, setDamageText] = useState("");
const [showDamageText, setShowDamageText] = useState(false);
const [damageTextPosition, setDamageTextPosition] = useState({ x: 0, y: 0 });
const [showStatsDialog, setShowStatsDialog] = useState(false);
const [combatMessage, setCombatMessage] = useState("");
const [showCombatMessage, setShowCombatMessage] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const animationRef = useRef<number>(0);
const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Resize handler for responsive canvas
useEffect(() => {
const handleResize = () => {
if (containerRef.current) {
// Get container width
const containerWidth = containerRef.current.clientWidth;
// Calculate responsive canvas size
let newWidth = containerWidth;
// Cap maximum width for larger screens (to avoid too large visualizations)
const maxWidth = 600;
newWidth = Math.min(newWidth, maxWidth);
// Maintain aspect ratio
const aspectRatio = baseHeight / baseWidth;
const newHeight = Math.floor(newWidth * aspectRatio);
// Update canvas size
setCanvasSize({ width: newWidth, height: newHeight });
// Update player and enemy positions based on new dimensions
const scaleX = newWidth / baseWidth;
setPlayerPosition({
x: Math.floor(100 * scaleX),
y: Math.floor(200 * (newHeight / baseHeight)),
});
setEnemyPosition({
x: Math.floor(300 * scaleX),
y: Math.floor(200 * (newHeight / baseHeight)),
});
}
};
// Initial sizing
handleResize();
// Set up resize listener
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, [baseWidth, baseHeight]);
// Animation logic
useEffect(() => {
if (isAttacking) {
// Determine who is attacking and who is receiving
const isPlayerAttacking = isPlayerTurn;
// Set combat message
if (isPlayerAttacking) {
if (skillName) {
setCombatMessage(
`You used ${skillName} for ${attackDamage} damage${
isCritical ? " (Critical!)" : ""
}!`
);
} else {
setCombatMessage(
`You attacked for ${attackDamage} damage${
isCritical ? " (Critical!)" : ""
}!`
);
}
} else {
setCombatMessage(
`${enemyName} attacked you for ${attackDamage} damage!`
);
}
setShowCombatMessage(true);
// Clear any existing timeout
if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current);
}
// Set timeout to hide message
messageTimeoutRef.current = setTimeout(() => {
setShowCombatMessage(false);
}, 2000);
if (isPlayerAttacking) {
// Player attacking enemy - static effect at enemy position
setEffectType("damage");
setEffectPosition({ x: enemyPosition.x, y: enemyPosition.y });
setShowEffect(true);
// Set damage text
if (skillName) {
setDamageText(
`${skillName}! ${attackDamage}${isCritical ? " CRIT!" : ""}`
);
} else {
setDamageText(`${attackDamage}${isCritical ? " CRIT!" : ""}`);
}
// Show damage text
setDamageTextPosition({
x: enemyPosition.x,
y: enemyPosition.y - 50,
});
setShowDamageText(true);
// Show effect for a short time
const timer = setTimeout(() => {
setShowEffect(false);
setShowDamageText(false);
onAnimationComplete();
}, 800);
return () => clearTimeout(timer);
} else {
// Enemy attacking player - static effect at player position
setEffectType("damage");
setEffectPosition({ x: playerPosition.x, y: playerPosition.y });
setShowEffect(true);
// Set damage text
setDamageText(`${attackDamage}`);
// Show damage text
setDamageTextPosition({
x: playerPosition.x,
y: playerPosition.y - 50,
});
setShowDamageText(true);
// Show effect for a short time
const timer = setTimeout(() => {
setShowEffect(false);
setShowDamageText(false);
onAnimationComplete();
}, 800);
return () => clearTimeout(timer);
}
} else if (isDefending) {
// Defending animation
setEffectType("defend");
setEffectPosition(isPlayerTurn ? playerPosition : enemyPosition);
setShowEffect(true);
// Set defend text
setDamageText("Defending!");
setDamageTextPosition({
x: isPlayerTurn ? playerPosition.x : enemyPosition.x,
y: (isPlayerTurn ? playerPosition.y : enemyPosition.y) - 50,
});
setShowDamageText(true);
// Set combat message
setCombatMessage("You are defending! Damage reduced by 50%");
setShowCombatMessage(true);
// Clear any existing timeout
if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current);
}
// Set timeout to hide message
messageTimeoutRef.current = setTimeout(() => {
setShowCombatMessage(false);
}, 2000);
// Show defend effect for a short time
const timer = setTimeout(() => {
setShowEffect(false);
setShowDamageText(false);
onAnimationComplete();
}, 500);
return () => clearTimeout(timer);
}
return () => {
cancelAnimationFrame(animationRef.current);
if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current);
}
};
}, [
isAttacking,
isDefending,
isPlayerTurn,
onAnimationComplete,
attackDamage,
isCritical,
skillName,
enemyName,
playerPosition,
enemyPosition,
]);
// Draw the combat scene
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// Scale for responsive drawing
const scaleX = canvasSize.width / baseWidth;
const scaleY = canvasSize.height / baseHeight;
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw background
ctx.fillStyle = "#0a0e14";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw player
ctx.fillStyle = playerColor;
ctx.beginPath();
ctx.arc(playerPosition.x, playerPosition.y, 15 * scaleY, 0, Math.PI * 2);
ctx.fill();
// Draw player name
ctx.fillStyle = "#e0f2ff";
ctx.font = `${14 * scaleY}px sans-serif`;
ctx.textAlign = "center";
ctx.fillText(playerName, playerPosition.x, playerPosition.y - 30 * scaleY);
// Draw enemy
ctx.fillStyle = enemyColor;
ctx.beginPath();
ctx.arc(enemyPosition.x, enemyPosition.y, 15 * scaleY, 0, Math.PI * 2);
ctx.fill();
// Draw enemy name
ctx.fillStyle = "#e0f2ff";
ctx.font = `${14 * scaleY}px sans-serif`;
ctx.textAlign = "center";
ctx.fillText(enemyName, enemyPosition.x, enemyPosition.y - 30 * scaleY);
// Draw effect if needed
if (showEffect) {
if (effectType === "attack") {
// Draw slash effect
ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 3 * scaleY;
ctx.beginPath();
ctx.moveTo(
effectPosition.x - 15 * scaleX,
effectPosition.y - 15 * scaleY
);
ctx.lineTo(
effectPosition.x + 15 * scaleX,
effectPosition.y + 15 * scaleY
);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(
effectPosition.x + 15 * scaleX,
effectPosition.y - 15 * scaleY
);
ctx.lineTo(
effectPosition.x - 15 * scaleX,
effectPosition.y + 15 * scaleY
);
ctx.stroke();
} else if (effectType === "defend") {
// Draw shield effect
ctx.strokeStyle = "#4cc9ff";
ctx.lineWidth = 3 * scaleY;
ctx.beginPath();
ctx.arc(
effectPosition.x,
effectPosition.y,
25 * scaleY,
0,
Math.PI * 2
);
ctx.stroke();
} else if (effectType === "damage") {
// Draw damage effect
ctx.fillStyle = "rgba(255, 0, 0, 0.5)";
ctx.beginPath();
ctx.arc(
effectPosition.x,
effectPosition.y,
25 * scaleY,
0,
Math.PI * 2
);
ctx.fill();
// Draw impact lines
ctx.strokeStyle = "#ff4c4c";
ctx.lineWidth = 2 * scaleY;
for (let i = 0; i < 8; i++) {
const angle = ((Math.PI * 2) / 8) * i;
ctx.beginPath();
ctx.moveTo(effectPosition.x, effectPosition.y);
ctx.lineTo(
effectPosition.x + Math.cos(angle) * 30 * scaleX,
effectPosition.y + Math.sin(angle) * 30 * scaleY
);
ctx.stroke();
}
}
}
// Draw damage text
if (showDamageText) {
ctx.font = `bold ${16 * scaleY}px sans-serif`;
ctx.textAlign = "center";
// Draw text with outline for better visibility
if (isCritical) {
ctx.fillStyle = "#ff0000";
} else {
ctx.fillStyle = "#ffffff";
}
// Draw text shadow/outline
ctx.strokeStyle = "#000000";
ctx.lineWidth = 3 * scaleY;
ctx.strokeText(damageText, damageTextPosition.x, damageTextPosition.y);
// Draw text
ctx.fillText(damageText, damageTextPosition.x, damageTextPosition.y);
}
// Draw arena floor
ctx.fillStyle = "#1e2a3a";
ctx.fillRect(50 * scaleX, 230 * scaleY, 300 * scaleX, 20 * scaleY);
}, [
playerPosition,
enemyPosition,
playerColor,
enemyColor,
showEffect,
effectPosition,
effectType,
playerName,
enemyName,
showDamageText,
damageText,
damageTextPosition,
isCritical,
canvasSize,
baseWidth,
baseHeight,
]);
return (
<Card className="bg-[#0a0e14]/80 border-[#1e2a3a] relative">
<div className="absolute inset-0 border border-[#4cc9ff]/10"></div>
<CardContent className="p-4 relative z-10">
{/* Stats Bar with vertical alignment */}
<div className="flex flex-col gap-4 mb-4">
{/* Player and Enemy stats row */}
<div className="grid grid-cols-2 gap-4">
{/* Player side */}
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-xs">{playerName}</span>
<span className="text-xs">Lv.{playerLevel}</span>
</div>
<div className="flex items-center mb-1">
<Heart className="h-3 w-3 text-red-400 mr-1" />
<Progress
value={(playerHp / playerMaxHp) * 100}
className="h-2 bg-[#1e2a3a] flex-1"
>
<div className="h-full bg-gradient-to-r from-red-500 to-red-600 rounded-full" />
</Progress>
<span className="text-xs ml-2">
{playerHp}/{playerMaxHp}
</span>
</div>
<div className="flex items-center">
<Zap className="h-3 w-3 text-blue-400 mr-1" />
<Progress
value={(playerMp / playerMaxMp) * 100}
className="h-2 bg-[#1e2a3a] flex-1"
>
<div className="h-full bg-gradient-to-r from-blue-500 to-blue-600 rounded-full" />
</Progress>
<span className="text-xs ml-2">
{playerMp}/{playerMaxMp}
</span>
</div>
</div>
{/* Enemy side */}
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-xs">{enemyName}</span>
<span className="text-xs">
Lv.{enemyName.includes("Lv") ? "" : ""}
</span>
</div>
<div className="flex items-center mb-1">
<Heart className="h-3 w-3 text-red-400 mr-1" />
<Progress
value={(enemyHp / enemyMaxHp) * 100}
className="h-2 bg-[#1e2a3a] flex-1"
>
<div className="h-full bg-gradient-to-r from-red-500 to-red-600 rounded-full" />
</Progress>
<span className="text-xs ml-2">
{enemyHp}/{enemyMaxHp}
</span>
</div>
</div>
</div>
{/* Turn Status row */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Dialog open={showStatsDialog} onOpenChange={setShowStatsDialog}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
<Info className="h-3 w-3" />
<span className="sr-only">Stats</span>
</Button>
</DialogTrigger>
<DialogContent className="bg-[#0a0e14] border-[#1e2a3a] text-[#e0f2ff]">
<DialogHeader>
<DialogTitle className="text-[#4cc9ff]">
Player Stats
</DialogTitle>
<DialogDescription className="text-[#8bacc1]">
Detailed stats for {playerName}
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="flex items-center">
<span className="text-[#8bacc1] mr-2">STR:</span>
<span>{playerStats.str}</span>
</div>
<div className="flex items-center">
<span className="text-[#8bacc1] mr-2">VIT:</span>
<span>{playerStats.vit}</span>
</div>
<div className="flex items-center">
<span className="text-[#8bacc1] mr-2">AGI:</span>
<span>{playerStats.agi}</span>
</div>
<div className="flex items-center">
<span className="text-[#8bacc1] mr-2">INT:</span>
<span>{playerStats.int}</span>
</div>
<div className="flex items-center">
<span className="text-[#8bacc1] mr-2">PER:</span>
<span>{playerStats.per}</span>
</div>
</div>
</DialogContent>
</Dialog>
{isDefending && (
<Badge className="bg-blue-700 text-xs flex items-center">
<Shield className="h-3 w-3 mr-1" /> Defending
</Badge>
)}
</div>
<Badge
className={`text-xs ${
isPlayerTurn ? "bg-green-700" : "bg-red-700"
}`}
>
{isPlayerTurn ? "Your Turn" : "Enemy Turn"}
</Badge>
</div>
</div>
{showCombatMessage && (
<div className="absolute top-16 left-0 right-0 z-20 flex justify-center">
<div className="bg-[#0a0e14]/90 border border-[#4cc9ff]/30 px-4 py-2 rounded-md text-sm animate-fadeIn">
{combatMessage}
</div>
</div>
)}
<div ref={containerRef} className="flex justify-center w-full">
<canvas
ref={canvasRef}
width={canvasSize.width}
height={canvasSize.height}
className="border border-[#1e2a3a] rounded-md max-w-full"
/>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,162 @@
"use client"
import { useState } from "react"
import { Search, 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 { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import type { Enemy } from "@/data/enemies"
interface EnemySelectionProps {
enemies: Enemy[]
onSelectEnemy: (enemy: Enemy) => void
}
export function EnemySelection({ enemies, onSelectEnemy }: EnemySelectionProps) {
const [searchTerm, setSearchTerm] = useState("")
const [selectedDifficulty, setSelectedDifficulty] = useState<string | null>(null)
// Filter enemies based on search term and difficulty
const filteredEnemies = enemies.filter(
(enemy) =>
(enemy.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(enemy.description && enemy.description.toLowerCase().includes(searchTerm.toLowerCase()))) &&
(selectedDifficulty === null || enemy.level.toString().includes(selectedDifficulty)),
)
// Group enemies by difficulty level
const difficultyLevels = ["Beginner (1-20)", "Intermediate (21-40)", "Advanced (41-60)"]
const getDifficultyFilter = (difficulty: string) => {
switch (difficulty) {
case "Beginner (1-20)":
return "1-20"
case "Intermediate (21-40)":
return "21-40"
case "Advanced (41-60)":
return "41-60"
default:
return null
}
}
return (
<div className="space-y-6">
<Card className="bg-[#0a0e14]/80 border-[#1e2a3a] relative">
<div className="absolute inset-0 border border-[#4cc9ff]/10"></div>
<CardHeader className="pb-2 relative z-10">
<CardTitle className="text-[#4cc9ff]">Select an Enemy</CardTitle>
<CardDescription>Choose an enemy to fight in the arena</CardDescription>
</CardHeader>
<CardContent className="relative z-10">
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#8bacc1]" />
<Input
placeholder="Search enemies..."
className="pl-9 bg-[#0a0e14] border-[#1e2a3a] focus-visible:ring-[#4cc9ff]"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex flex-wrap gap-2 mb-4">
{difficultyLevels.map((difficulty) => (
<Badge
key={difficulty}
className={`cursor-pointer ${
selectedDifficulty === getDifficultyFilter(difficulty)
? "bg-[#4cc9ff] text-[#0a0e14]"
: "bg-[#1e2a3a] hover:bg-[#2a3a4a]"
}`}
onClick={() => {
if (selectedDifficulty === getDifficultyFilter(difficulty)) {
setSelectedDifficulty(null)
} else {
setSelectedDifficulty(getDifficultyFilter(difficulty))
}
}}
>
{difficulty}
</Badge>
))}
{selectedDifficulty && (
<Badge className="bg-red-900 hover:bg-red-800 cursor-pointer" onClick={() => setSelectedDifficulty(null)}>
Clear Filter
</Badge>
)}
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{filteredEnemies.map((enemy) => (
<Card
key={enemy.id}
className="bg-[#0a0e14]/80 border-[#1e2a3a] relative cursor-pointer hover:border-[#4cc9ff]/30 transition-colors"
onClick={() => onSelectEnemy(enemy)}
>
<div className="absolute inset-0 border border-[#4cc9ff]/10"></div>
<CardHeader className="pb-2 relative z-10">
<div className="flex justify-between items-start">
<CardTitle className="text-base">{enemy.name}</CardTitle>
<Badge className="bg-[#1e2a3a]">Level {enemy.level}</Badge>
</div>
<CardDescription className="line-clamp-2">{enemy.description}</CardDescription>
</CardHeader>
<CardContent className="relative z-10">
<div className="grid grid-cols-5 gap-2 text-xs mb-4">
<div className="flex flex-col items-center p-1 bg-[#1e2a3a] rounded-md">
<Shield className="h-3 w-3 text-[#4cc9ff] mb-1" />
<span className="text-[#8bacc1]">STR</span>
<span>{enemy.stats.str}</span>
</div>
<div className="flex flex-col items-center p-1 bg-[#1e2a3a] rounded-md">
<Heart className="h-3 w-3 text-[#4cc9ff] mb-1" />
<span className="text-[#8bacc1]">VIT</span>
<span>{enemy.stats.vit}</span>
</div>
<div className="flex flex-col items-center p-1 bg-[#1e2a3a] rounded-md">
<Zap className="h-3 w-3 text-[#4cc9ff] mb-1" />
<span className="text-[#8bacc1]">AGI</span>
<span>{enemy.stats.agi}</span>
</div>
<div className="flex flex-col items-center p-1 bg-[#1e2a3a] rounded-md">
<Brain className="h-3 w-3 text-[#4cc9ff] mb-1" />
<span className="text-[#8bacc1]">INT</span>
<span>{enemy.stats.int}</span>
</div>
<div className="flex flex-col items-center p-1 bg-[#1e2a3a] rounded-md">
<Eye className="h-3 w-3 text-[#4cc9ff] mb-1" />
<span className="text-[#8bacc1]">PER</span>
<span>{enemy.stats.per}</span>
</div>
</div>
<div className="text-xs">
<div className="flex justify-between mb-1">
<span className="text-[#8bacc1]">EXP Reward:</span>
<span className="text-[#4cc9ff]">{enemy.rewards.exp}</span>
</div>
<div className="flex justify-between">
<span className="text-[#8bacc1]">Gold Reward:</span>
<span className="text-yellow-400">{enemy.rewards.gold}</span>
</div>
</div>
</CardContent>
<CardFooter className="relative z-10">
<Button className="w-full bg-transparent border border-[#4cc9ff] hover:bg-[#4cc9ff]/10 text-[#4cc9ff]">
Fight
</Button>
</CardFooter>
</Card>
))}
{filteredEnemies.length === 0 && (
<div className="col-span-2 text-center py-8 text-[#8bacc1] bg-[#0a0e14]/80 border border-[#1e2a3a] rounded-lg">
No enemies match your search criteria.
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,62 @@
"use client";
import { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface LevelUpModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
levelUpCount: number;
}
export function LevelUpModal({
open,
onOpenChange,
levelUpCount,
}: LevelUpModalProps) {
const [audioPlayed, setAudioPlayed] = useState(false);
useEffect(() => {
if (open && !audioPlayed) {
// Create and play the audio when the modal is opened
const audio = new Audio("/glitch-screen.mp3");
audio.play().catch((error) => {
console.error("Error playing audio:", error);
});
setAudioPlayed(true);
}
if (!open) {
// Reset audio played state when modal is closed
setAudioPlayed(false);
}
}, [open, audioPlayed]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md bg-[#0a0e14]/95 border-[#1e2a3a] text-[#e0f2ff]">
<DialogHeader>
<DialogTitle className="text-center text-xl font-bold text-[#4cc9ff]">
NOTIFICATION
</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center justify-center py-6">
{/* Repeat "Leveled Up!" based on levelUpCount */}
{Array.from({ length: levelUpCount }).map((_, index) => (
<div
key={index}
className="text-xl font-bold text-[#4cc9ff] mb-2 animate-pulse"
>
Leveled Up!
</div>
))}
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,26 @@
"use client";
import { useUser } from "@/context/user-context";
import { LevelUpModal } from "@/components/level-up-modal";
export function LevelUpNotification() {
const {
showLevelUpModal,
setShowLevelUpModal,
levelUpCount,
resetLevelUpCount,
} = useUser();
const handleCloseModal = () => {
setShowLevelUpModal(false);
resetLevelUpCount();
};
return (
<LevelUpModal
open={showLevelUpModal}
onOpenChange={handleCloseModal}
levelUpCount={levelUpCount}
/>
);
}

61
components/mobile-nav.tsx Normal file
View File

@ -0,0 +1,61 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { Home, Scroll, Backpack, Sword, Shield } from "lucide-react"
export function MobileNav() {
const pathname = usePathname()
const navItems = [
{
name: "Home",
href: "/",
icon: <Home className="h-5 w-5" />,
},
{
name: "Quests",
href: "/quests",
icon: <Scroll className="h-5 w-5" />,
},
{
name: "Inventory",
href: "/inventory",
icon: <Backpack className="h-5 w-5" />,
},
{
name: "Combat",
href: "/combat",
icon: <Sword className="h-5 w-5" />,
},
{
name: "Stats",
href: "/stats",
icon: <Shield className="h-5 w-5" />,
},
]
return (
<div className="fixed bottom-0 left-0 right-0 z-50 md:hidden bg-[#0a0e14] border-t border-[#1e2a3a] shadow-lg">
<div className="flex justify-around items-center h-16">
{navItems.map((item) => {
const isActive = pathname === item.href
return (
<Link
key={item.name}
href={item.href}
className={`flex flex-col items-center justify-center w-full h-full ${
isActive ? "text-[#4cc9ff]" : "text-[#8bacc1] hover:text-[#e0f2ff]"
}`}
>
<div className="flex flex-col items-center">
{item.icon}
<span className="text-xs mt-1">{item.name}</span>
</div>
</Link>
)
})}
</div>
</div>
)
}

View File

@ -0,0 +1,124 @@
"use client";
import type React from "react";
import { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useUser } from "@/context/user-context";
export function NameInputModal() {
const { userStats, setUserStats } = useUser();
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [hasCheckedStorage, setHasCheckedStorage] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
// Initialize audio element
useEffect(() => {
audioRef.current = new Audio("/glitch-screen.mp3");
return () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
};
}, []);
// Check if name is empty on component mount
useEffect(() => {
// Only check once to prevent reopening after saving
if (!hasCheckedStorage) {
// Check localStorage directly to avoid race conditions with context loading
const storedStats = localStorage.getItem("soloLevelUpUserStats");
let shouldOpen = false;
if (storedStats) {
const parsedStats = JSON.parse(storedStats);
shouldOpen = !parsedStats.name || parsedStats.name.trim() === "";
} else {
shouldOpen = true;
}
setOpen(shouldOpen);
setHasCheckedStorage(true);
}
}, [hasCheckedStorage, userStats.name]);
// Play sound effect when modal opens
useEffect(() => {
if (open && audioRef.current) {
// Reset the audio to start from beginning if it was played before
audioRef.current.currentTime = 0;
audioRef.current.play().catch((err) => {
console.error("Error playing audio:", err);
});
}
}, [open]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (name.trim()) {
setUserStats((prev) => ({
...prev,
name: name.trim(),
}));
setOpen(false);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
className="bg-[#0a0e14] border-[#1e2a3a] text-[#e0f2ff] w-[90%] sm:max-w-md animate-solo-modal"
style={
{
"--solo-expand-duration": "0.5s",
"--solo-expand-easing": "cubic-bezier(0.16, 1, 0.3, 1)",
} as React.CSSProperties
}
>
<DialogHeader>
<DialogTitle className="text-[#4cc9ff]">Welcome, Hunter</DialogTitle>
<DialogDescription className="text-[#8bacc1]">
Before you begin your journey, please tell us your name.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Your Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter your name"
className="bg-[#0a0e14] border-[#1e2a3a] focus-visible:ring-[#4cc9ff]"
required
autoFocus
/>
</div>
</div>
<DialogFooter>
<Button
type="submit"
className="w-full bg-transparent border border-[#4cc9ff] hover:bg-[#4cc9ff]/10 text-[#4cc9ff]"
disabled={!name.trim()}
>
Begin Adventure
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,120 @@
"use client"
import type React from "react"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { storeOpenAIKey } from "@/utils/openai"
interface OpenAIKeyModalProps {
open: boolean
onOpenChange: (open: boolean) => void
onKeySubmit: () => void
}
export function OpenAIKeyModal({ open, onOpenChange, onKeySubmit }: OpenAIKeyModalProps) {
const [apiKey, setApiKey] = useState("")
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
setError(null)
try {
if (!apiKey.trim()) {
throw new Error("API key is required")
}
// Simple validation - OpenAI keys typically start with "sk-"
if (!apiKey.trim().startsWith("sk-")) {
throw new Error("Invalid API key format. OpenAI keys typically start with 'sk-'")
}
// Store the API key
storeOpenAIKey(apiKey.trim())
// Call the onKeySubmit callback
onKeySubmit()
// Close the modal
onOpenChange(false)
} catch (error) {
setError(error instanceof Error ? error.message : "An unknown error occurred")
} finally {
setIsSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-[#0a0e14] border-[#1e2a3a] text-[#e0f2ff]">
<DialogHeader>
<DialogTitle className="text-[#4cc9ff]">OpenAI API Key Required</DialogTitle>
<DialogDescription className="text-[#8bacc1]">
To use AI features, please enter your OpenAI API key. This key will be stored securely in your browser for
future use.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="apiKey">OpenAI API Key</Label>
<Input
id="apiKey"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-..."
className="bg-[#0a0e14] border-[#1e2a3a] focus-visible:ring-[#4cc9ff]"
required
/>
{error && <p className="text-red-500 text-sm">{error}</p>}
<p className="text-[#8bacc1] text-xs mt-1">
Your API key is stored locally in your browser and is never sent to our servers. You can get an API key
from{" "}
<a
href="https://platform.openai.com/api-keys"
target="_blank"
rel="noopener noreferrer"
className="text-[#4cc9ff] hover:underline"
>
OpenAI's website
</a>
.
</p>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
className="border-[#1e2a3a] hover:bg-[#1e2a3a]"
>
Cancel
</Button>
<Button
type="submit"
className="bg-transparent border border-[#4cc9ff] hover:bg-[#4cc9ff]/10 text-[#4cc9ff]"
disabled={isSubmitting}
>
{isSubmitting ? "Saving..." : "Save API Key"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,11 @@
'use client'
import * as React from 'react'
import {
ThemeProvider as NextThemesProvider,
type ThemeProviderProps,
} from 'next-themes'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View File

@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@ -0,0 +1,141 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

59
components/ui/alert.tsx Normal file
View File

@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@ -0,0 +1,7 @@
"use client"
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
const AspectRatio = AspectRatioPrimitive.Root
export { AspectRatio }

50
components/ui/avatar.tsx Normal file
View File

@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

36
components/ui/badge.tsx Normal file
View File

@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,115 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
))
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
))
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

56
components/ui/button.tsx Normal file
View File

@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { DayPicker } from "react-day-picker"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(
buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
)
}
Calendar.displayName = "Calendar"
export { Calendar }

79
components/ui/card.tsx Normal file
View File

@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

262
components/ui/carousel.tsx Normal file
View File

@ -0,0 +1,262 @@
"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return
}
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) {
return
}
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) {
return
}
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
)
Carousel.displayName = "Carousel"
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel()
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
})
CarouselContent.displayName = "CarouselContent"
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel()
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
})
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
)
})
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
)
})
CarouselNext.displayName = "CarouselNext"
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

365
components/ui/chart.tsx Normal file
View File

@ -0,0 +1,365 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([_, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item.dataKey || item.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
)
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@ -0,0 +1,11 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

153
components/ui/command.tsx Normal file
View File

@ -0,0 +1,153 @@
"use client"
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuPortal = ContextMenuPrimitive.Portal
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

122
components/ui/dialog.tsx Normal file
View File

@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

118
components/ui/drawer.tsx Normal file
View File

@ -0,0 +1,118 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
)
Drawer.displayName = "Drawer"
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
)
DrawerHeader.displayName = "DrawerHeader"
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

178
components/ui/form.tsx Normal file
View File

@ -0,0 +1,178 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@ -0,0 +1,71 @@
"use client"
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { Dot } from "lucide-react"
import { cn } from "@/lib/utils"
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
))
InputOTP.displayName = "InputOTP"
const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
))
InputOTPGroup.displayName = "InputOTPGroup"
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
return (
<div
ref={ref}
className={cn(
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-2 ring-ring ring-offset-background",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
)
})
InputOTPSlot.displayName = "InputOTPSlot"
const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Dot />
</div>
))
InputOTPSeparator.displayName = "InputOTPSeparator"
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

22
components/ui/input.tsx Normal file
View File

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

26
components/ui/label.tsx Normal file
View File

@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

236
components/ui/menubar.tsx Normal file
View File

@ -0,0 +1,236 @@
"use client"
import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const MenubarMenu = MenubarPrimitive.Menu
const MenubarGroup = MenubarPrimitive.Group
const MenubarPortal = MenubarPrimitive.Portal
const MenubarSub = MenubarPrimitive.Sub
const MenubarRadioGroup = MenubarPrimitive.RadioGroup
const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn(
"flex h-10 items-center space-x-1 rounded-md border bg-background p-1",
className
)}
{...props}
/>
))
Menubar.displayName = MenubarPrimitive.Root.displayName
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className
)}
{...props}
/>
))
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
))
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
>(
(
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
ref
) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</MenubarPrimitive.Portal>
)
)
MenubarContent.displayName = MenubarPrimitive.Content.displayName
const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
MenubarItem.displayName = MenubarPrimitive.Item.displayName
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
))
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<MenubarPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
))
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
const MenubarShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
MenubarShortcut.displayname = "MenubarShortcut"
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
}

View File

@ -0,0 +1,128 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
"relative z-10 flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
))
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
className
)}
{...props}
/>
))
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
const NavigationMenuItem = NavigationMenuPrimitive.Item
const navigationMenuTriggerStyle = cva(
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50"
)
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
))
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
className
)}
{...props}
/>
))
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
const NavigationMenuLink = NavigationMenuPrimitive.Link
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
ref={ref}
{...props}
/>
</div>
))
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
))
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
}

View File

@ -0,0 +1,117 @@
import * as React from "react"
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
import { ButtonProps, buttonVariants } from "@/components/ui/button"
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
Pagination.displayName = "Pagination"
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
))
PaginationContent.displayName = "PaginationContent"
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
))
PaginationItem.displayName = "PaginationItem"
type PaginationLinkProps = {
isActive?: boolean
} & Pick<ButtonProps, "size"> &
React.ComponentProps<"a">
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
PaginationLink.displayName = "PaginationLink"
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
)
PaginationPrevious.displayName = "PaginationPrevious"
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
)
PaginationNext.displayName = "PaginationNext"
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
PaginationEllipsis.displayName = "PaginationEllipsis"
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}

31
components/ui/popover.tsx Normal file
View File

@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View File

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@ -0,0 +1,45 @@
"use client"
import { GripVertical } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
const ResizablePanel = ResizablePrimitive.Panel
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

View File

@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

160
components/ui/select.tsx Normal file
View File

@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

140
components/ui/sheet.tsx Normal file
View File

@ -0,0 +1,140 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

763
components/ui/sidebar.tsx Normal file
View File

@ -0,0 +1,763 @@
"use client"
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { VariantProps, cva } from "class-variance-authority"
import { PanelLeft } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { Sheet, SheetContent } from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar:state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContext = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContext | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}
>(
(
{
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref
) => {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContext>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
className
)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
)
SidebarProvider.displayName = "SidebarProvider"
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}
>(
(
{
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
},
ref
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
className={cn(
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
className
)}
ref={ref}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
ref={ref}
className="group peer hidden md:block text-sidebar-foreground"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
)}
/>
<div
className={cn(
"duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
)
}
)
Sidebar.displayName = "Sidebar"
const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
})
SidebarTrigger.displayName = "SidebarTrigger"
const SidebarRail = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button">
>(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
})
SidebarRail.displayName = "SidebarRail"
const SidebarInset = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"main">
>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex min-h-svh flex-1 flex-col bg-background",
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className
)}
{...props}
/>
)
})
SidebarInset.displayName = "SidebarInset"
const SidebarInput = React.forwardRef<
React.ElementRef<typeof Input>,
React.ComponentProps<typeof Input>
>(({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className
)}
{...props}
/>
)
})
SidebarInput.displayName = "SidebarInput"
const SidebarHeader = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
})
SidebarHeader.displayName = "SidebarHeader"
const SidebarFooter = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
})
SidebarFooter.displayName = "SidebarFooter"
const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>,
React.ComponentProps<typeof Separator>
>(({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
})
SidebarSeparator.displayName = "SidebarSeparator"
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
})
SidebarContent.displayName = "SidebarContent"
const SidebarGroup = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
})
SidebarGroup.displayName = "SidebarGroup"
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div"
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
})
SidebarGroupLabel.displayName = "SidebarGroupLabel"
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarGroupAction.displayName = "SidebarGroupAction"
const SidebarGroupContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
))
SidebarGroupContent.displayName = "SidebarGroupContent"
const SidebarMenu = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
))
SidebarMenu.displayName = "SidebarMenu"
const SidebarMenuItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
))
SidebarMenuItem.displayName = "SidebarMenuItem"
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
)
SidebarMenuButton.displayName = "SidebarMenuButton"
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className
)}
{...props}
/>
)
})
SidebarMenuAction.displayName = "SidebarMenuAction"
const SidebarMenuBadge = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
))
SidebarMenuBadge.displayName = "SidebarMenuBadge"
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("rounded-md h-8 flex gap-2 px-2 items-center", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 flex-1 max-w-[--skeleton-width]"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
})
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
const SidebarMenuSub = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
))
SidebarMenuSub.displayName = "SidebarMenuSub"
const SidebarMenuSubItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ ...props }, ref) => <li ref={ref} {...props} />)
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

28
components/ui/slider.tsx Normal file
View File

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

31
components/ui/sonner.tsx Normal file
View File

@ -0,0 +1,31 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster }

29
components/ui/switch.tsx Normal file
View File

@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

117
components/ui/table.tsx Normal file
View File

@ -0,0 +1,117 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

55
components/ui/tabs.tsx Normal file
View File

@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }

129
components/ui/toast.tsx Normal file
View File

@ -0,0 +1,129 @@
"use client"
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

35
components/ui/toaster.tsx Normal file
View File

@ -0,0 +1,35 @@
"use client"
import { useToast } from "@/hooks/use-toast"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
})
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
))
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
})
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
export { ToggleGroup, ToggleGroupItem }

45
components/ui/toggle.tsx Normal file
View File

@ -0,0 +1,45 @@
"use client"
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 gap-2",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-3 min-w-10",
sm: "h-9 px-2.5 min-w-9",
lg: "h-11 px-5 min-w-11",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
))
Toggle.displayName = TogglePrimitive.Root.displayName
export { Toggle, toggleVariants }

30
components/ui/tooltip.tsx Normal file
View File

@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

194
components/ui/use-toast.ts Normal file
View File

@ -0,0 +1,194 @@
"use client"
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

478
context/user-context.tsx Normal file
View File

@ -0,0 +1,478 @@
"use client";
import type React from "react";
import {
createContext,
useContext,
useEffect,
useState,
useCallback,
useRef,
} from "react";
import {
type UserStats,
initialUserStats,
loadUserStats,
saveUserStats,
addExperience,
allocateStat,
deallocateStat,
addItemToInventory,
removeItemFromInventory,
useConsumableItem,
} from "@/utils/storage";
import type { InventoryItem } from "@/data/enemies";
// Add these imports
import { v4 as uuidv4 } from "uuid";
import { Quest } from "@/interface/quest.interface";
// Update the UserContextType interface to include quest management functions
interface UserContextType {
userStats: UserStats;
setUserStats: React.Dispatch<React.SetStateAction<UserStats>>;
addExp: (exp: number) => void;
allocateStatPoint: (stat: keyof UserStats["stats"]) => void;
deallocateStatPoint: (stat: keyof UserStats["stats"]) => void;
completeQuest: (questId: string) => void;
updateQuestProgress: (questId: string, progress: number) => void;
updateQuest: (
questId: string,
updates: Partial<Omit<Quest, "id" | "completed" | "progress">>
) => void;
addCustomQuest: (
quest: Omit<Quest, "id" | "active" | "completed" | "progress">
) => void;
deleteQuest: (questId: string) => void;
addItem: (item: InventoryItem) => void;
removeItem: (itemId: string, quantity?: number) => void;
useItem: (itemId: string) => void;
addGold: (amount: number) => void;
levelUpCount: number;
showLevelUpModal: boolean;
setShowLevelUpModal: (show: boolean) => void;
resetLevelUpCount: () => void;
inCombat: boolean;
setInCombat: React.Dispatch<React.SetStateAction<boolean>>;
}
const UserContext = createContext<UserContextType | undefined>(undefined);
export function UserProvider({ children }: { children: React.ReactNode }) {
const [userStats, setUserStats] = useState<UserStats>(initialUserStats);
const [isInitialized, setIsInitialized] = useState(false);
const [itemToUse, setItemToUse] = useState<string | null>(null);
// Add state to track level up events
const [levelUpCount, setLevelUpCount] = useState(0);
const [showLevelUpModal, setShowLevelUpModal] = useState(false);
// Track last recovery time for HP and MP regeneration
const [lastRecoveryTime, setLastRecoveryTime] = useState<number>(Date.now());
const recoveryIntervalRef = useRef<NodeJS.Timeout | null>(null);
// Add state to track if player is in combat
const [inCombat, setInCombat] = useState(false);
// Reset level up count
const resetLevelUpCount = () => {
setLevelUpCount(0);
};
// Load user stats from localStorage on initial render
useEffect(() => {
let loadedStats = loadUserStats();
// Check if there's a last recovery time stored in localStorage
const storedLastRecoveryTime = localStorage.getItem("lastRecoveryTime");
if (storedLastRecoveryTime) {
setLastRecoveryTime(parseInt(storedLastRecoveryTime, 10));
}
// Check for recovery that should have happened while offline
if (storedLastRecoveryTime) {
const lastTime = parseInt(storedLastRecoveryTime, 10);
const now = Date.now();
const RECOVERY_INTERVAL = 500 * 60 * 10; // 5 minutes in milliseconds
const RECOVERY_PERCENTAGE = 0.1; // 10%
// Calculate how many recovery periods have passed
const timeDifference = now - lastTime;
const recoveryPeriods = Math.floor(timeDifference / RECOVERY_INTERVAL);
if (recoveryPeriods > 0) {
// Apply recovery for the time user was away
const newStats = { ...loadedStats };
if (newStats.hp < newStats.maxHp || newStats.mp < newStats.maxMp) {
// Calculate total recovery amount (capped at max values)
const hpRecoveryTotal = Math.min(
newStats.maxHp - newStats.hp,
Math.floor(newStats.maxHp * RECOVERY_PERCENTAGE * recoveryPeriods)
);
const mpRecoveryTotal = Math.min(
newStats.maxMp - newStats.mp,
Math.floor(newStats.maxMp * RECOVERY_PERCENTAGE * recoveryPeriods)
);
// Apply the recovery
newStats.hp = Math.min(newStats.maxHp, newStats.hp + hpRecoveryTotal);
newStats.mp = Math.min(newStats.maxMp, newStats.mp + mpRecoveryTotal);
// Update the recovery time to reflect the most recent recovery
const mostRecentRecoveryTime =
lastTime + recoveryPeriods * RECOVERY_INTERVAL;
setLastRecoveryTime(mostRecentRecoveryTime);
localStorage.setItem(
"lastRecoveryTime",
mostRecentRecoveryTime.toString()
);
// Set the updated stats
loadedStats = newStats;
}
}
}
setUserStats(loadedStats);
setIsInitialized(true);
}, []);
// Save user stats to localStorage whenever they change
useEffect(() => {
if (isInitialized) {
saveUserStats(userStats);
}
}, [userStats, isInitialized]);
// Natural HP and MP recovery system - 10% every 5 minutes when not in combat
useEffect(() => {
if (!isInitialized) return;
// Clear any existing interval
if (recoveryIntervalRef.current) {
clearInterval(recoveryIntervalRef.current);
}
const RECOVERY_INTERVAL = 500 * 60 * 10; // 5 minutes (changed from 5)
const RECOVERY_PERCENTAGE = 0.1; // 10%
const CHECK_INTERVAL = 60000; // Check every minute
// Only proceed with recovery if not in combat
if (!inCombat) {
// Check if player needs recovery (if HP or MP is below max)
const needsRecovery =
userStats.hp < userStats.maxHp || userStats.mp < userStats.maxMp;
if (needsRecovery) {
// Set up interval for recovery
recoveryIntervalRef.current = setInterval(() => {
const now = Date.now();
const timeSinceLastRecovery = now - lastRecoveryTime;
// Check if enough time has passed for recovery
if (timeSinceLastRecovery >= RECOVERY_INTERVAL) {
setUserStats((prevStats) => {
// Don't apply recovery if player is in combat
if (inCombat) return prevStats;
// Calculate recovery amounts (10% of max values)
const hpRecoveryAmount = Math.floor(
prevStats.maxHp * RECOVERY_PERCENTAGE
);
const mpRecoveryAmount = Math.floor(
prevStats.maxMp * RECOVERY_PERCENTAGE
);
// Calculate new HP and MP values, not exceeding max values
const newHp = Math.min(
prevStats.maxHp,
prevStats.hp + hpRecoveryAmount
);
const newMp = Math.min(
prevStats.maxMp,
prevStats.mp + mpRecoveryAmount
);
// Only update if there's actual recovery
if (newHp === prevStats.hp && newMp === prevStats.mp) {
return prevStats;
}
return {
...prevStats,
hp: newHp,
mp: newMp,
};
});
// Update last recovery time and store in localStorage
const newRecoveryTime = now;
setLastRecoveryTime(newRecoveryTime);
localStorage.setItem(
"lastRecoveryTime",
newRecoveryTime.toString()
);
}
}, CHECK_INTERVAL); // Check every minute
}
}
return () => {
if (recoveryIntervalRef.current) {
clearInterval(recoveryIntervalRef.current);
}
};
}, [
isInitialized,
userStats.hp,
userStats.mp,
userStats.maxHp,
userStats.maxMp,
lastRecoveryTime,
inCombat,
]);
// Update lastRecoveryTime in localStorage when component unmounts
useEffect(() => {
return () => {
localStorage.setItem("lastRecoveryTime", lastRecoveryTime.toString());
};
}, [lastRecoveryTime]);
// Add experience points
const addExp = (exp: number) => {
const prevLevel = userStats.level;
setUserStats((prevStats) => {
const updatedStats = addExperience(prevStats, exp);
// Check if level increased and by how much
const levelDifference = updatedStats.level - prevLevel;
if (levelDifference > 0) {
// Update level up count and show modal
setLevelUpCount(levelDifference);
setShowLevelUpModal(true);
}
return updatedStats;
});
};
// Allocate a stat point
const allocateStatPoint = (stat: keyof UserStats["stats"]) => {
setUserStats((prevStats) => allocateStat(prevStats, stat));
};
// Deallocate a stat point
const deallocateStatPoint = (stat: keyof UserStats["stats"]) => {
setUserStats((prevStats) => deallocateStat(prevStats, stat));
};
// Add these functions to the UserProvider component
// Complete a quest
const completeQuest = (questId: string) => {
const prevLevel = userStats.level;
setUserStats((prevStats) => {
// Find the quest
const quest = prevStats.quests.find((q) => q.id === questId);
if (!quest) return prevStats;
// First add the experience
let newStats = addExperience(prevStats, quest.expReward);
// Check if level increased and by how much
const levelDifference = newStats.level - prevLevel;
if (levelDifference > 0) {
// Update level up count and show modal
setLevelUpCount(levelDifference);
setShowLevelUpModal(true);
}
// Then add the stat points
newStats = {
...newStats,
statPoints: newStats.statPoints + quest.statPointsReward,
completedQuests: [...newStats.completedQuests, questId],
quests: newStats.quests.map((q) =>
q.id === questId
? {
...q,
progress: 100,
completed: true,
active: false,
completedAt: Date.now(), // Set completed timestamp
}
: q
),
};
// Apply stat rewards if any
if (quest.statRewards) {
Object.entries(quest.statRewards).forEach(([stat, value]) => {
if (value && value > 0) {
newStats.stats[stat as keyof UserStats["stats"]] += value;
}
});
// Update derived stats
if (quest.statRewards.vit) {
newStats.maxHp = Math.floor(
100 + newStats.level * 10 + newStats.stats.vit * 5
);
newStats.hp = newStats.maxHp; // Fully heal on stat increase
}
if (quest.statRewards.int) {
newStats.maxMp = Math.floor(
10 + newStats.level * 2 + newStats.stats.int * 2
);
newStats.mp = newStats.maxMp; // Fully restore MP on stat increase
}
}
// Add gold reward if any
if (quest.goldReward) {
newStats.gold += quest.goldReward;
}
// Add item rewards if any
if (quest.itemRewards && quest.itemRewards.length > 0) {
quest.itemRewards.forEach((item) => {
newStats = addItemToInventory(newStats, item);
});
}
return newStats;
});
};
// Update quest progress
const updateQuestProgress = (questId: string, progress: number) => {
setUserStats((prevStats) => ({
...prevStats,
quests: prevStats.quests.map((quest) =>
quest.id === questId ? { ...quest, progress } : quest
),
}));
};
// Add a custom quest
const addCustomQuest = (
quest: Omit<Quest, "id" | "active" | "completed" | "progress">
) => {
const newQuest = {
...quest,
id: uuidv4(),
active: true,
completed: false,
progress: 0,
isCustom: true,
createdAt: Date.now(), // Set created timestamp
};
setUserStats((prevStats) => ({
...prevStats,
quests: [...prevStats.quests, newQuest],
}));
};
// Delete a quest
const deleteQuest = (questId: string) => {
setUserStats((prevStats) => ({
...prevStats,
quests: prevStats.quests.filter((quest) => quest.id !== questId),
}));
};
// Add an item to inventory
const addItem = (item: InventoryItem) => {
setUserStats((prevStats) => addItemToInventory(prevStats, item));
};
// Remove an item from inventory
const removeItem = (itemId: string, quantity?: number) => {
setUserStats((prevStats) =>
removeItemFromInventory(prevStats, itemId, quantity)
);
};
// Use a consumable item
const useItem = (itemId: string) => {
setItemToUse(itemId);
};
// Add gold
const addGold = (amount: number) => {
setUserStats((prevStats) => ({
...prevStats,
gold: prevStats.gold + amount,
}));
};
const handleUseConsumableItem = useCallback(() => {
if (itemToUse) {
setUserStats((prevStats) => useConsumableItem(prevStats, itemToUse));
setItemToUse(null);
}
}, [itemToUse, setUserStats]);
useEffect(() => {
handleUseConsumableItem();
}, [handleUseConsumableItem]);
// Add update quest function
const updateQuest = (
questId: string,
updates: Partial<Omit<Quest, "id" | "completed" | "progress">>
) => {
setUserStats((prevStats) => ({
...prevStats,
quests: prevStats.quests.map((quest) =>
quest.id === questId
? {
...quest,
...updates,
}
: quest
),
}));
};
// Update the UserContext.Provider value
return (
<UserContext.Provider
value={{
userStats,
setUserStats,
addExp,
allocateStatPoint,
deallocateStatPoint,
completeQuest,
updateQuestProgress,
updateQuest,
addCustomQuest,
deleteQuest,
addItem,
removeItem,
useItem,
addGold,
levelUpCount,
showLevelUpModal,
setShowLevelUpModal,
resetLevelUpCount,
inCombat,
setInCombat,
}}
>
{children}
</UserContext.Provider>
);
}
export function useUser() {
const context = useContext(UserContext);
if (context === undefined) {
throw new Error("useUser must be used within a UserProvider");
}
return context;
}

229
data/enemies.ts Normal file
View File

@ -0,0 +1,229 @@
export interface Enemy {
id: string
name: string
level: number
stats: {
str: number
vit: number
agi: number
int: number
per: number
}
rewards: {
gold: number
items: InventoryItem[]
exp: number
}
description?: string
imageUrl?: string
}
export interface InventoryItem {
id: string
name: string
type: "Material" | "Weapon" | "Armor" | "Accessory" | "Consumable" | "Quest" | "Rune"
rarity: "Common" | "Uncommon" | "Rare" | "Epic" | "Legendary"
description: string
quantity?: number
stats?: {
str?: number
vit?: number
agi?: number
int?: number
per?: number
resistance?: {
fire?: number
ice?: number
lightning?: number
poison?: number
dark?: number
}
}
value?: number
imageUrl?: string
}
export const enemies: Enemy[] = [
{
id: "enemy-1",
name: "Blue Mane Lycan",
level: 15,
stats: {
str: 20,
vit: 18,
agi: 25,
int: 5,
per: 10,
},
rewards: {
gold: 150,
items: [
{
id: "item-lycan-fang",
name: "Lycan Fang",
type: "Material",
rarity: "Uncommon",
description: "A sharp fang from a Blue Mane Lycan. Used in crafting weapons and potions.",
},
{
id: "item-minor-health-potion",
name: "Minor Health Potion",
type: "Consumable",
rarity: "Common",
description: "Restores 50 HP when consumed.",
},
],
exp: 300,
},
description: "A wolf-like creature with a distinctive blue mane. Known for their speed and ferocity.",
},
{
id: "enemy-2",
name: "Cave Rock Golem",
level: 25,
stats: {
str: 30,
vit: 35,
agi: 10,
int: 5,
per: 15,
},
rewards: {
gold: 300,
items: [
{
id: "item-golem-core",
name: "Golem Core",
type: "Material",
rarity: "Rare",
description: "The magical core that animates a rock golem. Highly valued by enchanters.",
},
{
id: "item-defense-rune",
name: "Defense Rune",
type: "Rune",
rarity: "Uncommon",
description: "A rune that increases Vitality by 2 when applied to armor.",
stats: {
vit: 2,
},
},
],
exp: 600,
},
description: "A massive creature formed from cave rocks and animated by ancient magic. Slow but incredibly tough.",
},
{
id: "enemy-3",
name: "Undead Knight",
level: 35,
stats: {
str: 40,
vit: 30,
agi: 20,
int: 15,
per: 20,
},
rewards: {
gold: 450,
items: [
{
id: "item-cursed-blade",
name: "Cursed Blade",
type: "Weapon",
rarity: "Rare",
description: "A blade that drains life from its victims. Grants a chance to steal HP on hit.",
stats: {
str: 5,
},
},
{
id: "item-shadow-essence",
name: "Shadow Essence",
type: "Material",
rarity: "Rare",
description: "A dark, swirling essence extracted from undead creatures. Used in shadow magic.",
},
],
exp: 900,
},
description: "A knight who continues to fight long after death. Retains the combat skills it had in life.",
},
{
id: "enemy-4",
name: "Red-eyed Ice Bear",
level: 45,
stats: {
str: 50,
vit: 45,
agi: 15,
int: 10,
per: 25,
},
rewards: {
gold: 600,
items: [
{
id: "item-ice-bear-pelt",
name: "Ice Bear Pelt",
type: "Material",
rarity: "Rare",
description: "A thick, insulating pelt from a Red-eyed Ice Bear. Used to craft cold-resistant gear.",
},
{
id: "item-frost-amulet",
name: "Frost Amulet",
type: "Accessory",
rarity: "Epic",
description: "An amulet that grants resistance to ice damage and cold environments.",
stats: {
resistance: {
ice: 5,
},
},
},
],
exp: 1200,
},
description:
"A massive bear with glowing red eyes, adapted to the coldest environments. Its roar can freeze the air.",
},
{
id: "enemy-5",
name: "Baran, the Demon King",
level: 60,
stats: {
str: 70,
vit: 60,
agi: 40,
int: 50,
per: 35,
},
rewards: {
gold: 1200,
items: [
{
id: "item-barans-flame",
name: "Baran's Flame",
type: "Material",
rarity: "Legendary",
description:
"The eternal flame that burns within the Demon King's heart. Used in the most powerful enchantments.",
},
{
id: "item-demon-kings-crown",
name: "Demon King's Crown",
type: "Accessory",
rarity: "Legendary",
description: "The crown worn by Baran, the Demon King. Grants immense power to the wearer.",
stats: {
str: 10,
int: 10,
},
},
],
exp: 2500,
},
description: "The ruler of the demon realm, Baran possesses immense power and commands legions of lesser demons.",
},
]

67
data/items.ts Normal file
View File

@ -0,0 +1,67 @@
export interface PredefinedItem {
id: string;
name: string;
type:
| "Material"
| "Weapon"
| "Armor"
| "Accessory"
| "Consumable"
| "Quest"
| "Rune";
rarity: "Common" | "Uncommon" | "Rare" | "Epic" | "Legendary";
description: string;
quantity?: number;
}
// Define standard consumable items for quest rewards
export const predefinedConsumables: PredefinedItem[] = [
{
id: "item-health-potion",
name: "Health Potion",
type: "Consumable",
rarity: "Common",
description: "Restores 100 HP when consumed.",
quantity: 1,
},
{
id: "item-mana-potion",
name: "Mana Potion",
type: "Consumable",
rarity: "Common",
description: "Restores 50 MP when consumed.",
quantity: 1,
},
{
id: "item-greater-health-potion",
name: "Greater Health Potion",
type: "Consumable",
rarity: "Uncommon",
description: "Restores 200 HP when consumed.",
quantity: 1,
},
{
id: "item-greater-mana-potion",
name: "Greater Mana Potion",
type: "Consumable",
rarity: "Uncommon",
description: "Restores 100 MP when consumed.",
quantity: 1,
},
{
id: "item-healing-elixir",
name: "Healing Elixir",
type: "Consumable",
rarity: "Rare",
description: "Restores 350 HP when consumed.",
quantity: 1,
},
{
id: "item-mana-elixir",
name: "Mana Elixir",
type: "Consumable",
rarity: "Rare",
description: "Restores 175 MP when consumed.",
quantity: 1,
},
];

19
hooks/use-mobile.tsx Normal file
View File

@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

194
hooks/use-toast.ts Normal file
View File

@ -0,0 +1,194 @@
"use client"
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

View File

@ -0,0 +1,29 @@
import { InventoryItem } from "@/data/enemies";
// Define the Quest type
export interface Quest {
id: string;
title: string;
description: string;
reward: string;
progress: number;
difficulty: "S" | "A" | "B" | "C" | "D" | "E";
priority: "High" | "Medium" | "Low";
expiry: string;
expReward: number;
statPointsReward: number;
active: boolean;
completed: boolean;
isCustom?: boolean; // Flag to identify user-created quests
statRewards?: {
str?: number;
agi?: number;
per?: number;
int?: number;
vit?: number;
};
itemRewards?: InventoryItem[]; // Add item rewards
goldReward?: number; // Add gold reward
createdAt?: number;
completedAt?: number;
}

6
lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

14
next.config.mjs Normal file
View File

@ -0,0 +1,14 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
eslint: {
ignoreDuringBuilds: true,
},
typescript: {
ignoreBuildErrors: true,
},
images: {
unoptimized: true,
},
}
export default nextConfig

4247
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

71
package.json Normal file
View File

@ -0,0 +1,71 @@
{
"name": "my-v0-project",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@radix-ui/react-accordion": "1.2.2",
"@radix-ui/react-alert-dialog": "1.1.4",
"@radix-ui/react-aspect-ratio": "1.1.1",
"@radix-ui/react-avatar": "1.1.2",
"@radix-ui/react-checkbox": "1.1.3",
"@radix-ui/react-collapsible": "1.1.2",
"@radix-ui/react-context-menu": "2.2.4",
"@radix-ui/react-dialog": "1.1.4",
"@radix-ui/react-dropdown-menu": "2.1.4",
"@radix-ui/react-hover-card": "1.1.4",
"@radix-ui/react-label": "2.1.1",
"@radix-ui/react-menubar": "1.1.4",
"@radix-ui/react-navigation-menu": "1.2.3",
"@radix-ui/react-popover": "1.1.4",
"@radix-ui/react-progress": "1.1.1",
"@radix-ui/react-radio-group": "1.2.2",
"@radix-ui/react-scroll-area": "1.2.2",
"@radix-ui/react-select": "2.1.4",
"@radix-ui/react-separator": "1.1.1",
"@radix-ui/react-slider": "1.2.2",
"@radix-ui/react-slot": "1.1.1",
"@radix-ui/react-switch": "1.1.2",
"@radix-ui/react-tabs": "1.1.2",
"@radix-ui/react-toast": "1.2.4",
"@radix-ui/react-toggle": "1.1.1",
"@radix-ui/react-toggle-group": "1.1.1",
"@radix-ui/react-tooltip": "1.1.6",
"autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.0.4",
"date-fns": "4.1.0",
"embla-carousel-react": "8.5.1",
"input-otp": "1.4.1",
"lucide-react": "^0.454.0",
"next": "15.2.4",
"next-themes": "^0.4.4",
"react": "^19",
"react-day-picker": "8.10.1",
"react-dom": "^19",
"react-hook-form": "^7.54.1",
"react-resizable-panels": "^2.1.7",
"recharts": "2.15.0",
"sonner": "^1.7.1",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.6",
"zod": "^3.24.1",
"uuid": "latest"
},
"devDependencies": {
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"postcss": "^8",
"tailwindcss": "^3.4.17",
"typescript": "^5"
}
}

8
postcss.config.mjs Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

BIN
public/glitch-screen.mp3 Normal file

Binary file not shown.

BIN
public/placeholder-logo.png Normal file

Binary file not shown.

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="215" height="48" fill="none"><path fill="#000" d="M57.588 9.6h6L73.828 38h-5.2l-2.36-6.88h-11.36L52.548 38h-5.2l10.24-28.4Zm7.16 17.16-4.16-12.16-4.16 12.16h8.32Zm23.694-2.24c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.486-7.72.12 3.4c.534-1.227 1.307-2.173 2.32-2.84 1.04-.693 2.267-1.04 3.68-1.04 1.494 0 2.76.387 3.8 1.16 1.067.747 1.827 1.813 2.28 3.2.507-1.44 1.294-2.52 2.36-3.24 1.094-.747 2.414-1.12 3.96-1.12 1.414 0 2.64.307 3.68.92s1.84 1.52 2.4 2.72c.56 1.2.84 2.667.84 4.4V38h-4.96V25.92c0-1.813-.293-3.187-.88-4.12-.56-.96-1.413-1.44-2.56-1.44-.906 0-1.68.213-2.32.64-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.84-.48 3.04V38h-4.56V25.92c0-1.2-.133-2.213-.4-3.04-.24-.827-.626-1.453-1.16-1.88-.506-.427-1.133-.64-1.88-.64-.906 0-1.68.227-2.32.68-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.827-.48 3V38h-4.96V16.8h4.48Zm26.723 10.6c0-2.24.427-4.187 1.28-5.84.854-1.68 2.067-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.84 0 3.494.413 4.96 1.24 1.467.827 2.64 2.08 3.52 3.76.88 1.653 1.347 3.693 1.4 6.12v1.32h-15.08c.107 1.813.614 3.227 1.52 4.24.907.987 2.134 1.48 3.68 1.48.987 0 1.88-.253 2.68-.76a4.803 4.803 0 0 0 1.84-2.2l5.08.36c-.64 2.027-1.84 3.64-3.6 4.84-1.733 1.173-3.733 1.76-6 1.76-2.08 0-3.906-.453-5.48-1.36-1.573-.907-2.786-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84Zm15.16-2.04c-.213-1.733-.76-3.013-1.64-3.84-.853-.827-1.893-1.24-3.12-1.24-1.44 0-2.6.453-3.48 1.36-.88.88-1.44 2.12-1.68 3.72h9.92ZM163.139 9.6V38h-5.04V9.6h5.04Zm8.322 7.2.24 5.88-.64-.36c.32-2.053 1.094-3.56 2.32-4.52 1.254-.987 2.787-1.48 4.6-1.48 2.32 0 4.107.733 5.36 2.2 1.254 1.44 1.88 3.387 1.88 5.84V38h-4.96V25.92c0-1.253-.12-2.28-.36-3.08-.24-.8-.64-1.413-1.2-1.84-.533-.427-1.253-.64-2.16-.64-1.44 0-2.573.48-3.4 1.44-.8.933-1.2 2.307-1.2 4.12V38h-4.96V16.8h4.48Zm30.003 7.72c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.443 8.16V38h-5.6v-5.32h5.6Z"/><path fill="#171717" fill-rule="evenodd" d="m7.839 40.783 16.03-28.054L20 6 0 40.783h7.839Zm8.214 0H40L27.99 19.894l-4.02 7.032 3.976 6.914H20.02l-3.967 6.943Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
public/placeholder-user.jpg Normal file

Binary file not shown.

BIN
public/placeholder.jpg Normal file

Binary file not shown.

1
public/placeholder.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

94
styles/globals.css Normal file
View File

@ -0,0 +1,94 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-family: Arial, Helvetica, sans-serif;
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

96
tailwind.config.ts Normal file
View File

@ -0,0 +1,96 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"*.{js,ts,jsx,tsx,mdx}"
],
theme: {
extend: {
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))'
},
sidebar: {
DEFAULT: 'hsl(var(--sidebar-background))',
foreground: 'hsl(var(--sidebar-foreground))',
primary: 'hsl(var(--sidebar-primary))',
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
accent: 'hsl(var(--sidebar-accent))',
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
border: 'hsl(var(--sidebar-border))',
ring: 'hsl(var(--sidebar-ring))'
}
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
keyframes: {
'accordion-down': {
from: {
height: '0'
},
to: {
height: 'var(--radix-accordion-content-height)'
}
},
'accordion-up': {
from: {
height: 'var(--radix-accordion-content-height)'
},
to: {
height: '0'
}
}
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out'
}
}
},
plugins: [require("tailwindcss-animate")],
};
export default config;

27
tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"target": "ES6",
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

274
utils/ai-service.ts Normal file
View File

@ -0,0 +1,274 @@
// Store the API key in localStorage
export const storeAPIKey = (provider: string, apiKey: string): void => {
if (typeof window !== "undefined") {
localStorage.setItem("soloLevelUpAIProvider", provider);
localStorage.setItem("soloLevelUpAIKey", apiKey);
}
};
// Retrieve the API provider from localStorage
export const getAIProvider = (): string | null => {
if (typeof window !== "undefined") {
return localStorage.getItem("soloLevelUpAIProvider") || "openai";
}
return null;
};
// Retrieve the API key from localStorage
export const getAPIKey = (): string | null => {
if (typeof window !== "undefined") {
return localStorage.getItem("soloLevelUpAIKey");
}
return null;
};
// Check if any API key exists
export const hasAPIKey = (): boolean => {
return getAPIKey() !== null;
};
// Remove the API key from localStorage
export const removeAPIKey = (): void => {
if (typeof window !== "undefined") {
localStorage.removeItem("soloLevelUpAIProvider");
localStorage.removeItem("soloLevelUpAIKey");
}
};
// Interface for AI-generated quest data
export interface AIQuestData {
title: string;
description: string;
difficulty: "S" | "A" | "B" | "C" | "D" | "E";
priority?: "High" | "Medium" | "Low"; // Added priority field
expiry?: string; // Made optional since we won't use it
expReward: number;
statPointsReward: number;
goldReward: number;
statRewards: {
str?: number;
agi?: number;
per?: number;
int?: number;
vit?: number;
};
itemRewards?: {
name: string;
type: string;
description: string;
id?: string; // Added ID for consumables
}[];
}
// Generate quest data using OpenAI API
const generateQuestWithOpenAI = async (
description: string
): Promise<AIQuestData> => {
const apiKey = getAPIKey();
if (!apiKey) {
throw new Error("OpenAI API key not found");
}
try {
const response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: "gpt-3.5-turbo",
messages: [
{
role: "system",
content: `You are an AI assistant for a Solo Leveling themed self-improvement app.
The user will provide a quest description, and you need to generate appropriate quest data.
Your main task is to correct any grammar or typos in the title and description while preserving the original meaning.
Format your response as a valid JSON object with the following structure:
{
"title": "Corrected title (keep it close to original)",
"description": "Corrected description (keep it close to original)",
"difficulty": "One of: S, A, B, C, D, E (S is hardest, E is easiest)",
"expReward": number (10-500 based on difficulty),
"statPointsReward": number (1-10 based on difficulty),
"goldReward": number (10-1000 based on difficulty),
"statRewards": {
"str": number (optional),
"agi": number (optional),
"per": number (optional),
"int": number (optional),
"vit": number (optional)
},
"itemRewards": [
{
"name": "Item name",
"type": "One of: Material, Consumable, Weapon, Armor, Accessory, Rune",
"description": "Brief description of the item",
"id": "If the type is Consumable, include the item ID from the list below"
}
] (optional)
}
When generating consumable rewards, ONLY use these specific consumables with their exact IDs:
- item-health-potion
- item-mana-potion
- item-greater-health-potion
- item-greater-mana-potion
- item-healing-elixir
- item-mana-elixir
Analyze the description to determine appropriate stats to reward based on the activity.
For example, physical activities should reward STR and VIT, mental activities should reward INT and PER, etc.
The difficulty should be based on how challenging the task seems.
Only include 1-2 item rewards for difficult quests (S, A, B), and none for easier quests.`,
},
{
role: "user",
content: description,
},
],
temperature: 0.7,
max_tokens: 800,
}),
});
if (!response.ok) {
throw new Error(`API request failed with status ${response.status}`);
}
const data = await response.json();
const content = data.choices[0].message.content;
// Parse the JSON response
try {
const questData = JSON.parse(content) as AIQuestData;
return questData;
} catch (error) {
console.error("Failed to parse OpenAI response:", content);
throw new Error("Failed to parse AI response");
}
} catch (error) {
console.error("Error generating quest data with OpenAI:", error);
throw error;
}
};
// Generate quest data using Gemini API
const generateQuestWithGemini = async (
description: string
): Promise<AIQuestData> => {
const apiKey = getAPIKey();
if (!apiKey) {
throw new Error("Gemini API key not found");
}
try {
// Updated to use the newer Gemini 2.0 Flash model
const response = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
contents: [
{
parts: [
{
text: `You are an AI assistant for a Solo Leveling themed self-improvement app.
I will provide a quest description, and you need to generate appropriate quest data.
Your main task is to correct any grammar or typos in the title and description while preserving the original meaning.
Format your response as a valid JSON object with the following structure:
{
"title": "Corrected title (keep it close to original)",
"description": "Corrected description (keep it close to original)",
"difficulty": "One of: S, A, B, C, D, E (S is hardest, E is easiest)",
"expReward": number (10-500 based on difficulty),
"statPointsReward": number (1-10 based on difficulty),
"goldReward": number (10-1000 based on difficulty),
"statRewards": {
"str": number (optional),
"agi": number (optional),
"per": number (optional),
"int": number (optional),
"vit": number (optional)
},
"itemRewards": [
{
"name": "Item name",
"type": "One of: Material, Consumable, Weapon, Armor, Accessory, Rune",
"description": "Brief description of the item",
"id": "If the type is Consumable, include the item ID from the list below"
}
] (optional)
}
When generating consumable rewards, ONLY use these specific consumables with their exact IDs:
- item-health-potion - item-greater-health-potion
- item-greater-mana-potion
- item-healing-elixir
- item-mana-elixir
Analyze the description to determine appropriate stats to reward based on the activity.
For example, physical activities should reward STR and VIT, mental activities should reward INT and PER, etc.
The difficulty should be based on how challenging the task seems.
Only include 1-2 item rewards for difficult quests (S, A, B), and none for easier quests.
Quest description: ${description}`,
},
],
},
],
generationConfig: {
temperature: 0.7,
maxOutputTokens: 1024,
},
}),
}
);
if (!response.ok) {
throw new Error(`API request failed with status ${response.status}`);
}
const data = await response.json();
// Extract the content from Gemini's response format
const content = data.candidates[0].content.parts[0].text;
// Find the JSON object in the response
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error("Could not find JSON in Gemini response");
}
// Parse the JSON response
try {
const questData = JSON.parse(jsonMatch[0]) as AIQuestData;
return questData;
} catch (error) {
console.error("Failed to parse Gemini response:", content);
throw new Error("Failed to parse AI response");
}
} catch (error) {
console.error("Error generating quest data with Gemini:", error);
throw error;
}
};
// Generate quest data using the selected AI provider
export const generateQuestData = async (
description: string
): Promise<AIQuestData> => {
const provider = getAIProvider();
if (provider === "gemini") {
return generateQuestWithGemini(description);
} else {
// Default to OpenAI
return generateQuestWithOpenAI(description);
}
};

133
utils/openai.ts Normal file
View File

@ -0,0 +1,133 @@
// Utility functions for OpenAI API key management
// Store the OpenAI API key in localStorage
export const storeOpenAIKey = (apiKey: string): void => {
if (typeof window !== "undefined") {
localStorage.setItem("soloLevelUpOpenAIKey", apiKey)
}
}
// Retrieve the OpenAI API key from localStorage
export const getOpenAIKey = (): string | null => {
if (typeof window !== "undefined") {
return localStorage.getItem("soloLevelUpOpenAIKey")
}
return null
}
// Check if the OpenAI API key exists
export const hasOpenAIKey = (): boolean => {
return getOpenAIKey() !== null
}
// Remove the OpenAI API key from localStorage
export const removeOpenAIKey = (): void => {
if (typeof window !== "undefined") {
localStorage.removeItem("soloLevelUpOpenAIKey")
}
}
// Interface for AI-generated quest data
export interface AIQuestData {
title: string
description: string
difficulty: "S" | "A" | "B" | "C" | "D" | "E"
expiry: string
expReward: number
statPointsReward: number
goldReward: number
statRewards: {
str?: number
agi?: number
per?: number
int?: number
vit?: number
}
itemRewards?: {
name: string
type: string
description: string
}[]
}
// Generate quest data using OpenAI API
export const generateQuestData = async (description: string): Promise<AIQuestData> => {
const apiKey = getOpenAIKey()
if (!apiKey) {
throw new Error("OpenAI API key not found")
}
try {
const response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: "gpt-3.5-turbo",
messages: [
{
role: "system",
content: `You are an AI assistant for a Solo Leveling themed self-improvement app.
The user will provide a quest description, and you need to generate appropriate quest data.
Format your response as a valid JSON object with the following structure:
{
"title": "Short, catchy title for the quest",
"description": "Refined, motivational description of the quest",
"difficulty": "One of: S, A, B, C, D, E (S is hardest, E is easiest)",
"expiry": "One of: Daily, Weekly, Monthly, One-time",
"expReward": number (10-500 based on difficulty),
"statPointsReward": number (1-10 based on difficulty),
"goldReward": number (10-1000 based on difficulty),
"statRewards": {
"str": number (optional),
"agi": number (optional),
"per": number (optional),
"int": number (optional),
"vit": number (optional)
},
"itemRewards": [
{
"name": "Item name",
"type": "One of: Material, Consumable, Weapon, Armor, Accessory, Rune",
"description": "Brief description of the item"
}
] (optional)
}
Analyze the description to determine appropriate stats to reward based on the activity.
For example, physical activities should reward STR and VIT, mental activities should reward INT and PER, etc.
The difficulty should be based on how challenging the task seems.
Only include 1-2 item rewards for difficult quests (S, A, B), and none for easier quests.`,
},
{
role: "user",
content: description,
},
],
temperature: 0.7,
max_tokens: 800,
}),
})
if (!response.ok) {
throw new Error(`API request failed with status ${response.status}`)
}
const data = await response.json()
const content = data.choices[0].message.content
// Parse the JSON response
try {
const questData = JSON.parse(content) as AIQuestData
return questData
} catch (error) {
console.error("Failed to parse OpenAI response:", content)
throw new Error("Failed to parse AI response")
}
} catch (error) {
console.error("Error generating quest data:", error)
throw error
}
}

308
utils/storage.ts Normal file
View File

@ -0,0 +1,308 @@
// Import the InventoryItem interface
import type { InventoryItem } from "@/data/enemies";
import { Quest } from "@/interface/quest.interface";
// Define the user data structure
export interface UserStats {
name: string; // Add this line
level: number;
exp: number;
expToNextLevel: number;
job: string | null;
title: string | null;
hp: number;
maxHp: number;
mp: number;
maxMp: number;
fatigue: number;
gold: number; // Add gold currency
stats: {
str: number;
agi: number;
per: number;
int: number;
vit: number;
};
statPoints: number;
equipment: Equipment[];
quests: Quest[];
completedQuests: string[];
inventory: InventoryItem[]; // Add inventory array
}
export interface Equipment {
id: string;
name: string;
rarity: "Common" | "Uncommon" | "Rare" | "Epic" | "Legendary";
stats: string[];
setBonus: string;
slot: string;
equipped: boolean;
}
// Add some sample quests to the initial user stats
export const initialUserStats: UserStats = {
name: "", // Add this line with empty string as default
level: 1,
exp: 0,
expToNextLevel: 100, // Level 1 needs 100 XP to reach level 2
job: null,
title: null,
hp: 100,
maxHp: 100,
mp: 10,
maxMp: 10,
fatigue: 0,
gold: 0, // Start with 0 gold
stats: {
str: 10,
agi: 10,
per: 10,
int: 10,
vit: 10,
},
statPoints: 0,
equipment: [],
quests: [], // Removed initial quests
completedQuests: [],
inventory: [
{
id: "item-health-potion",
name: "Health Potion",
type: "Consumable",
rarity: "Common",
description: "Restores 100 HP when consumed.",
quantity: 3,
},
{
id: "item-mana-potion",
name: "Mana Potion",
type: "Consumable",
rarity: "Common",
description: "Restores 50 MP when consumed.",
quantity: 2,
},
],
};
// Save user data to localStorage
export const saveUserStats = (stats: UserStats): void => {
if (typeof window !== "undefined") {
localStorage.setItem("soloLevelUpUserStats", JSON.stringify(stats));
}
};
// Load user data from localStorage
export const loadUserStats = (): UserStats => {
if (typeof window !== "undefined") {
const savedStats = localStorage.getItem("soloLevelUpUserStats");
if (savedStats) {
return JSON.parse(savedStats);
}
}
return initialUserStats;
};
// Calculate XP needed for next level
export const calculateExpToNextLevel = (level: number): number => {
return Math.floor(100 * Math.pow(1.1, level - 1));
};
// Update the levelUp function to automatically add 1 point to all stats
export const levelUp = (stats: UserStats): UserStats => {
const newLevel = stats.level + 1;
// Automatically increase all stats by 1 point
const newStats = {
str: stats.stats.str + 1,
agi: stats.stats.agi + 1,
per: stats.stats.per + 1,
int: stats.stats.int + 1,
vit: stats.stats.vit + 1,
};
// HP and MP increases based on level and vitality/intelligence
const newMaxHp = Math.floor(100 + newLevel * 10 + newStats.vit * 5);
const newMaxMp = Math.floor(10 + newLevel * 2 + newStats.int * 2);
return {
...stats,
level: newLevel,
exp: stats.exp - stats.expToNextLevel,
expToNextLevel: calculateExpToNextLevel(newLevel),
maxHp: newMaxHp,
hp: newMaxHp, // Fully heal on level up
maxMp: newMaxMp,
mp: newMaxMp, // Fully restore MP on level up
stats: newStats,
// No longer adding stat points since we automatically increase all stats
};
};
// Add experience points
export const addExperience = (stats: UserStats, exp: number): UserStats => {
let updatedStats = { ...stats, exp: stats.exp + exp };
// Check if leveled up
while (updatedStats.exp >= updatedStats.expToNextLevel) {
updatedStats = levelUp(updatedStats);
}
return updatedStats;
};
// Allocate stat point
export const allocateStat = (
stats: UserStats,
stat: keyof UserStats["stats"]
): UserStats => {
if (stats.statPoints <= 0) return stats;
const newStats = { ...stats };
newStats.stats[stat]++;
newStats.statPoints--;
// Update derived stats
if (stat === "vit") {
newStats.maxHp = Math.floor(
100 + newStats.level * 10 + newStats.stats.vit * 5
);
}
if (stat === "int") {
newStats.maxMp = Math.floor(
10 + newStats.level * 2 + newStats.stats.int * 2
);
}
return newStats;
};
// Remove stat point (for UI)
export const deallocateStat = (
stats: UserStats,
stat: keyof UserStats["stats"]
): UserStats => {
if (stats.stats[stat] <= 10) return stats; // Can't go below base value
const newStats = { ...stats };
newStats.stats[stat]--;
newStats.statPoints++;
// Update derived stats
if (stat === "vit") {
newStats.maxHp = Math.floor(
100 + newStats.level * 10 + newStats.stats.vit * 5
);
}
if (stat === "int") {
newStats.maxMp = Math.floor(
10 + newStats.level * 2 + newStats.stats.int * 2
);
}
return newStats;
};
// Add item to inventory
export const addItemToInventory = (
stats: UserStats,
item: InventoryItem
): UserStats => {
const newStats = { ...stats };
const existingItem = newStats.inventory.find((i) => i.id === item.id);
if (existingItem && existingItem.quantity) {
// If item exists and has quantity, increment quantity
existingItem.quantity += item.quantity || 1;
} else {
// Otherwise add as new item
newStats.inventory.push({
...item,
quantity: item.quantity || 1,
});
}
return newStats;
};
// Remove item from inventory
export const removeItemFromInventory = (
stats: UserStats,
itemId: string,
quantity = 1
): UserStats => {
const newStats = { ...stats };
const itemIndex = newStats.inventory.findIndex((i) => i.id === itemId);
if (itemIndex === -1) return stats;
const item = newStats.inventory[itemIndex];
if (item.quantity && item.quantity > quantity) {
// Reduce quantity if there are more than requested
item.quantity -= quantity;
} else {
// Remove item completely
newStats.inventory.splice(itemIndex, 1);
}
return newStats;
};
// Use consumable item
export const useConsumableItem = (
stats: UserStats,
itemId: string
): UserStats => {
const newStats = { ...stats };
const item = newStats.inventory.find((i) => i.id === itemId);
if (!item || item.type !== "Consumable") return stats;
// First try specific item IDs for built-in items
switch (item.id) {
case "item-health-potion":
newStats.hp = Math.min(newStats.maxHp, newStats.hp + 100);
break;
case "item-mana-potion":
newStats.mp = Math.min(newStats.maxMp, newStats.mp + 50);
break;
case "item-focus-potion":
// This would need a temporary buff system to be implemented
break;
default:
// For custom/generated items, check name patterns instead of relying on specific IDs
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;
newStats.hp = Math.min(newStats.maxHp, newStats.hp + healAmount);
} 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;
newStats.mp = Math.min(newStats.maxMp, newStats.mp + manaAmount);
} else {
// Unknown consumable effect
return stats;
}
}
// Remove one of the item
return removeItemFromInventory(newStats, itemId, 1);
};