This is a clone of an solo leveling node app from an git repo
This commit is contained in:
commit
e33baa3633
75
README.md
Normal file
75
README.md
Normal 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_
|
||||

|
||||
|
||||
_Quests view_
|
||||

|
||||
|
||||
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
3
app/combat/loading.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
932
app/combat/page.tsx
Normal file
932
app/combat/page.tsx
Normal 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
457
app/equipment/page.tsx
Normal 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
116
app/globals.css
Normal 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;
|
||||
}
|
||||
3
app/inventory/loading.tsx
Normal file
3
app/inventory/loading.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
483
app/inventory/page.tsx
Normal file
483
app/inventory/page.tsx
Normal 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
42
app/layout.tsx
Normal 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
540
app/page.tsx
Normal 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
3
app/quests/loading.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
660
app/quests/page.tsx
Normal file
660
app/quests/page.tsx
Normal 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
315
app/stats/page.tsx
Normal 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
21
components.json
Normal 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"
|
||||
}
|
||||
759
components/add-quest-form.tsx
Normal file
759
components/add-quest-form.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
171
components/api-key-modal.tsx
Normal file
171
components/api-key-modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
261
components/combat-actions.tsx
Normal file
261
components/combat-actions.tsx
Normal 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
42
components/combat-log.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
587
components/combat-visualization.tsx
Normal file
587
components/combat-visualization.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
162
components/enemy-selection.tsx
Normal file
162
components/enemy-selection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
62
components/level-up-modal.tsx
Normal file
62
components/level-up-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
components/level-up-notification.tsx
Normal file
26
components/level-up-notification.tsx
Normal 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
61
components/mobile-nav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
124
components/name-input-modal.tsx
Normal file
124
components/name-input-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
components/openai-key-modal.tsx
Normal file
120
components/openai-key-modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
components/theme-provider.tsx
Normal file
11
components/theme-provider.tsx
Normal 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>
|
||||
}
|
||||
58
components/ui/accordion.tsx
Normal file
58
components/ui/accordion.tsx
Normal 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 }
|
||||
141
components/ui/alert-dialog.tsx
Normal file
141
components/ui/alert-dialog.tsx
Normal 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
59
components/ui/alert.tsx
Normal 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 }
|
||||
7
components/ui/aspect-ratio.tsx
Normal file
7
components/ui/aspect-ratio.tsx
Normal 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
50
components/ui/avatar.tsx
Normal 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
36
components/ui/badge.tsx
Normal 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 }
|
||||
115
components/ui/breadcrumb.tsx
Normal file
115
components/ui/breadcrumb.tsx
Normal 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
56
components/ui/button.tsx
Normal 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 }
|
||||
66
components/ui/calendar.tsx
Normal file
66
components/ui/calendar.tsx
Normal 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
79
components/ui/card.tsx
Normal 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
262
components/ui/carousel.tsx
Normal 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
365
components/ui/chart.tsx
Normal 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,
|
||||
}
|
||||
30
components/ui/checkbox.tsx
Normal file
30
components/ui/checkbox.tsx
Normal 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 }
|
||||
11
components/ui/collapsible.tsx
Normal file
11
components/ui/collapsible.tsx
Normal 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
153
components/ui/command.tsx
Normal 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,
|
||||
}
|
||||
200
components/ui/context-menu.tsx
Normal file
200
components/ui/context-menu.tsx
Normal 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
122
components/ui/dialog.tsx
Normal 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
118
components/ui/drawer.tsx
Normal 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,
|
||||
}
|
||||
200
components/ui/dropdown-menu.tsx
Normal file
200
components/ui/dropdown-menu.tsx
Normal 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
178
components/ui/form.tsx
Normal 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,
|
||||
}
|
||||
29
components/ui/hover-card.tsx
Normal file
29
components/ui/hover-card.tsx
Normal 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 }
|
||||
71
components/ui/input-otp.tsx
Normal file
71
components/ui/input-otp.tsx
Normal 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
22
components/ui/input.tsx
Normal 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
26
components/ui/label.tsx
Normal 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
236
components/ui/menubar.tsx
Normal 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,
|
||||
}
|
||||
128
components/ui/navigation-menu.tsx
Normal file
128
components/ui/navigation-menu.tsx
Normal 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,
|
||||
}
|
||||
117
components/ui/pagination.tsx
Normal file
117
components/ui/pagination.tsx
Normal 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
31
components/ui/popover.tsx
Normal 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 }
|
||||
28
components/ui/progress.tsx
Normal file
28
components/ui/progress.tsx
Normal 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 }
|
||||
44
components/ui/radio-group.tsx
Normal file
44
components/ui/radio-group.tsx
Normal 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 }
|
||||
45
components/ui/resizable.tsx
Normal file
45
components/ui/resizable.tsx
Normal 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 }
|
||||
48
components/ui/scroll-area.tsx
Normal file
48
components/ui/scroll-area.tsx
Normal 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
160
components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
31
components/ui/separator.tsx
Normal file
31
components/ui/separator.tsx
Normal 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
140
components/ui/sheet.tsx
Normal 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
763
components/ui/sidebar.tsx
Normal 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,
|
||||
}
|
||||
15
components/ui/skeleton.tsx
Normal file
15
components/ui/skeleton.tsx
Normal 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
28
components/ui/slider.tsx
Normal 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
31
components/ui/sonner.tsx
Normal 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
29
components/ui/switch.tsx
Normal 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
117
components/ui/table.tsx
Normal 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
55
components/ui/tabs.tsx
Normal 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 }
|
||||
22
components/ui/textarea.tsx
Normal file
22
components/ui/textarea.tsx
Normal 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
129
components/ui/toast.tsx
Normal 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
35
components/ui/toaster.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
61
components/ui/toggle-group.tsx
Normal file
61
components/ui/toggle-group.tsx
Normal 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
45
components/ui/toggle.tsx
Normal 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
30
components/ui/tooltip.tsx
Normal 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 }
|
||||
19
components/ui/use-mobile.tsx
Normal file
19
components/ui/use-mobile.tsx
Normal 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
194
components/ui/use-toast.ts
Normal 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
478
context/user-context.tsx
Normal 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
229
data/enemies.ts
Normal 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
67
data/items.ts
Normal 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
19
hooks/use-mobile.tsx
Normal 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
194
hooks/use-toast.ts
Normal 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 }
|
||||
29
interface/quest.interface.ts
Normal file
29
interface/quest.interface.ts
Normal 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
6
lib/utils.ts
Normal 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
14
next.config.mjs
Normal 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
4247
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
71
package.json
Normal file
71
package.json
Normal 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
8
postcss.config.mjs
Normal 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
BIN
public/glitch-screen.mp3
Normal file
Binary file not shown.
BIN
public/placeholder-logo.png
Normal file
BIN
public/placeholder-logo.png
Normal file
Binary file not shown.
1
public/placeholder-logo.svg
Normal file
1
public/placeholder-logo.svg
Normal 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
BIN
public/placeholder-user.jpg
Normal file
Binary file not shown.
BIN
public/placeholder.jpg
Normal file
BIN
public/placeholder.jpg
Normal file
Binary file not shown.
1
public/placeholder.svg
Normal file
1
public/placeholder.svg
Normal 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 |
BIN
public/screenshots/dashboard-screenshot.png
Normal file
BIN
public/screenshots/dashboard-screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
BIN
public/screenshots/quests-screenshot.png
Normal file
BIN
public/screenshots/quests-screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
94
styles/globals.css
Normal file
94
styles/globals.css
Normal 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
96
tailwind.config.ts
Normal 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
27
tsconfig.json
Normal 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
274
utils/ai-service.ts
Normal 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
133
utils/openai.ts
Normal 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
308
utils/storage.ts
Normal 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);
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user