Compare commits
16 Commits
2202bd606f
...
main
Author | SHA1 | Date | |
---|---|---|---|
0be511c245 | |||
2487076e92 | |||
1f2a71452b | |||
e5af3b8c51 | |||
d1a08d055a | |||
64ca3bedf4 | |||
53b9694b09 | |||
9fbff6d8f2 | |||
a12cfc5a2a | |||
62634a426e | |||
88a9c6507c | |||
c2e5bf92a3 | |||
78c0f52c39 | |||
243c50a1a0 | |||
a624ba1e73 | |||
dd32327383 |
7
.env.example
Normal file
7
.env.example
Normal 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
7
.env.production
Normal 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
58
.gitea/workflows/main.yml
Normal 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
2
.gitignore
vendored
@@ -22,3 +22,5 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
.env
|
||||||
|
18
Dockerfile
Normal file
18
Dockerfile
Normal 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
12
env.sh
Normal 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
|
@@ -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
10
nginx.conf
Normal 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
1071
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
62
src/App.tsx
62
src/App.tsx
@@ -1,29 +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 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');
|
||||||
};
|
};
|
||||||
@@ -32,6 +48,25 @@ 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -48,7 +83,10 @@ export default function App() {
|
|||||||
<AnimatedBackground />
|
<AnimatedBackground />
|
||||||
<div className="relative z-10 min-h-screen backdrop-blur-sm bg-gray-900/20 text-white p-4">
|
<div className="relative z-10 min-h-screen backdrop-blur-sm bg-gray-900/20 text-white p-4">
|
||||||
<div className="max-w-4xl mx-auto grid gap-8">
|
<div className="max-w-4xl mx-auto grid gap-8">
|
||||||
<PetDisplay pet={pet} />
|
<PetDisplay
|
||||||
|
pet={pet}
|
||||||
|
onPetUpdate={handlePetUpdate} // Add this prop
|
||||||
|
/>
|
||||||
<InteractionMenu
|
<InteractionMenu
|
||||||
pet={pet}
|
pet={pet}
|
||||||
onPetUpdate={handlePetUpdate}
|
onPetUpdate={handlePetUpdate}
|
||||||
|
@@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -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,6 +125,10 @@ export default function InteractionMenu({ pet, onPetUpdate }: InteractionMenuPro
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCollect = () => {
|
||||||
|
setGatheredResources([]);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 bg-gray-900 p-6 rounded-xl shadow-xl">
|
<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 && (
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
20
src/components/Tooltip.tsx
Normal file
20
src/components/Tooltip.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
129
src/components/auth/AuthForm.tsx
Normal file
129
src/components/auth/AuthForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
201
src/components/modal/InventoryModal.tsx
Normal file
201
src/components/modal/InventoryModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
22
src/components/modal/SkillTreeModal.module.css
Normal file
22
src/components/modal/SkillTreeModal.module.css
Normal 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;
|
||||||
|
}
|
213
src/components/modal/SkillTreeModal.tsx
Normal file
213
src/components/modal/SkillTreeModal.tsx
Normal 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
19
src/config/firebase.ts
Normal 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);
|
@@ -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;
|
||||||
}
|
}
|
@@ -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',
|
||||||
|
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
@@ -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,
|
||||||
|
@@ -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';
|
||||||
}
|
}
|
10
src/services/api/userApi.ts
Normal file
10
src/services/api/userApi.ts
Normal 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
40
src/services/auth/auth.ts
Normal 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
31
src/types/GameItem.ts
Normal 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'
|
||||||
|
}
|
@@ -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
22
src/types/PetAction.ts
Normal 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;
|
||||||
|
}
|
@@ -1,6 +1,4 @@
|
|||||||
import { PetClass } from "./Pet";
|
|
||||||
|
|
||||||
export interface PetCreationRequest {
|
export interface PetCreationRequest {
|
||||||
name: string;
|
name: string;
|
||||||
class: PetClass;
|
class: string;
|
||||||
}
|
}
|
@@ -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
34
src/types/Skills.ts
Normal 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;
|
||||||
|
}
|
@@ -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] || '');
|
|
||||||
}
|
}
|
Reference in New Issue
Block a user