Compare commits

..

17 Commits

Author SHA1 Message Date
0be511c245 feat: update Gitea workflow trigger and enhance API error handling with token refresh logic 2025-02-16 13:16:41 -03:00
2487076e92 feat: add production environment variables and update Gitea workflow for deployment
All checks were successful
Main Build & Deploy / Build and Push Docker Image (amd64 and arm64) (push) Successful in 2m59s
Main Build & Deploy / Deploy Live (push) Successful in 5s
2025-02-16 11:47:28 -03:00
1f2a71452b feat: add environment variable handling in Dockerfile and update Firebase configuration 2025-02-16 11:38:30 -03:00
e5af3b8c51 feat: add environment variables for Firebase configuration in Dockerfile 2025-02-16 11:18:22 -03:00
d1a08d055a refactor: remove security headers from nginx configuration 2025-02-16 11:08:36 -03:00
64ca3bedf4 fix: provide default values for Firebase configuration variables 2025-02-16 11:03:53 -03:00
53b9694b09 refactor: remove Docker installation steps from Gitea workflow 2025-02-15 22:29:45 -03:00
9fbff6d8f2 feat: add Dockerfile and GitHub Actions workflow for build and deployment 2025-02-15 22:19:57 -03:00
a12cfc5a2a feat: integrate Firebase for authentication and data management, add GameItem type, and enhance UI with tooltips 2025-02-15 21:54:28 -03:00
62634a426e feat: implement item icon loading in InventoryModal and add API function to fetch item icons 2025-02-12 21:22:22 -03:00
88a9c6507c refactor: reorganize pet action types and update related components for improved resource management 2025-02-09 21:23:07 -03:00
c2e5bf92a3 feat: enhance PetDisplay and SkillTreeModal with resource management and skill allocation logic 2025-02-08 22:46:35 -03:00
78c0f52c39 feat: enhance SkillTreeModal with skill allocation logic and custom scrollbar styles 2025-02-08 20:52:44 -03:00
243c50a1a0 feat: add inventory and skill tree modals, enhance pet display with update handling 2025-02-05 20:36:59 -03:00
a624ba1e73 feat: enhance pet management with new skills and inventory system 2025-02-05 20:10:38 -03:00
dd32327383 changing website title 2025-02-05 19:39:03 -03:00
2202bd606f feat: add AnimatedBackground component and enhance UI with background effects 2025-02-01 23:15:44 -03:00
38 changed files with 2408 additions and 158 deletions

7
.env.example Normal file
View File

@@ -0,0 +1,7 @@
VITE_FIREBASE_API_KEY=your-api-key
VITE_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=your-project-id
VITE_FIREBASE_STORAGE_BUCKET=your-project.appspot.com
VITE_FIREBASE_MESSAGING_SENDER_ID=your-sender-id
VITE_FIREBASE_APP_ID=your-app-id
VITE_FIREBASE_MEASUREMENT_ID=your-measurement-id

7
.env.production Normal file
View File

@@ -0,0 +1,7 @@
VITE_FIREBASE_API_KEY=VITE_FIREBASE_API_KEY
VITE_FIREBASE_AUTH_DOMAIN=VITE_FIREBASE_AUTH_DOMAIN
VITE_FIREBASE_PROJECT_ID=VITE_FIREBASE_PROJECT_ID
VITE_FIREBASE_STORAGE_BUCKET=VITE_FIREBASE_STORAGE_BUCKET
VITE_FIREBASE_MESSAGING_SENDER_ID=VITE_FIREBASE_MESSAGING_SENDER_ID
VITE_FIREBASE_APP_ID=VITE_FIREBASE_APP_ID
VITE_FIREBASE_MEASUREMENT_ID=VITE_FIREBASE_MEASUREMENT_ID

58
.gitea/workflows/main.yml Normal file
View File

@@ -0,0 +1,58 @@
name: Main Build & Deploy
on: workflow_dispatch
jobs:
build:
name: Build and Push Docker Image (amd64 and arm64)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
with:
config-inline: |
[registry."git.ivanch.me"]
- name: Login to Docker Hub
uses: https://github.com/docker/login-action@v3.3.0
with:
registry: git.ivanch.me
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
- name: Build Docker image and push
id: docker_build
uses: https://github.com/docker/build-push-action@v6.12.0
with:
context: ./
file: ./Dockerfile
push: true
tags: git.ivanch.me/ivanch/pet-companion/pet-companion:latest
platforms: linux/amd64, linux/arm64
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}
deploy_live:
name: Deploy Live
runs-on: ubuntu-latest
needs: build
steps:
- name: Recreate container
uses: https://github.com/appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.LIVE_HOST }}
username: ${{ secrets.LIVE_USERNAME }}
key: ${{ secrets.LIVE_KEY }}
port: ${{ secrets.LIVE_PORT }}
script: |
cd ${{ secrets.LIVE_PROJECT_DIR }}
docker compose down
docker compose rm
docker compose pull
docker compose up -d

2
.gitignore vendored
View File

@@ -22,3 +22,5 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
.env

18
Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY env.sh /docker-entrypoint.d/env.sh
RUN chmod +x /docker-entrypoint.d/env.sh
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

12
env.sh Normal file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
for i in $(env | grep VITE_)
do
key=$(echo $i | cut -d '=' -f 1)
value=$(echo $i | cut -d '=' -f 2-)
echo $key=$value
# sed All files
# find /usr/share/nginx/html -type f -exec sed -i "s|${key}|${value}|g" '{}' +
# sed JS and CSS only
find /usr/share/nginx/html -type f \( -name '*.js' -o -name '*.css' \) -exec sed -i "s|${key}|${value}|g" '{}' +
done

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title> <title>Idle Pet</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

10
nginx.conf Normal file
View File

@@ -0,0 +1,10 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

1071
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"axios": "^1.6.7", "axios": "^1.6.7",
"firebase": "^11.3.1",
"lucide-react": "^0.344.0", "lucide-react": "^0.344.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
@@ -31,4 +32,4 @@
"typescript-eslint": "^8.3.0", "typescript-eslint": "^8.3.0",
"vite": "^5.4.2" "vite": "^5.4.2"
} }
} }

View File

@@ -1,28 +1,45 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { onAuthStateChanged } from 'firebase/auth';
import { auth } from './config/firebase';
import PetDisplay from './components/PetDisplay'; import PetDisplay from './components/PetDisplay';
import InteractionMenu from './components/InteractionMenu'; import InteractionMenu from './components/InteractionMenu';
import PetRegister from './components/PetRegister'; import PetRegister from './components/PetRegister';
import AnimatedBackground from './components/AnimatedBackground';
import AuthForm from './components/auth/AuthForm';
import { Pet } from './types/Pet'; import { Pet } from './types/Pet';
import { fetchPets } from './services/api/api'; import { fetchPets } from './services/api/api';
export default function App() { export default function App() {
const [pet, setPet] = useState<Pet | null>(null); const [pet, setPet] = useState<Pet | null>(null);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => { useEffect(() => {
const loadPets = async () => { const unsubscribe = onAuthStateChanged(auth, (user) => {
try { setIsAuthenticated(!!user);
const pets = await fetchPets(); setIsLoading(false);
if (pets.length > 0) { });
setPet(pets[0]);
}
} catch (error) {
console.error('Error loading pets:', error);
}
};
loadPets(); return () => unsubscribe();
}, []); }, []);
useEffect(() => {
if (isAuthenticated) {
const loadPets = async () => {
try {
const pets = await fetchPets();
if (pets.length > 0) {
setPet(pets[0]);
}
} catch (error) {
console.error('Error loading pets:', error);
}
};
loadPets();
}
}, [isAuthenticated]);
const handleCustomize = () => { const handleCustomize = () => {
console.log('Customize pet'); console.log('Customize pet');
}; };
@@ -31,20 +48,52 @@ export default function App() {
setPet(updatedPet); setPet(updatedPet);
}; };
if (isLoading) {
return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
<div className="text-white">Loading...</div>
</div>
);
}
if (!isAuthenticated) {
return (
<>
<AnimatedBackground />
<div className="relative z-10">
<AuthForm onAuthSuccess={() => setIsAuthenticated(true)} />
</div>
</>
);
}
if (!pet) { if (!pet) {
return <PetRegister onPetCreated={setPet} />; return (
<>
<AnimatedBackground />
<div className="relative z-10">
<PetRegister onPetCreated={setPet} />
</div>
</>
);
} }
return ( return (
<div className="min-h-screen bg-gray-900 text-white p-4"> <>
<div className="max-w-4xl mx-auto grid gap-8"> <AnimatedBackground />
<PetDisplay pet={pet} /> <div className="relative z-10 min-h-screen backdrop-blur-sm bg-gray-900/20 text-white p-4">
<InteractionMenu <div className="max-w-4xl mx-auto grid gap-8">
pet={pet} <PetDisplay
onPetUpdate={handlePetUpdate} pet={pet}
onCustomize={handleCustomize} onPetUpdate={handlePetUpdate} // Add this prop
/> />
<InteractionMenu
pet={pet}
onPetUpdate={handlePetUpdate}
onCustomize={handleCustomize}
/>
</div>
</div> </div>
</div> </>
); );
} }

View File

@@ -1,5 +1,6 @@
import { LucideIcon } from 'lucide-react'; import { LucideIcon } from 'lucide-react';
import { Pet, Resources } from '../types/Pet'; import { Pet } from '../types/Pet';
import Tooltip from './Tooltip';
import { isActionActive, formatResourceName, getResourceFromAction } from '../utils/petUtils'; import { isActionActive, formatResourceName, getResourceFromAction } from '../utils/petUtils';
const colorClassMap = { const colorClassMap = {
@@ -11,6 +12,15 @@ const colorClassMap = {
purple: 'bg-purple-900/30 hover:bg-purple-800/50 border-purple-500/50', purple: 'bg-purple-900/30 hover:bg-purple-800/50 border-purple-500/50',
} as const; } as const;
const activeColorClassMap = {
amber: 'bg-amber-700/50 border-amber-400',
emerald: 'bg-emerald-700/50 border-emerald-400',
red: 'bg-red-700/50 border-red-400',
green: 'bg-green-700/50 border-green-400',
blue: 'bg-blue-700/50 border-blue-400',
purple: 'bg-purple-700/50 border-purple-400',
} as const;
const getActionVerb = (actionType: 'gather' | 'explore' | 'battle'): string => { const getActionVerb = (actionType: 'gather' | 'explore' | 'battle'): string => {
const verbs = { const verbs = {
gather: 'Gathering', gather: 'Gathering',
@@ -20,6 +30,15 @@ const getActionVerb = (actionType: 'gather' | 'explore' | 'battle'): string => {
return verbs[actionType]; return verbs[actionType];
}; };
const getActionDescription = (actionType: 'gather' | 'explore' | 'battle'): string => {
const descriptions = {
gather: 'Send your pet to gather resources<br/>Your pet will collect items over time',
explore: 'Send your pet to explore<br/>Your pet might find rare items',
battle: 'Send your pet to battle<br/>Your pet will gain experience and items',
};
return descriptions[actionType];
};
type ButtonColor = keyof typeof colorClassMap; type ButtonColor = keyof typeof colorClassMap;
interface ActionResourceButtonProps { interface ActionResourceButtonProps {
@@ -28,9 +47,9 @@ interface ActionResourceButtonProps {
label: string; label: string;
actionType: 'gather' | 'explore' | 'battle'; actionType: 'gather' | 'explore' | 'battle';
color: ButtonColor; color: ButtonColor;
actionText?: string;
onActionClick: () => void; onActionClick: () => void;
onActionComplete: (updatedPet: Pet) => void; onActionComplete: (updatedPet: Pet) => void;
onResourcesUpdate: (resources: Resources) => void;
} }
export default function ActionResourceButton({ export default function ActionResourceButton({
@@ -39,25 +58,43 @@ export default function ActionResourceButton({
label, label,
actionType, actionType,
color, color,
actionText,
onActionClick onActionClick
}: ActionResourceButtonProps) { }: ActionResourceButtonProps) {
const isActive = isActionActive(pet.petGatherAction, actionType); const isActive = isActionActive(pet.petGatherAction, actionType);
const currentResource = getResourceFromAction(pet.petGatherAction); const currentResource = getResourceFromAction(pet.petGatherAction);
return ( const getButtonText = () => {
if (!isActive) return label;
if (actionType === 'explore' && pet.petGatherAction === 'EXPLORE') {
return 'Exploring';
}
if (actionType === 'battle' && pet.petGatherAction === 'BATTLE') {
return 'Battling';
}
if (currentResource) {
return `${getActionVerb(actionType)} ${formatResourceName(currentResource)}`;
}
return label;
};
const button = (
<button <button
onClick={onActionClick} onClick={onActionClick}
className={`flex items-center justify-center space-x-2 className={`flex items-center justify-center space-x-2
${colorClassMap[color]} ${isActive ? activeColorClassMap[color] : colorClassMap[color]}
border-2 rounded-lg p-4 border-2 rounded-lg p-4
transition-all duration-300 transform hover:scale-105 w-full`} transition-all duration-300 transform hover:scale-105 w-full`}
> >
<Icon className="w-6 h-6" /> <Icon className="w-6 h-6" />
<span> <span>{getButtonText()}</span>
{isActive && currentResource
? `${getActionVerb(actionType)} ${formatResourceName(currentResource)}`
: label}
</span>
</button> </button>
); );
return (
<Tooltip content={actionText || ''}>
{button}
</Tooltip>
);
} }

View File

@@ -0,0 +1,105 @@
import { useEffect, useRef } from 'react';
export default function AnimatedBackground() {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const particles: Particle[] = [];
const particleCount = 50;
class Particle {
x: number;
y: number;
size: number;
speedX: number;
speedY: number;
opacity: number;
constructor() {
this.x = Math.random() * canvas.width;
this.y = Math.random() * canvas.height;
this.size = Math.random() * 3 + 1;
this.speedX = Math.random() * 1 - 0.5;
this.speedY = Math.random() * 1 - 0.5;
this.opacity = Math.random() * 0.5 + 0.2;
}
update() {
this.x += this.speedX;
this.y += this.speedY;
if (this.x > canvas.width) this.x = 0;
if (this.x < 0) this.x = canvas.width;
if (this.y > canvas.height) this.y = 0;
if (this.y < 0) this.y = canvas.height;
}
draw() {
if (!ctx) return;
ctx.fillStyle = `rgba(255, 255, 255, ${this.opacity})`;
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fill();
}
}
// Create particles
for (let i = 0; i < particleCount; i++) {
particles.push(new Particle());
}
function connectParticles() {
for (let i = 0; i < particles.length; i++) {
for (let j = i + 1; j < particles.length; j++) {
const dx = particles[i].x - particles[j].x;
const dy = particles[i].y - particles[j].y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 100) {
if (!ctx) return;
ctx.strokeStyle = `rgba(255, 255, 255, ${0.2 * (1 - distance/100)})`;
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(particles[i].x, particles[i].y);
ctx.lineTo(particles[j].x, particles[j].y);
ctx.stroke();
}
}
}
}
function animate() {
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
particles.forEach(particle => {
particle.update();
particle.draw();
});
connectParticles();
requestAnimationFrame(animate);
}
animate();
const handleResize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return <canvas ref={canvasRef} className="fixed top-0 left-0 w-full h-full -z-10" />;
}

View File

@@ -1,20 +1,23 @@
import { Resources } from '../types/Pet';
import { putPetCollectResources } from '../services/api/api'; import { putPetCollectResources } from '../services/api/api';
import { PetActionGathered } from '../types/PetAction';
import { Pet } from '../types/Pet';
interface CollectResourcesButtonProps { interface CollectResourcesButtonProps {
petId: string; petId: string;
resources: Resources; resources: PetActionGathered[];
onCollect: () => void; onCollect: () => void;
onPetUpdate: (pet: Pet) => void;
} }
export default function CollectResourcesButton({ petId, resources, onCollect }: CollectResourcesButtonProps) { export default function CollectResourcesButton({ petId, resources, onCollect, onPetUpdate }: CollectResourcesButtonProps) {
const hasResources = Object.values(resources).some(value => value > 0); const hasResources = Object.values(resources).length > 0;
if (!hasResources) return null; if (!hasResources) return null;
const handleCollect = async () => { const handleCollect = async () => {
try { try {
await putPetCollectResources(petId); const updatedPet = await putPetCollectResources(petId);
onPetUpdate(updatedPet);
onCollect(); onCollect();
} catch (error) { } catch (error) {
console.error('Failed to collect resources:', error); console.error('Failed to collect resources:', error);
@@ -24,17 +27,20 @@ export default function CollectResourcesButton({ petId, resources, onCollect }:
return ( return (
<button <button
onClick={handleCollect} onClick={handleCollect}
className="flex items-center justify-center space-x-2 className="flex flex-col items-center justify-center
bg-green-900/30 hover:bg-green-800/50 bg-green-900/30 hover:bg-green-800/50
border-2 border-green-500/50 rounded-lg p-4 border-2 border-green-500/50 rounded-lg p-4
transition-all duration-300 transform hover:scale-105 w-full mt-2" transition-all duration-300 transform hover:scale-105 w-full mt-2"
> >
<span> <span className="font-bold mb-1">Collect:</span>
Collect: {Object.entries(resources) {resources.map((item, index) => (
.filter(([_, value]) => value > 0) <span key={index}>
.map(([key, value]) => `${value} ${key}`) {item.gameItem
.join(', ')} ? item.gameItem.name
</span> : `${item.resource} x${item.amount}`
}
</span>
))}
</button> </button>
); );
} }

View File

@@ -1,13 +1,12 @@
import { Pizza, PlayCircle, Moon, Compass, Sword, FeatherIcon } from 'lucide-react'; import { Pizza, PlayCircle, Moon, Compass, Sword, FeatherIcon } from 'lucide-react';
import CollectResourcesButton from './CollectResourcesButton'; import CollectResourcesButton from './CollectResourcesButton';
import { Pet, Resources } from '../types/Pet'; import { Pet } from '../types/Pet';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { updatePetAction, getPetGatheredResources } from '../services/api/api'; import { updatePetAction, getPetGatheredResources } from '../services/api/api';
import { PetBasicAction } from '../types/PetUpdateActionRequest'; import { PetActionGathered, PetBasicAction, PetGatherAction } from '../types/PetAction';
import ActionButton from './button/ActionButton'; import ActionButton from './button/ActionButton';
import ActionResourceButton from './ActionResourceButton'; import ActionResourceButton from './ActionResourceButton';
import ResourceSelectionModal from './modal/ResourceSelectionModal'; import ResourceSelectionModal from './modal/ResourceSelectionModal';
import { PetAction } from '../types/PetUpdateActionRequest';
interface InteractionMenuProps { interface InteractionMenuProps {
pet: Pet; pet: Pet;
@@ -16,10 +15,9 @@ interface InteractionMenuProps {
} }
export default function InteractionMenu({ pet, onPetUpdate }: InteractionMenuProps) { export default function InteractionMenu({ pet, onPetUpdate }: InteractionMenuProps) {
const [gatheredResources, setGatheredResources] = useState<Resources>({ wisdom: 0, gold: 0, food: 0, junk: 0 }); const [gatheredResources, setGatheredResources] = useState<PetActionGathered[]>([]);
const [remainingCooldown, setRemainingCooldown] = useState<number | null>(null); const [remainingCooldown, setRemainingCooldown] = useState<number | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedActionType, setSelectedActionType] = useState<'gather' | 'explore' | 'battle' | null>(null);
useEffect(() => { useEffect(() => {
const updateCooldown = () => { const updateCooldown = () => {
@@ -78,14 +76,6 @@ export default function InteractionMenu({ pet, onPetUpdate }: InteractionMenuPro
onPetUpdate(updatedPet); onPetUpdate(updatedPet);
}; };
const handleResourcesUpdate = (resources: Resources) => {
setGatheredResources(resources);
};
const handleCollect = () => {
setGatheredResources({ wisdom: 0, gold: 0, food: 0, junk: 0 });
};
function performBasicAction(basicAction: PetBasicAction): () => void { function performBasicAction(basicAction: PetBasicAction): () => void {
return async () => { return async () => {
try { try {
@@ -99,13 +89,12 @@ export default function InteractionMenu({ pet, onPetUpdate }: InteractionMenuPro
const handleActionStart = async (actionType: 'gather' | 'explore' | 'battle') => { const handleActionStart = async (actionType: 'gather' | 'explore' | 'battle') => {
if (actionType === 'gather') { if (actionType === 'gather') {
setSelectedActionType(actionType);
setIsModalOpen(true); setIsModalOpen(true);
return; return;
} }
try { try {
const action: PetAction = actionType === 'explore' ? 'EXPLORING' : 'BATTLE'; const action: PetGatherAction = actionType === 'explore' ? 'EXPLORE' : 'BATTLE';
const updatedPet = await updatePetAction(pet.id, { gatherAction: action }); const updatedPet = await updatePetAction(pet.id, { gatherAction: action });
onPetUpdate(updatedPet); onPetUpdate(updatedPet);
} catch (error) { } catch (error) {
@@ -126,7 +115,7 @@ export default function InteractionMenu({ pet, onPetUpdate }: InteractionMenuPro
} }
try { try {
const action: PetAction = `GATHERING_${resourceType.toUpperCase()}` as PetAction; const action: PetGatherAction = `GATHERING_${resourceType.toUpperCase()}` as PetGatherAction;
const updatedPet = await updatePetAction(pet.id, { gatherAction: action }); const updatedPet = await updatePetAction(pet.id, { gatherAction: action });
onPetUpdate(updatedPet); onPetUpdate(updatedPet);
} catch (error) { } catch (error) {
@@ -136,8 +125,12 @@ export default function InteractionMenu({ pet, onPetUpdate }: InteractionMenuPro
} }
}; };
const handleCollect = () => {
setGatheredResources([]);
}
return ( return (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4"> <div className="grid grid-cols-2 md:grid-cols-3 gap-4 bg-gray-900 p-6 rounded-xl shadow-xl">
{remainingCooldown !== null && ( {remainingCooldown !== null && (
<div className="col-span-2 md:col-span-3 text-center p-2 bg-yellow-900/30 border-2 border-yellow-500/50 rounded-lg"> <div className="col-span-2 md:col-span-3 text-center p-2 bg-yellow-900/30 border-2 border-yellow-500/50 rounded-lg">
<span className="text-yellow-200">Cooldown: {formatCooldownTime(remainingCooldown)}</span> <span className="text-yellow-200">Cooldown: {formatCooldownTime(remainingCooldown)}</span>
@@ -150,6 +143,7 @@ export default function InteractionMenu({ pet, onPetUpdate }: InteractionMenuPro
onClick={performBasicAction('FEED')} onClick={performBasicAction('FEED')}
color="green" color="green"
disabled={remainingCooldown !== null} disabled={remainingCooldown !== null}
actionText="Feed your pet<br/>+1 Strengh (up to max)<br/>+5 Health<br/>-1 Food"
/> />
<ActionButton <ActionButton
icon={PlayCircle} icon={PlayCircle}
@@ -157,6 +151,7 @@ export default function InteractionMenu({ pet, onPetUpdate }: InteractionMenuPro
onClick={performBasicAction('PLAY')} onClick={performBasicAction('PLAY')}
color="blue" color="blue"
disabled={remainingCooldown !== null} disabled={remainingCooldown !== null}
actionText="Play with your pet<br/>+1 Charisma (up to max)<br/>-1 Junk"
/> />
<ActionButton <ActionButton
icon={Moon} icon={Moon}
@@ -164,6 +159,7 @@ export default function InteractionMenu({ pet, onPetUpdate }: InteractionMenuPro
onClick={performBasicAction('SLEEP')} onClick={performBasicAction('SLEEP')}
color="purple" color="purple"
disabled={remainingCooldown !== null} disabled={remainingCooldown !== null}
actionText="Put your pet to sleep<br/>+1 Intelligence (up to max)<br/>+1 Strengh (up to max)<br/>+15 Health"
/> />
<div className="col-span-2 md:col-span-3 grid grid-cols-3 gap-4"> <div className="col-span-2 md:col-span-3 grid grid-cols-3 gap-4">
@@ -175,7 +171,7 @@ export default function InteractionMenu({ pet, onPetUpdate }: InteractionMenuPro
color="amber" color="amber"
onActionClick={() => handleActionStart('gather')} onActionClick={() => handleActionStart('gather')}
onActionComplete={handleGatherComplete} onActionComplete={handleGatherComplete}
onResourcesUpdate={handleResourcesUpdate} actionText='Send your pet to gather resources<br/>Your pet will collect items over time'
/> />
<ActionResourceButton <ActionResourceButton
pet={pet} pet={pet}
@@ -185,7 +181,7 @@ export default function InteractionMenu({ pet, onPetUpdate }: InteractionMenuPro
color="emerald" color="emerald"
onActionClick={() => handleActionStart('explore')} onActionClick={() => handleActionStart('explore')}
onActionComplete={handleGatherComplete} onActionComplete={handleGatherComplete}
onResourcesUpdate={handleResourcesUpdate} actionText='Send your pet to explore based on their strength<br/>Your pet might find wisdom and rare items but also may lose health'
/> />
<ActionResourceButton <ActionResourceButton
pet={pet} pet={pet}
@@ -195,7 +191,7 @@ export default function InteractionMenu({ pet, onPetUpdate }: InteractionMenuPro
color="red" color="red"
onActionClick={() => handleActionStart('battle')} onActionClick={() => handleActionStart('battle')}
onActionComplete={handleGatherComplete} onActionComplete={handleGatherComplete}
onResourcesUpdate={handleResourcesUpdate} actionText='Send your pet to battle based on their strength<br/>Your pet will gain experience and items, but also may lose health'
/> />
</div> </div>
@@ -204,6 +200,7 @@ export default function InteractionMenu({ pet, onPetUpdate }: InteractionMenuPro
petId={pet.id} petId={pet.id}
resources={gatheredResources} resources={gatheredResources}
onCollect={handleCollect} onCollect={handleCollect}
onPetUpdate={onPetUpdate}
/> />
</div> </div>

View File

@@ -1,18 +1,23 @@
import { Pet } from '../types/Pet'; import { Pet } from '../types/Pet';
import { Brain, Dumbbell, Heart, Sparkles, Coins, Pizza, Trash2, Trophy } from 'lucide-react'; import { Brain, Dumbbell, Smile, Sparkles, Coins, Pizza, Trash2, Trophy, Heart, Star } from 'lucide-react';
import { PET_CLASSES } from '../data/petClasses'; import { PET_CLASSES } from '../data/petClasses';
import { useState } from 'react';
import InventoryModal from './modal/InventoryModal';
import SkillTreeModal from './modal/SkillTreeModal';
interface PetDisplayProps { interface PetDisplayProps {
pet: Pet; pet: Pet;
onPetUpdate: (updatedPet: Pet) => void; // Add this prop
} }
export default function PetDisplay({ pet }: PetDisplayProps) { export default function PetDisplay({ pet, onPetUpdate }: PetDisplayProps) {
const StatBar = ({ value, maxValue, label, icon: Icon }: { value: number; maxValue: number; label: string; icon: any }) => ( const StatBar = ({ value, maxValue, label, icon: Icon }:
<div className="flex items-center space-x-2"> { value: number; maxValue: number; label: string; icon: any; }) => (
<div className="flex items-center space-x-2" title={label}>
<Icon className="w-5 h-5" /> <Icon className="w-5 h-5" />
<div className="flex-1 bg-gray-700 rounded-full h-4"> <div className="flex-1 bg-gray-700 rounded-full h-4">
<div <div
className="bg-blue-500 rounded-full h-4" className={`bg-blue-500 rounded-full h-4`}
style={{ width: `${(value / maxValue) * 100}%` }} style={{ width: `${(value / maxValue) * 100}%` }}
/> />
</div> </div>
@@ -28,6 +33,9 @@ export default function PetDisplay({ pet }: PetDisplayProps) {
</div> </div>
); );
const [showInventoryModal, setShowInventoryModal] = useState(false);
const [showSkillTreeModal, setShowSkillTreeModal] = useState(false);
return ( return (
<div className="bg-gray-900 p-6 rounded-xl shadow-xl"> <div className="bg-gray-900 p-6 rounded-xl shadow-xl">
<div className="text-center mb-8"> <div className="text-center mb-8">
@@ -47,6 +55,14 @@ export default function PetDisplay({ pet }: PetDisplayProps) {
</div> </div>
<div className="space-y-4 mb-6"> <div className="space-y-4 mb-6">
<div className="space-y-4 mb-6">
<StatBar
value={pet.health}
maxValue={pet.maxHealth}
label="Health"
icon={Heart}
/>
</div>
<StatBar <StatBar
value={pet.stats.intelligence} value={pet.stats.intelligence}
maxValue={pet.stats.maxIntelligence} maxValue={pet.stats.maxIntelligence}
@@ -63,16 +79,50 @@ export default function PetDisplay({ pet }: PetDisplayProps) {
value={pet.stats.charisma} value={pet.stats.charisma}
maxValue={pet.stats.maxCharisma} maxValue={pet.stats.maxCharisma}
label="Charisma" label="Charisma"
icon={Heart} icon={Smile}
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="flex items-start mb-4 space-x-4">
<ResourceCounter value={pet.resources.wisdom} label="Wisdom" icon={Sparkles} /> <div className="grid grid-cols-2 gap-4 flex-1">
<ResourceCounter value={pet.resources.gold} label="Gold" icon={Coins} /> <ResourceCounter value={pet.resources.wisdom} label="Wisdom" icon={Sparkles} />
<ResourceCounter value={pet.resources.food} label="Food" icon={Pizza} /> <ResourceCounter value={pet.resources.gold} label="Gold" icon={Coins} />
<ResourceCounter value={pet.resources.junk} label="Junk" icon={Trash2} /> <ResourceCounter value={pet.resources.food} label="Food" icon={Pizza} />
<ResourceCounter value={pet.resources.junk} label="Junk" icon={Trash2} />
</div>
<div className="flex flex-col space-y-4">
<button
onClick={() => setShowInventoryModal(true)}
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded"
>
Inventory
</button>
<button
onClick={() => setShowSkillTreeModal(true)}
className="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded"
>
Skill Tree
</button>
</div>
</div> </div>
{showInventoryModal && (
<InventoryModal
inventory={pet.inventory}
petId={pet.id}
onClose={() => setShowInventoryModal(false)}
onPetUpdate={onPetUpdate}
/>
)}
{showSkillTreeModal && (
<SkillTreeModal
petId={pet.id}
petResources={pet.resources}
onPetUpdate={onPetUpdate}
onClose={() => setShowSkillTreeModal(false)
}
/>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,20 @@
interface TooltipProps {
content: string;
children: React.ReactNode;
}
export default function Tooltip({ content, children }: TooltipProps) {
return (
<div className="group relative inline-block w-full">
{children}
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-300
absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2
bg-gray-900 text-white text-sm rounded-lg shadow-lg whitespace-nowrap
border border-gray-700 pointer-events-none z-50">
<div dangerouslySetInnerHTML={{ __html: content }} />
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2
border-4 border-transparent border-t-gray-900" />
</div>
</div>
);
}

View File

@@ -0,0 +1,129 @@
import { useState } from 'react';
import { login, register, signInWithGoogle } from '../../services/auth/auth';
interface AuthFormProps {
onAuthSuccess: () => void;
}
export default function AuthForm({ onAuthSuccess }: AuthFormProps) {
const [isLogin, setIsLogin] = useState(true);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
try {
if (isLogin) {
await login(email, password);
} else {
await register(email, password);
}
onAuthSuccess();
} catch (err: any) {
setError(err.message);
}
};
const handleGoogleSignIn = async () => {
try {
await signInWithGoogle();
onAuthSuccess();
} catch (err: any) {
setError(err.message);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-900">
<div className="bg-gray-800 p-8 rounded-xl shadow-xl w-96">
<h2 className="text-2xl font-bold text-white mb-6 text-center">
{isLogin ? 'Login' : 'Register'}
</h2>
{error && (
<div className="bg-red-500 text-white p-3 rounded mb-4 text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full p-3 rounded bg-gray-700 text-white"
required
/>
</div>
<div>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full p-3 rounded bg-gray-700 text-white"
required
/>
</div>
<button
type="submit"
className="w-full bg-blue-600 hover:bg-blue-700 text-white p-3 rounded"
>
{isLogin ? 'Login' : 'Register'}
</button>
</form>
<div className="mt-4 relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-600"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-gray-800 text-gray-400">Or continue with</span>
</div>
</div>
<button
type="button"
onClick={handleGoogleSignIn}
className="mt-4 w-full flex items-center justify-center gap-2 bg-white hover:bg-gray-100 text-gray-900 font-semibold p-3 rounded"
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
Continue with Google
</button>
<button
onClick={() => setIsLogin(!isLogin)}
className="w-full text-blue-400 mt-4 text-sm"
>
{isLogin ? 'Need an account? Register' : 'Have an account? Login'}
</button>
</div>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { LucideIcon } from 'lucide-react'; import { LucideIcon } from 'lucide-react';
import Tooltip from '../Tooltip';
interface ActionButtonProps { interface ActionButtonProps {
icon: LucideIcon; icon: LucideIcon;
@@ -6,10 +7,11 @@ interface ActionButtonProps {
onClick: () => void; onClick: () => void;
color: string; color: string;
disabled?: boolean; disabled?: boolean;
actionText?: string;
} }
export default function ActionButton({ icon: Icon, label, onClick, color, disabled }: ActionButtonProps) { export default function ActionButton({ icon: Icon, label, onClick, color, disabled, actionText }: ActionButtonProps) {
return ( const button = (
<button <button
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
@@ -22,4 +24,14 @@ export default function ActionButton({ icon: Icon, label, onClick, color, disabl
<span>{label}</span> <span>{label}</span>
</button> </button>
); );
if (!actionText) {
return button;
}
return (
<Tooltip content={actionText}>
{button}
</Tooltip>
);
} }

View File

@@ -0,0 +1,201 @@
import React, { useState, useEffect } from 'react';
import { Inventory, InvItemInteraction, Pet } from '../../types/Pet';
import { putPetItemInteract, getItemIcon, getItemInfo } from '../../services/api/api';
import { Loader2 } from 'lucide-react';
import { GameItem, ItemRarity } from '../../types/GameItem';
interface InventoryModalProps {
inventory: Inventory;
petId: string;
onClose: () => void;
onPetUpdate: (updatedPet: Pet) => void;
}
export default function InventoryModal({ inventory, petId, onClose, onPetUpdate }: InventoryModalProps) {
const capacity = inventory.capacity;
const items = inventory.items;
const gridSlots = Array.from({ length: capacity }, (_, i) => items[i]);
const [selectedItemIndex, setSelectedItemIndex] = useState<number | null>(null);
const [itemIcons, setItemIcons] = useState<Map<number, string>>(new Map());
const [loadingIcons, setLoadingIcons] = useState<Set<number>>(new Set());
const [selectedItemDetails, setSelectedItemDetails] = useState<GameItem | null>(null);
const [loadingDetails, setLoadingDetails] = useState(false);
const rarityColors = {
[ItemRarity.Common]: 'text-gray-200',
[ItemRarity.Uncommon]: 'text-green-400',
[ItemRarity.Rare]: 'text-blue-400',
[ItemRarity.Legendary]: 'text-purple-400'
};
useEffect(() => {
const fetchItemIcons = async () => {
const newIcons = new Map<number, string>();
const loadingItems = new Set<number>();
for (const itemId of items) {
if (itemId !== undefined && !itemIcons.has(itemId)) {
loadingItems.add(itemId);
}
}
setLoadingIcons(loadingItems);
for (const itemId of loadingItems) {
if (itemId !== undefined && !itemIcons.has(itemId)) {
try {
const blob = await getItemIcon(itemId);
const imageUrl = URL.createObjectURL(blob);
newIcons.set(itemId, imageUrl);
} catch (error) {
console.error(`Failed to load icon for item ${itemId}:`, error);
} finally {
loadingItems.delete(itemId);
setLoadingIcons(new Set(loadingItems));
}
}
}
setItemIcons(new Map([...itemIcons, ...newIcons]));
};
fetchItemIcons();
return () => {
// Cleanup object URLs on unmount
itemIcons.forEach(url => URL.revokeObjectURL(url));
};
}, [items]);
const handleItemClick = async (index: number) => {
if (selectedItemIndex === index || gridSlots[index] === undefined) {
setSelectedItemIndex(null);
setSelectedItemDetails(null);
return;
}
setSelectedItemIndex(index);
const itemId = gridSlots[index];
if (itemId !== undefined) {
setLoadingDetails(true);
try {
const details = await getItemInfo(itemId);
setSelectedItemDetails(details);
} catch (error) {
console.error('Failed to load item details:', error);
} finally {
setLoadingDetails(false);
}
}
};
const handleInteraction = async (interaction: InvItemInteraction) => {
if (selectedItemIndex === null || gridSlots[selectedItemIndex] === undefined) return;
try {
const updatedPet = await putPetItemInteract(petId, gridSlots[selectedItemIndex], interaction);
onPetUpdate(updatedPet);
} catch (error) {
console.error('Item interaction failed:', error);
}
};
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50">
<div className="bg-gray-800 p-6 rounded-xl max-w-2xl w-full">
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-bold">Inventory</h2>
<button
onClick={onClose}
className="bg-red-600 hover:bg-red-700 text-white px-2 py-1 rounded"
>
Close
</button>
</div>
<div className="grid grid-cols-5 grid-rows-4 gap-4">
{gridSlots.map((itemId, index) => (
<div
key={index}
onClick={() => handleItemClick(index)}
className={`border rounded p-2 flex items-center justify-center h-24 w-24
${itemId !== undefined ? 'bg-gray-700 cursor-pointer' : 'bg-gray-800 text-gray-500'}
${selectedItemIndex === index ? 'border-blue-500 animate-pulse' : 'border-gray-600'}`}
>
{itemId !== undefined ? (
loadingIcons.has(itemId) ? (
<Loader2 className="w-6 h-6 animate-spin" />
) : (
<img
src={itemIcons.get(itemId)}
alt={`Item ${itemId}`}
className="w-full h-full object-contain"
/>
)
) : (
<span className="text-gray-500">Empty</span>
)}
</div>
))}
</div>
{selectedItemIndex !== null && (
<div className="mt-4 mb-4 p-4 bg-gray-700 rounded">
{loadingDetails ? (
<div className="flex justify-center">
<Loader2 className="w-6 h-6 animate-spin" />
</div>
) : selectedItemDetails ? (
<div className="space-y-2">
<h3 className="text-lg font-semibold text-white mb-1">
{selectedItemDetails.name}
</h3>
<div className="flex gap-2 items-center">
<span className={rarityColors[selectedItemDetails.rarity]}>
{selectedItemDetails.rarity}
</span>
<span className="text-gray-300"></span>
<span className="text-gray-200">{selectedItemDetails.type}</span>
</div>
<div className="text-gray-300 text-sm space-y-1">
{selectedItemDetails.description.split(';').map((line, index) => (
<p key={index}>{line.trim()}</p>
))}
</div>
</div>
) : null}
</div>
)}
<div className="mt-4 grid grid-cols-4 gap-4">
<button
disabled={selectedItemIndex === null}
onClick={() => handleInteraction('USE')}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded disabled:opacity-50"
>
Use
</button>
<button
disabled={selectedItemIndex === null}
onClick={() => handleInteraction('DROP')}
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded disabled:opacity-50"
>
Drop
</button>
<button
disabled={selectedItemIndex === null}
onClick={() => handleInteraction('EQUIP')}
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded disabled:opacity-50"
>
Equip
</button>
<button
disabled={selectedItemIndex === null}
onClick={() => handleInteraction('UNEQUIP')}
className="bg-yellow-600 hover:bg-yellow-700 text-white px-4 py-2 rounded disabled:opacity-50"
>
Unequip
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
.customScroll {
scrollbar-width: thin;
scrollbar-color: #4B5563 #1F2937;
}
.customScroll::-webkit-scrollbar {
width: 8px;
}
.customScroll::-webkit-scrollbar-track {
background: #1F2937;
border-radius: 4px;
}
.customScroll::-webkit-scrollbar-thumb {
background-color: #4B5563;
border-radius: 4px;
}
.customScroll::-webkit-scrollbar-thumb:hover {
background-color: #6B7280;
}

View File

@@ -0,0 +1,213 @@
import { useEffect, useState } from 'react';
import { Pet, Resources } from '../../types/Pet';
import { Skill, PetSkill, SkillType } from '../../types/Skills';
import { fetchPets, getAllSkills, getPetSkills, postAllocatePetSkill } from '../../services/api/api';
import { Loader2 } from 'lucide-react';
import styles from './SkillTreeModal.module.css';
interface SkillTreeModalProps {
onClose: () => void;
petId: string;
petResources: Resources;
onPetUpdate: (updatedPet: Pet) => void;
}
export default function SkillTreeModal({ onClose, petId, petResources, onPetUpdate }: SkillTreeModalProps) {
const [skills, setSkills] = useState<Skill[]>([]);
const [petSkills, setPetSkills] = useState<PetSkill[]>([]);
const [loading, setLoading] = useState(true);
const [allocating, setAllocating] = useState(false);
useEffect(() => {
const fetchData = async () => {
try {
const [allSkills, ownedSkills] = await Promise.all([
getAllSkills(),
getPetSkills(petId)
]);
setSkills(allSkills);
setPetSkills(ownedSkills);
} catch (error) {
console.error('Error fetching skills:', error);
} finally {
setLoading(false);
}
};
fetchData();
}, [petId]);
const handleAllocateSkill = async (skillId: number) => {
try {
setAllocating(true);
const updatedPetSkill = await postAllocatePetSkill(petId, skillId);
setPetSkills([...petSkills, updatedPetSkill]);
// Refresh pet data to update skill points
const updatedPet = await fetchPets();
onPetUpdate(updatedPet[0]);
} catch (error) {
console.error('Error allocating skill:', error);
} finally {
setAllocating(false);
}
};
const getSkillTier = (skillId: number) => {
return petSkills.find(ps => ps.skillId === skillId)?.currentTier;
};
const canAllocateSkill = (skill: Skill) => {
// check if all required skills are owned, and has all the required resources
const requiredSkills = skills.filter(s => skill.skillsIdRequired?.includes(s.id));
const requiredSkillIds = requiredSkills.map(s => s.id);
const ownedRequiredSkills = petSkills.filter(ps => requiredSkillIds.includes(ps.skillId));
const hasAllRequiredSkills = requiredSkills.length === ownedRequiredSkills.length;
const hasAllResources = skill.skillRequirements.every(req => {
const resourceValue = petResources[req.resource.toLowerCase() as keyof Resources];
return resourceValue >= req.cost;
});
return hasAllRequiredSkills && hasAllResources;
};
const getRequiredSkillNames = (skillId: number[] | null) => {
if (!skillId) return null;
return skills.filter(s => skillId.includes(s.id)).map(s => s.name);
};
const getNextTierEffect = (currentTier?: string) => {
if (!currentTier) return 'I';
if (currentTier === 'I') return 'II';
if (currentTier === 'II') return 'III';
return null;
};
const renderSkillNode = (skill: Skill) => {
const owned = petSkills.some(ps => ps.skillId === skill.id);
const tier = getSkillTier(skill.id);
const isMaxTier = tier === 'III';
const canAllocate = !isMaxTier && canAllocateSkill(skill);
const nextTierEffect = getNextTierEffect(tier);
const requiredSkillNames = getRequiredSkillNames(skill.skillsIdRequired);
return (
<div
key={skill.id}
className={`
relative p-4 rounded-lg border-2 flex flex-col
${isMaxTier ? 'border-green-500 bg-gray-700' :
owned ? 'border-blue-500 bg-gray-700' : 'border-gray-600 bg-gray-800'}
transition-colors
`}
>
<div className="flex-grow">
<div className="flex items-center gap-2 mb-2">
<span>{skill.icon}</span>
<h3 className="font-bold">{skill.name}</h3>
{tier && (
<span className={`px-2 py-1 rounded text-xs ${isMaxTier ? 'bg-green-600' : 'bg-blue-600'}`}>
Tier {tier}
</span>
)}
</div>
<p className="text-sm text-gray-300 mb-2">{skill.description}</p>
<div className="text-sm space-y-2 mb-3">
{!isMaxTier && nextTierEffect && (
<div className="text-blue-300">
Next: Tier {nextTierEffect}
</div>
)}
{!isMaxTier && nextTierEffect && (
<div className="space-y-1">
<div className="text-gray-400 font-medium">Requirements:</div>
{requiredSkillNames && requiredSkillNames.map((name, idx) => (
<div key={idx} className={`text-xs ${canAllocate ? 'text-green-400' : 'text-red-400'}`}>
Requires: {name}
</div>
))}
{skill.skillRequirements.map((req, idx) => (
<div key={idx} className={`text-xs ${petResources[req.resource.toLowerCase() as keyof Resources] >= req.cost
? 'text-green-400' : 'text-red-400'
}`}>
{req.resource}: {req.cost}
</div>
))}
</div>
)}
</div>
</div>
{isMaxTier ? (
<div className="mt-auto px-3 py-1 rounded text-sm bg-green-600 text-center">
Mastered
</div>
) : (
<button
onClick={() => handleAllocateSkill(skill.id)}
disabled={!canAllocate || allocating}
className={`
mt-auto px-3 py-1 rounded text-sm
${canAllocate
? owned
? 'bg-blue-600 hover:bg-blue-700'
: 'bg-green-600 hover:bg-green-700'
: 'bg-gray-600 cursor-not-allowed'}
`}
>
{allocating ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
owned ? 'Upgrade' : 'Learn'
)}
</button>
)}
</div>
);
};
const renderSkillTree = () => {
const groundSkills = skills.filter(s => s.type === 'GROUND');
const grandSkills = skills.filter(s => s.type === 'GRAND');
return (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{groundSkills.map(renderSkillNode)}
</div>
<div className="border-t border-gray-600 pt-6">
<h2 className="text-xl font-bold mb-4">Advanced Skills</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{grandSkills.map(renderSkillNode)}
</div>
</div>
</div>
);
};
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50">
<div className={`bg-gray-800 p-6 rounded-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto ${styles.customScroll}`}>
<h2 className="text-2xl font-bold mb-4">Skill Tree</h2>
{loading ? (
<div className="flex justify-center items-center h-40">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
) : (
renderSkillTree()
)}
<div className="mt-6 flex justify-end">
<button
onClick={onClose}
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded"
>
Close
</button>
</div>
</div>
</div>
);
}

19
src/config/firebase.ts Normal file
View File

@@ -0,0 +1,19 @@
import { initializeApp } from 'firebase/app';
import { getFirestore } from 'firebase/firestore';
import { getAuth } from 'firebase/auth';
import { getAnalytics } from 'firebase/analytics';
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY || 'VITE_FIREBASE_API_KEY',
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN || 'VITE_FIREBASE_AUTH_DOMAIN',
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID || 'VITE_FIREBASE_PROJECT_ID',
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET || 'VITE_FIREBASE_STORAGE_BUCKET',
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID || 'VITE_FIREBASE_MESSAGING_SENDER_ID',
appId: import.meta.env.VITE_FIREBASE_APP_ID || 'VITE_FIREBASE_APP_ID',
measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID || 'VITE_FIREBASE_MEASUREMENT_ID',
};
const app = initializeApp(firebaseConfig);
export const db = getFirestore(app);
export const auth = getAuth(app);
export const analytics = getAnalytics(app);

View File

@@ -1,3 +1,18 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
body {
margin: 0;
overflow-x: hidden;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
}
.backdrop-blur-sm {
backdrop-filter: blur(8px);
}
/* Add smooth transitions */
.transition-all {
transition: all 0.3s ease-in-out;
}

View File

@@ -1,57 +1,69 @@
import { ApiService } from './index'; import { ApiService } from './index';
import { Pet, Resources } from '../../types/Pet'; import { InvItemInteraction, Pet, Resources } from '../../types/Pet';
import { PetCreationRequest } from '../../types/PetCreationRequest'; import { PetCreationRequest } from '../../types/PetCreationRequest';
import { PetUpdateActionRequest } from '../../types/PetUpdateActionRequest'; import { PetActionGathered, PetUpdateActionRequest } from '../../types/PetAction';
import { PetSkill, Skill } from '../../types/Skills';
import { GameItem } from '../../types/GameItem';
// Get API service instance // Get API service instance
const api = ApiService.getInstance(); const api = ApiService.getInstance();
export async function fetchPets(): Promise<Pet[]> { export async function fetchPets(): Promise<Pet[]> {
try { const response = await api.get<Pet[]>('/api/v1/pet');
const response = await api.get<Pet[]>('/api/v1/pet'); return response.data;
return response.data;
} catch (error: any) {
console.error('Failed to fetch pets:', error.message);
throw error;
}
} }
export async function createPet(data: PetCreationRequest): Promise<Pet> { export async function createPet(data: PetCreationRequest): Promise<Pet> {
try { const response = await api.post<Pet>('/api/v1/pet', data);
const response = await api.post<Pet>('/api/v1/pet', data); return response.data;
return response.data;
} catch (error: any) {
console.error('Failed to create pet:', error.message);
throw error;
}
} }
export async function updatePetAction(petId: string, data: PetUpdateActionRequest): Promise<Pet> { export async function updatePetAction(petId: string, data: PetUpdateActionRequest): Promise<Pet> {
try { const response = await api.put<Pet>(`/api/v1/pet/${petId}/action`, data);
const response = await api.put<Pet>(`/api/v1/pet/${petId}/action`, data); return response.data;
return response.data;
} catch (error: any) {
console.error('Failed to update pet action:', error.message);
throw error;
}
} }
export async function getPetGatheredResources(petId: string): Promise<Resources> { export async function getPetGatheredResources(petId: string): Promise<PetActionGathered[]> {
try { const response = await api.get<PetActionGathered[]>(`/api/v1/pet/${petId}/resources/gathered`);
const response = await api.get<Resources>(`/api/v1/pet/${petId}/resources/gathered`); return response.data;
return response.data;
} catch (error: any) {
console.error('Failed to fetch pet gathered resources:', error.message);
throw error;
}
} }
export async function putPetCollectResources(petId: string): Promise<Pet> { export async function putPetCollectResources(petId: string): Promise<Pet> {
try { const response = await api.put<Pet>(`/api/v1/pet/${petId}/resources/collect`);
const response = await api.put<Pet>(`/api/v1/pet/${petId}/resources/collect`); return response.data;
return response.data; }
} catch (error: any) {
console.error('Failed to collect pet gathered resources:', error.message); export async function getAllSkills(): Promise<Skill[]> {
throw error; const response = await api.get<Skill[]>(`/api/v1/skill`);
} return response.data;
}
export async function getPetSkills(petId: string): Promise<PetSkill[]> {
const response = await api.get<PetSkill[]>(`/api/v1/skill/${petId}/skills`);
return response.data;
}
export async function postAllocatePetSkill(petId: string, skillId: number): Promise<PetSkill> {
const response = await api.post<PetSkill>(`/api/v1/skill/${petId}/allocate/${skillId}`);
return response.data;
}
export async function putPetItemInteract(petId: string, itemId: number, inter: InvItemInteraction): Promise<Pet> {
const response = await api.put<Pet>(`/api/v1/inventory/${petId}/${itemId}/${inter.toLowerCase()}`);
return response.data;
}
export async function getItemInfo(itemId: number): Promise<GameItem> {
const response = await api.get<GameItem>(`/api/v1/gamedata/item/${itemId}`);
return response.data;
}
export async function getItemIcon(itemId: number): Promise<Blob> {
const response = await api.get<Blob>(`/api/v1/gamedata/item/${itemId}/icon`, {
responseType: 'blob',
headers: {
Accept: 'image/png'
}
});
return response.data;
} }

View File

@@ -2,7 +2,7 @@ import { ApiConfig } from './types';
// API configuration // API configuration
export const apiConfig: ApiConfig = { export const apiConfig: ApiConfig = {
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:5278', baseURL: import.meta.env.VITE_API_BASE_URL || 'VITE_API_BASE_URL',
timeout: 10000, // 10 seconds timeout: 10000, // 10 seconds
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@@ -49,4 +49,8 @@ export class ApiErrorHandler {
static isClientError(error: ApiError): boolean { static isClientError(error: ApiError): boolean {
return error.status >= 400 && error.status < 500; return error.status >= 400 && error.status < 500;
} }
public static isUnauthorizedError(error: ApiError): boolean {
return error.status === 401;
}
} }

View File

@@ -3,17 +3,60 @@ import { apiConfig } from './config';
import { setupInterceptors } from './interceptors'; import { setupInterceptors } from './interceptors';
import { ApiErrorHandler } from './error'; import { ApiErrorHandler } from './error';
import { ApiResponse, RequestOptions, ApiError } from './types'; import { ApiResponse, RequestOptions, ApiError } from './types';
import { auth } from '../../config/firebase';
class ApiService { class ApiService {
private static instance: ApiService; private static instance: ApiService;
private axios: AxiosInstance; private axiosInstance: AxiosInstance;
private constructor() { private constructor() {
// Create axios instance with base configuration // Create axios instance with base configuration
this.axios = axios.create(apiConfig); this.axiosInstance = axios.create(apiConfig);
// Setup interceptors // Setup interceptors
setupInterceptors(this.axios); setupInterceptors(this.axiosInstance);
// Add request interceptor
this.axiosInstance.interceptors.request.use(
async (config) => {
const token = await auth.currentUser?.getIdToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Add response interceptor
this.axiosInstance.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// Check if error is 401 and we haven't tried to refresh token yet
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// Force token refresh
const user = auth.currentUser;
if (user) {
const newToken = await user.getIdToken(true);
originalRequest.headers.Authorization = `Bearer ${newToken}`;
localStorage.setItem('TOKEN', newToken);
return this.axiosInstance(originalRequest);
}
} catch (refreshError) {
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
} }
public static getInstance(): ApiService { public static getInstance(): ApiService {
@@ -40,7 +83,7 @@ class ApiService {
config.data = data; config.data = data;
} }
const response = await this.axios.request<T>(config); const response = await this.axiosInstance.request<T>(config);
return { return {
data: response.data, data: response.data,

View File

@@ -29,4 +29,5 @@ export interface RequestOptions {
headers?: Record<string, string>; headers?: Record<string, string>;
params?: Record<string, any>; params?: Record<string, any>;
timeout?: number; timeout?: number;
responseType? : 'json' | 'blob' | 'text';
} }

View File

@@ -0,0 +1,10 @@
import { Pet } from '../../types/Pet';
import { ApiService } from './index';
// Get API service instance
const api = ApiService.getInstance();
export async function fetchPets(): Promise<Pet[]> {
const response = await api.get<Pet[]>('/api/v1/pet');
return response.data;
}

40
src/services/auth/auth.ts Normal file
View File

@@ -0,0 +1,40 @@
import {
signInWithEmailAndPassword,
createUserWithEmailAndPassword,
signOut as firebaseSignOut,
User,
GoogleAuthProvider,
signInWithPopup
} from 'firebase/auth';
import { auth } from '../../config/firebase';
export async function login(email: string, password: string): Promise<string> {
const userCredential = await signInWithEmailAndPassword(auth, email, password);
const token = await userCredential.user.getIdToken();
localStorage.setItem('TOKEN', token);
return token;
}
export async function register(email: string, password: string): Promise<string> {
const userCredential = await createUserWithEmailAndPassword(auth, email, password);
const token = await userCredential.user.getIdToken();
localStorage.setItem('TOKEN', token);
return token;
}
export async function signOut(): Promise<void> {
await firebaseSignOut(auth);
localStorage.removeItem('TOKEN');
}
export function getCurrentUser(): User | null {
return auth.currentUser;
}
export async function signInWithGoogle(): Promise<string> {
const provider = new GoogleAuthProvider();
const userCredential = await signInWithPopup(auth, provider);
const token = await userCredential.user.getIdToken();
localStorage.setItem('TOKEN', token);
return token;
}

31
src/types/GameItem.ts Normal file
View File

@@ -0,0 +1,31 @@
export interface GameItem {
id: number;
name: string;
type: ItemType;
rarity: ItemRarity;
description: string;
price: number;
effect: string;
equipTarget: ItemEquipTarget;
}
export enum ItemType {
Material = 'Material',
Consumable = 'Consumable',
Equipment = 'Equipment'
}
export enum ItemRarity {
Common = 'Common', // White
Uncommon = 'Uncommon', // Green
Rare = 'Rare', // Blue
Legendary = 'Legendary' // Purple
}
export enum ItemEquipTarget {
None = 'None',
Head = 'Head',
Body = 'Body',
Legs = 'Legs',
Weapon = 'Weapon'
}

View File

@@ -1,4 +1,4 @@
import { PetBasicAction, PetGatherAction } from "./PetUpdateActionRequest"; import { PetBasicAction, PetGatherAction } from "./PetAction";
export type PetClass = 'FOREST_SPIRIT' | 'OCEAN_GUARDIAN' | 'FIRE_ELEMENTAL' | 'MYTHICAL_BEAST' | 'SHADOW_WALKER' | 'CYBER_PET' | 'BIO_MECHANICAL'; export type PetClass = 'FOREST_SPIRIT' | 'OCEAN_GUARDIAN' | 'FIRE_ELEMENTAL' | 'MYTHICAL_BEAST' | 'SHADOW_WALKER' | 'CYBER_PET' | 'BIO_MECHANICAL';
@@ -25,9 +25,19 @@ export interface Pet {
stats: PetStats; stats: PetStats;
resources: Resources; resources: Resources;
level: number; level: number;
petBasicAction: PetBasicAction; health: number;
basicActionCooldown: Date; maxHealth: number;
petGatherAction: PetGatherAction; petGatherAction: PetGatherAction;
basicActionCooldown: Date;
petBasicAction: PetBasicAction;
skillPoints: number;
inventory: Inventory;
}
export type InvItemInteraction = 'EQUIP' | 'UNEQUIP' | 'USE' | 'DROP';
export interface Inventory {
items: number[];
capacity: number;
} }
export interface PetClassInfo { export interface PetClassInfo {

22
src/types/PetAction.ts Normal file
View File

@@ -0,0 +1,22 @@
export type PetBasicAction = 'UNKNOWN' | 'FEED' | 'PLAY' | 'SLEEP';
export type PetGatherAction = 'IDLE' | 'GATHERING_WISDOM' | 'GATHERING_GOLD' | 'GATHERING_FOOD' | 'EXPLORE' | 'BATTLE';
export interface PetUpdateActionRequest {
basicAction?: PetBasicAction;
gatherAction?: PetGatherAction;
}
export interface PetActionGathered {
petId: string;
resource: string;
itemId: number;
amount: number;
gameItem: PetGatheredItem;
}
export interface PetGatheredItem {
id: number;
name: number;
type: string;
rarity: string;
}

View File

@@ -1,6 +1,4 @@
import { PetClass } from "./Pet";
export interface PetCreationRequest { export interface PetCreationRequest {
name: string; name: string;
class: PetClass; class: string;
} }

View File

@@ -1,7 +0,0 @@
export type PetBasicAction = 'UNKNOWN' | 'FEED' | 'PLAY' | 'SLEEP';
export type PetGatherAction = 'IDLE' | 'GATHERING_WISDOM' | 'GATHERING_GOLD' | 'GATHERING_FOOD';
export interface PetUpdateActionRequest {
basicAction?: PetBasicAction;
gatherAction?: PetGatherAction;
}

34
src/types/Skills.ts Normal file
View File

@@ -0,0 +1,34 @@
export type SkillType = 'GROUND' | 'GRAND';
export interface Skill {
id: number;
name: string;
description: string;
type: SkillType;
skillRequirements: SkillRequirement[];
icon: string;
skillsIdRequired: number[] | null;
effects: SkillEffect[];
}
export interface SkillRequirement {
cost: number;
resource: string;
}
export interface SkillEffect {
id: number;
skillId: number;
tier: SkillTier;
effect: string;
value: number;
}
export type SkillTier = 'I' | 'II' | 'III';
export interface PetSkill {
id: number;
petId: string;
skillId: number;
skill: Skill;
currentTier: SkillTier;
}

View File

@@ -1,34 +1,29 @@
import { PetGatherAction } from '../types/PetUpdateActionRequest'; import { PetGatherAction } from '../types/PetAction';
export function isGatheringAction(action: PetGatherAction): boolean { export function isGatheringAction(action: PetGatherAction): boolean {
return action.startsWith('GATHERING_'); return action.startsWith('GATHERING_');
} }
export function getResourceFromAction(action: string): string | null { export function isActionActive(currentAction: PetGatherAction, actionType: 'gather' | 'explore' | 'battle'): boolean {
if (!action) return null; if (actionType === 'gather') {
return currentAction.startsWith('GATHERING_');
const patterns = ['GATHERING_', 'EXPLORING_', 'BATTLE_']; }
for (const pattern of patterns) { if (actionType === 'explore') {
if (action.startsWith(pattern)) { return currentAction === 'EXPLORE';
return action.replace(pattern, '').toLowerCase(); }
} if (actionType === 'battle') {
return currentAction === 'BATTLE';
}
return false;
}
export function getResourceFromAction(action: PetGatherAction): string | null {
if (action.startsWith('GATHERING_')) {
return action.replace('GATHERING_', '').toLowerCase();
} }
return null; return null;
} }
export function formatResourceName(resource: string): string { export function formatResourceName(resource: string): string {
return resource.charAt(0).toUpperCase() + resource.slice(1); return resource.charAt(0).toUpperCase() + resource.slice(1).toLowerCase();
}
export function isActionActive(action: string, actionType: string): boolean {
if (!action) return false;
const actionMap = {
'gather': 'GATHERING_',
'explore': 'EXPLORING_',
'battle': 'BATTLE_'
};
return action.startsWith(actionMap[actionType] || '');
} }