This commit is contained in:
Joan
2025-11-27 16:27:01 +01:00
parent 33cc9586c2
commit 81f8912059
304 changed files with 56149 additions and 10122 deletions

View File

@@ -0,0 +1,261 @@
.account-page {
min-height: 100vh;
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 100%);
padding: 2rem;
}
.account-container {
max-width: 1000px;
margin: 0 auto;
}
.account-title {
font-size: 2.5rem;
color: #646cff;
margin-bottom: 2rem;
text-align: center;
}
.account-loading,
.account-error {
text-align: center;
padding: 3rem;
color: #fff;
}
.account-error h2 {
color: #ff6b6b;
margin-bottom: 1rem;
}
/* Account Sections */
.account-section {
background: rgba(42, 42, 42, 0.6);
backdrop-filter: blur(10px);
border: 1px solid rgba(100, 108, 255, 0.2);
border-radius: 12px;
padding: 2rem;
margin-bottom: 2rem;
}
.section-title {
font-size: 1.5rem;
color: #646cff;
margin-bottom: 1.5rem;
border-bottom: 1px solid rgba(100, 108, 255, 0.2);
padding-bottom: 0.5rem;
}
/* Account Information Grid */
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
}
.info-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.info-label {
font-size: 0.9rem;
color: #888;
font-weight: 600;
}
.info-value {
font-size: 1.1rem;
color: #fff;
}
.info-value.premium {
color: #ffd93d;
font-weight: 600;
}
/* Characters Grid */
.characters-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.character-card {
background: rgba(26, 26, 26, 0.8);
border: 1px solid rgba(100, 108, 255, 0.3);
border-radius: 8px;
padding: 1.5rem;
transition: all 0.3s ease;
}
.character-card:hover {
transform: translateY(-4px);
border-color: rgba(100, 108, 255, 0.6);
box-shadow: 0 8px 20px rgba(100, 108, 255, 0.2);
}
.character-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.character-header h3 {
font-size: 1.3rem;
color: #fff;
margin: 0;
}
.character-level {
background: linear-gradient(135deg, #646cff 0%, #8b5cf6 100%);
color: #fff;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 600;
}
.character-stats {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
.stat {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.stat-label {
font-size: 0.8rem;
color: #888;
}
.stat-value {
font-size: 1rem;
color: #fff;
font-weight: 600;
}
.character-attributes {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
font-size: 0.9rem;
color: #aaa;
}
.no-characters {
color: #888;
text-align: center;
padding: 2rem;
font-style: italic;
}
/* Settings */
.setting-item {
margin-bottom: 2rem;
padding-bottom: 2rem;
border-bottom: 1px solid rgba(100, 108, 255, 0.1);
}
.setting-item:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.setting-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.setting-header h3 {
font-size: 1.2rem;
color: #fff;
margin: 0;
}
.setting-form {
background: rgba(26, 26, 26, 0.6);
border: 1px solid rgba(100, 108, 255, 0.2);
border-radius: 8px;
padding: 1.5rem;
margin-top: 1rem;
}
.setting-form .form-group {
margin-bottom: 1rem;
}
.setting-form .form-group:last-of-type {
margin-bottom: 1.5rem;
}
/* Actions */
.account-actions {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
margin-top: 2rem;
}
.button-danger {
background-color: #ff6b6b;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.25s;
}
.button-danger:hover {
background-color: #ff5252;
}
/* Responsive Design */
@media (max-width: 768px) {
.account-page {
padding: 1rem;
}
.account-title {
font-size: 2rem;
}
.account-section {
padding: 1.5rem;
}
.info-grid {
grid-template-columns: 1fr;
}
.characters-grid {
grid-template-columns: 1fr;
}
.setting-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.account-actions {
flex-direction: column;
}
.account-actions button {
width: 100%;
}
}

View File

@@ -0,0 +1,363 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../hooks/useAuth'
import { authApi, Account, Character } from '../services/api'
import './AccountPage.css'
function AccountPage() {
const navigate = useNavigate()
const { logout } = useAuth()
const [account, setAccount] = useState<Account | null>(null)
const [characters, setCharacters] = useState<Character[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
// Email change state
const [showEmailChange, setShowEmailChange] = useState(false)
const [newEmail, setNewEmail] = useState('')
const [emailPassword, setEmailPassword] = useState('')
const [emailLoading, setEmailLoading] = useState(false)
const [emailError, setEmailError] = useState('')
const [emailSuccess, setEmailSuccess] = useState('')
// Password change state
const [showPasswordChange, setShowPasswordChange] = useState(false)
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmNewPassword, setConfirmNewPassword] = useState('')
const [passwordLoading, setPasswordLoading] = useState(false)
const [passwordError, setPasswordError] = useState('')
const [passwordSuccess, setPasswordSuccess] = useState('')
useEffect(() => {
fetchAccountData()
}, [])
const fetchAccountData = async () => {
try {
setLoading(true)
const data = await authApi.getAccount()
setAccount(data.account)
setCharacters(data.characters)
setError('')
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to load account data')
} finally {
setLoading(false)
}
}
const handleEmailChange = async (e: React.FormEvent) => {
e.preventDefault()
setEmailError('')
setEmailSuccess('')
if (!newEmail || !emailPassword) {
setEmailError('Please fill in all fields')
return
}
setEmailLoading(true)
try {
const response = await authApi.changeEmail(emailPassword, newEmail)
setEmailSuccess(response.message)
setNewEmail('')
setEmailPassword('')
setShowEmailChange(false)
// Refresh account data
await fetchAccountData()
} catch (err: any) {
setEmailError(err.response?.data?.detail || 'Failed to change email')
} finally {
setEmailLoading(false)
}
}
const handlePasswordChange = async (e: React.FormEvent) => {
e.preventDefault()
setPasswordError('')
setPasswordSuccess('')
if (!currentPassword || !newPassword || !confirmNewPassword) {
setPasswordError('Please fill in all fields')
return
}
if (newPassword !== confirmNewPassword) {
setPasswordError('New passwords do not match')
return
}
if (newPassword.length < 6) {
setPasswordError('New password must be at least 6 characters')
return
}
setPasswordLoading(true)
try {
const response = await authApi.changePassword(currentPassword, newPassword)
setPasswordSuccess(response.message)
setCurrentPassword('')
setNewPassword('')
setConfirmNewPassword('')
setShowPasswordChange(false)
} catch (err: any) {
setPasswordError(err.response?.data?.detail || 'Failed to change password')
} finally {
setPasswordLoading(false)
}
}
const formatDate = (timestamp: string | number) => {
if (!timestamp) return 'Never'
const date = typeof timestamp === 'number' ? new Date(timestamp * 1000) : new Date(timestamp)
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString()
}
const getAccountTypeDisplay = (type: string) => {
const types: { [key: string]: string } = {
'web': 'Web',
'standalone': 'Standalone',
'steam': 'Steam'
}
return types[type] || type
}
if (loading) {
return (
<div className="account-page">
<div className="account-loading">Loading account...</div>
</div>
)
}
if (error || !account) {
return (
<div className="account-page">
<div className="account-error">
<h2>Error</h2>
<p>{error || 'Account not found'}</p>
<button onClick={() => navigate('/game')}>Back to Game</button>
</div>
</div>
)
}
return (
<div className="account-page">
<div className="account-container">
<h1 className="account-title">Account Management</h1>
{/* Account Information Section */}
<section className="account-section">
<h2 className="section-title">Account Information</h2>
<div className="info-grid">
<div className="info-item">
<span className="info-label">Email:</span>
<span className="info-value">{account.email}</span>
</div>
<div className="info-item">
<span className="info-label">Account Type:</span>
<span className="info-value">{getAccountTypeDisplay(account.account_type)}</span>
</div>
<div className="info-item">
<span className="info-label">Premium Status:</span>
<span className={`info-value ${account.premium_expires_at ? 'premium' : ''}`}>
{account.premium_expires_at && Number(account.premium_expires_at) > Date.now() / 1000
? '✓ Premium Active'
: 'Free Account'}
</span>
</div>
<div className="info-item">
<span className="info-label">Created:</span>
<span className="info-value">{formatDate(account.created_at)}</span>
</div>
<div className="info-item">
<span className="info-label">Last Login:</span>
<span className="info-value">{formatDate(account.last_login_at)}</span>
</div>
</div>
</section>
{/* Characters Section */}
<section className="account-section">
<h2 className="section-title">Your Characters</h2>
{characters.length === 0 ? (
<p className="no-characters">No characters yet. Create one to start playing!</p>
) : (
<div className="characters-grid">
{characters.map((char) => (
<div key={char.id} className="character-card">
<div className="character-header">
<h3>{char.name}</h3>
<span className="character-level">Level {char.level}</span>
</div>
<div className="character-stats">
<div className="stat">
<span className="stat-label">HP:</span>
<span className="stat-value">{char.hp}/{char.max_hp}</span>
</div>
<div className="stat">
<span className="stat-label">Stamina:</span>
<span className="stat-value">{char.stamina}/{char.max_stamina}</span>
</div>
</div>
<div className="character-attributes">
<span>STR: {char.strength}</span>
<span>AGI: {char.agility}</span>
<span>END: {char.endurance}</span>
<span>INT: {char.intellect}</span>
</div>
<button
className="button-secondary"
onClick={() => navigate(`/profile/${char.id}`)}
>
View Profile
</button>
</div>
))}
</div>
)}
<button
className="button-primary"
onClick={() => navigate('/create-character')}
>
Create New Character
</button>
</section>
{/* Settings Section */}
<section className="account-section">
<h2 className="section-title">Account Settings</h2>
{/* Email Change */}
<div className="setting-item">
<div className="setting-header">
<h3>Change Email</h3>
<button
className="button-link"
onClick={() => setShowEmailChange(!showEmailChange)}
>
{showEmailChange ? 'Cancel' : 'Change'}
</button>
</div>
{showEmailChange && (
<form onSubmit={handleEmailChange} className="setting-form">
<div className="form-group">
<label htmlFor="newEmail">New Email</label>
<input
type="email"
id="newEmail"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
placeholder="new.email@example.com"
required
disabled={emailLoading}
/>
</div>
<div className="form-group">
<label htmlFor="emailPassword">Current Password</label>
<input
type="password"
id="emailPassword"
value={emailPassword}
onChange={(e) => setEmailPassword(e.target.value)}
placeholder="Verify your identity"
required
disabled={emailLoading}
/>
</div>
{emailError && <div className="error">{emailError}</div>}
{emailSuccess && <div className="success">{emailSuccess}</div>}
<button type="submit" className="button-primary" disabled={emailLoading}>
{emailLoading ? 'Updating...' : 'Update Email'}
</button>
</form>
)}
</div>
{/* Password Change */}
<div className="setting-item">
<div className="setting-header">
<h3>Change Password</h3>
<button
className="button-link"
onClick={() => setShowPasswordChange(!showPasswordChange)}
>
{showPasswordChange ? 'Cancel' : 'Change'}
</button>
</div>
{showPasswordChange && (
<form onSubmit={handlePasswordChange} className="setting-form">
<div className="form-group">
<label htmlFor="currentPassword">Current Password</label>
<input
type="password"
id="currentPassword"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="Your current password"
required
disabled={passwordLoading}
/>
</div>
<div className="form-group">
<label htmlFor="newPassword">New Password</label>
<input
type="password"
id="newPassword"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="At least 6 characters"
required
disabled={passwordLoading}
/>
</div>
<div className="form-group">
<label htmlFor="confirmNewPassword">Confirm New Password</label>
<input
type="password"
id="confirmNewPassword"
value={confirmNewPassword}
onChange={(e) => setConfirmNewPassword(e.target.value)}
placeholder="Re-enter new password"
required
disabled={passwordLoading}
/>
</div>
{passwordError && <div className="error">{passwordError}</div>}
{passwordSuccess && <div className="success">{passwordSuccess}</div>}
<button type="submit" className="button-primary" disabled={passwordLoading}>
{passwordLoading ? 'Updating...' : 'Update Password'}
</button>
</form>
)}
</div>
</section>
{/* Actions Section */}
<section className="account-actions">
<button
className="button-secondary"
onClick={() => navigate('/game')}
>
Back to Game
</button>
<button
className="button-danger"
onClick={() => {
if (confirm('Are you sure you want to logout?')) {
logout()
navigate('/login')
}
}}
>
Logout
</button>
</section>
</div>
</div>
)
}
export default AccountPage

View File

@@ -0,0 +1,203 @@
.character-creation-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 2rem;
background: linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%);
}
.character-creation-card {
background-color: #2a2a2a;
border-radius: 12px;
padding: 2rem;
max-width: 700px;
width: 100%;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4);
}
.character-creation-card h1 {
font-size: 2rem;
color: #646cff;
text-align: center;
margin-bottom: 0.5rem;
}
.subtitle {
text-align: center;
color: #888;
margin-bottom: 2rem;
}
.form-section {
margin-bottom: 2rem;
}
.form-section h2 {
font-size: 1.3rem;
color: #fff;
margin-bottom: 1rem;
}
.input-hint {
font-size: 0.85rem;
color: #888;
margin-top: 0.25rem;
}
.points-remaining {
text-align: center;
font-size: 1.2rem;
font-weight: bold;
padding: 1rem;
background-color: #1a1a1a;
border-radius: 8px;
margin-bottom: 1.5rem;
}
.points-complete {
color: #51cf66;
}
.points-over {
color: #ff6b6b;
}
.stats-grid {
display: grid;
gap: 1rem;
}
.stat-input {
background-color: #1a1a1a;
border-radius: 8px;
padding: 1rem;
}
.stat-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.stat-icon {
font-size: 1.5rem;
}
.stat-header label {
font-size: 1.1rem;
font-weight: 600;
color: #fff;
}
.stat-control {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
}
.stat-control input {
flex: 1;
text-align: center;
font-size: 1.2rem;
font-weight: bold;
padding: 0.5rem;
margin: 0;
}
.stat-button {
width: 40px;
height: 40px;
border-radius: 8px;
border: none;
background-color: #646cff;
color: white;
font-size: 1.5rem;
cursor: pointer;
transition: background-color 0.25s;
display: flex;
align-items: center;
justify-content: center;
}
.stat-button:hover:not(:disabled) {
background-color: #535bf2;
}
.stat-button:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.stat-description {
font-size: 0.85rem;
color: #888;
margin: 0;
}
.character-preview {
background: linear-gradient(135deg, rgba(100, 108, 255, 0.1) 0%, rgba(83, 91, 242, 0.1) 100%);
border: 1px solid rgba(100, 108, 255, 0.3);
border-radius: 8px;
padding: 1.5rem;
}
.preview-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
}
.preview-stat {
display: flex;
justify-content: space-between;
align-items: center;
background-color: rgba(0, 0, 0, 0.3);
padding: 0.75rem 1rem;
border-radius: 8px;
}
.preview-label {
font-weight: 600;
color: #aaa;
}
.preview-value {
font-size: 1.2rem;
font-weight: bold;
color: #646cff;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 2rem;
}
.form-actions button {
flex: 1;
}
@media (max-width: 768px) {
.character-creation-container {
padding: 1rem;
}
.character-creation-card {
padding: 1.5rem;
}
.character-creation-card h1 {
font-size: 1.5rem;
}
.stat-control input {
font-size: 1rem;
}
.preview-stats {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,265 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../hooks/useAuth'
import './CharacterCreation.css'
function CharacterCreation() {
const { createCharacter } = useAuth()
const navigate = useNavigate()
const [name, setName] = useState('')
const [strength, setStrength] = useState(0)
const [agility, setAgility] = useState(0)
const [endurance, setEndurance] = useState(0)
const [intellect, setIntellect] = useState(0)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const TOTAL_POINTS = 20
const usedPoints = strength + agility + endurance + intellect
const remainingPoints = TOTAL_POINTS - usedPoints
const calculateHP = (str: number) => 30 + (str * 2)
const calculateStamina = (end: number) => 20 + (end * 1)
const handleStatChange = (
stat: 'strength' | 'agility' | 'endurance' | 'intellect',
value: number
) => {
// Prevent negative values
if (value < 0) return
const currentTotal = strength + agility + endurance + intellect
const otherStats = currentTotal - (stat === 'strength' ? strength : stat === 'agility' ? agility : stat === 'endurance' ? endurance : intellect)
// Prevent exceeding total points
if (otherStats + value > TOTAL_POINTS) {
value = TOTAL_POINTS - otherStats
}
switch (stat) {
case 'strength':
setStrength(value)
break
case 'agility':
setAgility(value)
break
case 'endurance':
setEndurance(value)
break
case 'intellect':
setIntellect(value)
break
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
// Validation
if (name.length < 3 || name.length > 20) {
setError('Name must be between 3 and 20 characters')
return
}
if (usedPoints !== TOTAL_POINTS) {
setError(`You must allocate exactly ${TOTAL_POINTS} stat points (currently: ${usedPoints})`)
return
}
if (strength < 0 || agility < 0 || endurance < 0 || intellect < 0) {
setError('Stats cannot be negative')
return
}
setLoading(true)
try {
await createCharacter({
name,
strength,
agility,
endurance,
intellect,
})
navigate('/characters')
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to create character')
} finally {
setLoading(false)
}
}
const handleCancel = () => {
navigate('/characters')
}
return (
<div className="character-creation-container">
<div className="character-creation-card">
<h1>Create Your Character</h1>
<p className="subtitle">Choose your name and distribute your stat points</p>
<form onSubmit={handleSubmit}>
{/* Name Input */}
<div className="form-section">
<label htmlFor="name">Character Name</label>
<input
type="text"
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter character name"
minLength={3}
maxLength={20}
required
disabled={loading}
/>
<p className="input-hint">3-20 characters, must be unique</p>
</div>
{/* Stat Allocation */}
<div className="form-section">
<h2>Stat Allocation</h2>
<div className="points-remaining">
<span className={remainingPoints === 0 ? 'points-complete' : remainingPoints < 0 ? 'points-over' : ''}>
Points Remaining: {remainingPoints} / {TOTAL_POINTS}
</span>
</div>
<div className="stats-grid">
<StatInput
label="Strength"
icon="💪"
value={strength}
onChange={(v) => handleStatChange('strength', v)}
description="Increases melee damage and carry capacity"
disabled={loading}
/>
<StatInput
label="Agility"
icon="⚡"
value={agility}
onChange={(v) => handleStatChange('agility', v)}
description="Improves dodge chance and critical hits"
disabled={loading}
/>
<StatInput
label="Endurance"
icon="🛡️"
value={endurance}
onChange={(v) => handleStatChange('endurance', v)}
description="Increases HP and stamina"
disabled={loading}
/>
<StatInput
label="Intellect"
icon="🧠"
value={intellect}
onChange={(v) => handleStatChange('intellect', v)}
description="Enhances crafting and resource gathering"
disabled={loading}
/>
</div>
</div>
{/* Character Preview */}
<div className="form-section character-preview">
<h2>Character Preview</h2>
<div className="preview-stats">
<div className="preview-stat">
<span className="preview-label">HP:</span>
<span className="preview-value">{calculateHP(strength)}</span>
</div>
<div className="preview-stat">
<span className="preview-label">Stamina:</span>
<span className="preview-value">{calculateStamina(endurance)}</span>
</div>
<div className="preview-stat">
<span className="preview-label">Level:</span>
<span className="preview-value">1</span>
</div>
</div>
</div>
{error && <div className="error">{error}</div>}
<div className="form-actions">
<button
type="button"
className="button-secondary"
onClick={handleCancel}
disabled={loading}
>
Cancel
</button>
<button
type="submit"
className="button-primary"
disabled={loading || remainingPoints !== 0}
>
{loading ? 'Creating...' : 'Create Character'}
</button>
</div>
</form>
</div>
</div>
)
}
function StatInput({
label,
icon,
value,
onChange,
description,
disabled,
}: {
label: string
icon: string
value: number
onChange: (value: number) => void
description: string
disabled: boolean
}) {
return (
<div className="stat-input">
<div className="stat-header">
<span className="stat-icon">{icon}</span>
<label>{label}</label>
</div>
<div className="stat-control">
<button
type="button"
className="stat-button"
onClick={() => onChange(value - 1)}
disabled={disabled || value <= 0}
>
-
</button>
<input
type="number"
value={value}
onChange={(e) => onChange(parseInt(e.target.value) || 0)}
min="0"
disabled={disabled}
/>
<button
type="button"
className="stat-button"
onClick={() => onChange(value + 1)}
disabled={disabled}
>
+
</button>
</div>
<p className="stat-description">{description}</p>
</div>
)
}
export default CharacterCreation

View File

@@ -0,0 +1,239 @@
.character-selection-container {
min-height: 100vh;
padding: 2rem;
background: linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%);
}
.character-selection-header {
text-align: center;
margin-bottom: 3rem;
position: relative;
}
.character-selection-header h1 {
font-size: 2.5rem;
color: #646cff;
margin-bottom: 0.5rem;
}
.character-selection-header .subtitle {
color: #888;
font-size: 1rem;
}
.logout-button {
position: absolute;
top: 0;
right: 0;
}
.characters-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
max-width: 1200px;
margin: 0 auto;
}
.character-card {
background-color: #2a2a2a;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
transition: transform 0.2s, box-shadow 0.2s;
display: flex;
flex-direction: column;
gap: 1rem;
}
.character-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4);
}
.character-avatar {
width: 100px;
height: 100px;
margin: 0 auto;
border-radius: 50%;
overflow: hidden;
background: linear-gradient(135deg, #646cff 0%, #535bf2 100%);
display: flex;
align-items: center;
justify-content: center;
}
.character-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-placeholder {
font-size: 2rem;
font-weight: bold;
color: white;
}
.character-info {
text-align: center;
}
.character-info h3 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
color: #fff;
}
.character-stats {
display: flex;
justify-content: center;
gap: 1rem;
margin-bottom: 0.5rem;
color: #aaa;
font-size: 0.9rem;
}
.character-attributes {
display: flex;
justify-content: center;
gap: 1rem;
font-size: 1rem;
margin: 0.5rem 0;
}
.character-attributes span {
padding: 0.25rem 0.5rem;
background-color: #1a1a1a;
border-radius: 4px;
}
.character-meta {
color: #666;
font-size: 0.85rem;
margin-top: 0.5rem;
}
.character-actions {
display: flex;
gap: 0.5rem;
}
.character-actions button {
flex: 1;
}
.button-danger {
background-color: #dc3545;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.25s;
}
.button-danger:hover {
background-color: #c82333;
}
.button-danger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.create-character-card {
cursor: pointer;
border: 2px dashed #646cff;
background-color: rgba(100, 108, 255, 0.1);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 250px;
}
.create-character-card:hover {
background-color: rgba(100, 108, 255, 0.2);
border-color: #535bf2;
}
.create-character-icon {
font-size: 4rem;
color: #646cff;
margin-bottom: 1rem;
}
.create-character-card h3 {
color: #646cff;
margin-bottom: 0.5rem;
}
.create-character-subtitle {
color: #888;
font-size: 0.9rem;
}
.premium-banner {
background: linear-gradient(135deg, #646cff 0%, #535bf2 100%);
border-radius: 12px;
padding: 2rem;
text-align: center;
max-width: 600px;
margin: 2rem auto 0;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
.premium-banner h3 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.premium-banner p {
margin-bottom: 1.5rem;
font-size: 1rem;
}
.premium-banner button {
background-color: white;
color: #646cff;
font-weight: bold;
}
.premium-banner button:hover {
background-color: #f0f0f0;
}
.no-characters {
text-align: center;
color: #888;
padding: 3rem;
max-width: 500px;
margin: 0 auto;
}
.no-characters p {
margin-bottom: 1rem;
font-size: 1.1rem;
}
@media (max-width: 768px) {
.character-selection-container {
padding: 1rem;
}
.character-selection-header h1 {
font-size: 1.8rem;
}
.logout-button {
position: static;
margin-top: 1rem;
}
.characters-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
}

View File

@@ -0,0 +1,169 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../hooks/useAuth'
import { Character } from '../services/api'
import './CharacterSelection.css'
function CharacterSelection() {
const { characters, account, selectCharacter, deleteCharacter, logout } = useAuth()
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [deletingId, setDeletingId] = useState<number | null>(null)
const navigate = useNavigate()
const handleSelectCharacter = async (characterId: number) => {
setLoading(true)
setError('')
try {
await selectCharacter(characterId)
navigate('/game')
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to select character')
} finally {
setLoading(false)
}
}
const handleDeleteCharacter = async (characterId: number) => {
if (!window.confirm('Are you sure you want to delete this character? This action cannot be undone.')) {
return
}
setDeletingId(characterId)
setError('')
try {
await deleteCharacter(characterId)
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to delete character')
} finally {
setDeletingId(null)
}
}
const handleCreateCharacter = () => {
navigate('/create-character')
}
const isPremium = account?.premium_expires_at !== null
const maxCharacters = isPremium ? 10 : 1
const canCreateCharacter = characters.length < maxCharacters
return (
<div className="character-selection-container">
<div className="character-selection-header">
<h1>Select Your Character</h1>
<p className="subtitle">Echoes of the Ash</p>
<button className="button-secondary logout-button" onClick={logout}>
Logout
</button>
</div>
{error && <div className="error">{error}</div>}
<div className="characters-grid">
{characters.map((character) => (
<CharacterCard
key={character.id}
character={character}
onSelect={() => handleSelectCharacter(character.id)}
onDelete={() => handleDeleteCharacter(character.id)}
loading={loading || deletingId === character.id}
/>
))}
{canCreateCharacter && (
<div className="character-card create-character-card" onClick={handleCreateCharacter}>
<div className="create-character-icon">+</div>
<h3>Create New Character</h3>
<p className="create-character-subtitle">
{characters.length} / {maxCharacters} slots used
</p>
</div>
)}
</div>
{!canCreateCharacter && !isPremium && (
<div className="premium-banner">
<h3>Character Limit Reached</h3>
<p>Upgrade to Premium to create up to 10 characters!</p>
<button className="button-primary">Upgrade to Premium - $4.99</button>
</div>
)}
{characters.length === 0 && (
<div className="no-characters">
<p>You don't have any characters yet.</p>
<p>Click the "Create New Character" button to get started!</p>
</div>
)}
</div>
)
}
function CharacterCard({
character,
onSelect,
onDelete,
loading
}: {
character: Character
onSelect: () => void
onDelete: () => void
loading: boolean
}) {
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString()
}
return (
<div className="character-card">
<div className="character-avatar">
{character.avatar_data?.image ? (
<img src={character.avatar_data.image} alt={character.name} />
) : (
<div className="avatar-placeholder">
{character.name.substring(0, 2).toUpperCase()}
</div>
)}
</div>
<div className="character-info">
<h3>{character.name}</h3>
<div className="character-stats">
<span className="stat">Level {character.level}</span>
<span className="stat">HP: {character.hp}/{character.max_hp}</span>
</div>
<div className="character-attributes">
<span title="Strength">💪 {character.strength}</span>
<span title="Agility">⚡ {character.agility}</span>
<span title="Endurance">🛡️ {character.endurance}</span>
<span title="Intellect">🧠 {character.intellect}</span>
</div>
<p className="character-meta">
Last played: {formatDate(character.last_played_at)}
</p>
</div>
<div className="character-actions">
<button
className="button-primary"
onClick={onSelect}
disabled={loading}
>
{loading ? 'Loading...' : 'Play'}
</button>
<button
className="button-danger"
onClick={onDelete}
disabled={loading}
>
Delete
</button>
</div>
</div>
)
}
export default CharacterSelection

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,8 @@
import { useState, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useAuth } from '../hooks/useAuth'
import { useGameWebSocket } from '../hooks/useGameWebSocket'
import api from '../services/api'
import './Game.css'
interface GameHeaderProps {
@@ -9,37 +12,82 @@ interface GameHeaderProps {
export default function GameHeader({ className = '' }: GameHeaderProps) {
const navigate = useNavigate()
const location = useLocation()
const { user, logout } = useAuth()
const { currentCharacter, logout } = useAuth()
const [playerCount, setPlayerCount] = useState<number>(0)
// Fetch initial player count
useEffect(() => {
const fetchPlayerCount = async () => {
try {
const response = await api.get('/api/statistics/online-players')
if (response.data && typeof response.data.count === 'number') {
setPlayerCount(response.data.count)
}
} catch (error) {
console.error('Failed to fetch player count:', error)
}
}
fetchPlayerCount()
}, [])
// Connect to WebSocket for player count updates
// We use a separate connection here to ensure the header always has live data
// regardless of which page is active (Game, Leaderboards, Profile)
const token = localStorage.getItem('token')
useGameWebSocket({
token,
enabled: !!token,
onMessage: (message) => {
if (message.type === 'player_count_update' && message.data?.count !== undefined) {
//console.log('🔢 GameHeader received count update:', message.data.count)
setPlayerCount(message.data.count)
}
}
})
const isActive = (path: string) => {
return location.pathname === path || location.pathname.startsWith(path)
}
const isOnOwnProfile = location.pathname === `/profile/${user?.id}`
const isOnOwnProfile = location.pathname === `/profile/${currentCharacter?.id}`
return (
<header className={`game-header ${className}`}>
<h1>Echoes of the Ash</h1>
<div className="header-left">
<h1>Echoes of the Ash</h1>
</div>
<nav className="nav-links">
<button
onClick={() => navigate('/game')}
<button
onClick={() => navigate('/game')}
className={`nav-link ${isActive('/game') ? 'active' : ''}`}
>
🎮 Game
</button>
<button
onClick={() => navigate('/leaderboards')}
<button
onClick={() => navigate('/leaderboards')}
className={`nav-link ${isActive('/leaderboards') ? 'active' : ''}`}
>
🏆 Leaderboards
</button>
</nav>
<div className="user-info">
<button
onClick={() => navigate(`/profile/${user?.id}`)}
<div className="player-count-badge" title="Online Players">
<span className="status-dot"></span>
<span className="count-text">{playerCount} Online</span>
</div>
<button
onClick={() => navigate(`/profile/${currentCharacter?.id}`)}
className={`username-link ${isOnOwnProfile ? 'active' : ''}`}
>
{user?.username}
{currentCharacter?.name}
</button>
<button
onClick={() => navigate('/account')}
className="button-secondary"
>
Account
</button>
<button onClick={logout} className="button-secondary">Logout</button>
</div>

View File

@@ -0,0 +1,14 @@
import { Outlet } from 'react-router-dom'
import GameHeader from './GameHeader'
import './Game.css'
export default function GameLayout() {
return (
<div className="game-layout">
<GameHeader />
<div className="game-content">
<Outlet />
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,271 @@
.landing-page {
min-height: 100vh;
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 50%, #0f0f0f 100%);
color: #fff;
}
/* Hero Section */
.hero-section {
position: relative;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
overflow: hidden;
}
.hero-gradient {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(ellipse at center, rgba(100, 108, 255, 0.15) 0%, transparent 70%);
pointer-events: none;
animation: pulse 8s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 0.5;
}
50% {
opacity: 1;
}
}
.hero-content {
position: relative;
z-index: 1;
max-width: 800px;
text-align: center;
animation: fadeInUp 1s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.hero-title {
font-size: 4rem;
font-weight: 700;
margin-bottom: 1rem;
background: linear-gradient(135deg, #646cff 0%, #8b5cf6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: glow 3s ease-in-out infinite;
}
@keyframes glow {
0%,
100% {
filter: drop-shadow(0 0 20px rgba(100, 108, 255, 0.5));
}
50% {
filter: drop-shadow(0 0 30px rgba(100, 108, 255, 0.8));
}
}
.hero-subtitle {
font-size: 1.5rem;
color: #ccc;
margin-bottom: 1.5rem;
font-weight: 300;
}
.hero-description {
font-size: 1.1rem;
color: #999;
line-height: 1.8;
margin-bottom: 2.5rem;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.hero-buttons {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
.hero-button {
padding: 1rem 2.5rem;
font-size: 1.1rem;
min-width: 180px;
transition: all 0.3s ease;
}
.hero-button:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(100, 108, 255, 0.4);
}
/* Features Section */
.features-section {
padding: 6rem 2rem;
background: linear-gradient(180deg, transparent 0%, rgba(100, 108, 255, 0.05) 100%);
}
.section-title {
text-align: center;
font-size: 2.5rem;
margin-bottom: 3rem;
color: #646cff;
font-weight: 600;
}
.features-grid {
max-width: 1200px;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 2rem;
}
.feature-card {
background: rgba(42, 42, 42, 0.6);
backdrop-filter: blur(10px);
border: 1px solid rgba(100, 108, 255, 0.2);
border-radius: 16px;
padding: 2rem;
transition: all 0.3s ease;
animation: fadeIn 0.6s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.feature-card:hover {
transform: translateY(-8px);
border-color: rgba(100, 108, 255, 0.5);
box-shadow: 0 12px 30px rgba(100, 108, 255, 0.2);
}
.feature-icon {
font-size: 3rem;
margin-bottom: 1rem;
filter: drop-shadow(0 0 10px rgba(100, 108, 255, 0.5));
}
.feature-card h3 {
font-size: 1.5rem;
margin-bottom: 1rem;
color: #fff;
}
.feature-card p {
color: #aaa;
line-height: 1.6;
margin-bottom: 1rem;
}
.feature-screenshot {
width: 100%;
border-radius: 8px;
margin-top: 1rem;
border: 1px solid rgba(100, 108, 255, 0.3);
transition: all 0.3s ease;
}
.feature-screenshot:hover {
transform: scale(1.02);
box-shadow: 0 8px 20px rgba(100, 108, 255, 0.3);
}
/* About Section */
.about-section {
padding: 6rem 2rem;
background: rgba(26, 26, 26, 0.8);
}
.about-content {
max-width: 800px;
margin: 0 auto;
text-align: center;
}
.about-content p {
font-size: 1.1rem;
line-height: 1.8;
color: #bbb;
margin-bottom: 1.5rem;
}
/* Footer */
.landing-footer {
padding: 2rem;
text-align: center;
background: #0a0a0a;
border-top: 1px solid rgba(100, 108, 255, 0.2);
}
.landing-footer p {
color: #666;
font-size: 0.9rem;
}
/* Responsive Design */
@media (max-width: 768px) {
.hero-title {
font-size: 2.5rem;
}
.hero-subtitle {
font-size: 1.2rem;
}
.hero-description {
font-size: 1rem;
}
.section-title {
font-size: 2rem;
}
.features-grid {
grid-template-columns: 1fr;
}
.features-section,
.about-section {
padding: 4rem 1rem;
}
}
@media (max-width: 480px) {
.hero-title {
font-size: 2rem;
}
.hero-buttons {
flex-direction: column;
}
.hero-button {
width: 100%;
}
}

View File

@@ -0,0 +1,121 @@
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../hooks/useAuth'
import { useEffect } from 'react'
import './LandingPage.css'
function LandingPage() {
const navigate = useNavigate()
const { isAuthenticated } = useAuth()
// Redirect authenticated users to characters page
useEffect(() => {
if (isAuthenticated) {
navigate('/characters')
}
}, [isAuthenticated, navigate])
return (
<div className="landing-page">
{/* Hero Section */}
<section className="hero-section">
<div className="hero-content">
<h1 className="hero-title">Echoes of the Ash</h1>
<p className="hero-subtitle">Survive the Wasteland. Forge Your Legend.</p>
<p className="hero-description">
A post-apocalyptic survival RPG where every decision matters.
Explore desolate wastelands, battle mutated creatures, craft essential gear,
and compete with other survivors in a world consumed by ash.
</p>
<div className="hero-buttons">
<button
className="button-primary hero-button"
onClick={() => navigate('/register')}
>
Start Your Journey
</button>
<button
className="button-secondary hero-button"
onClick={() => navigate('/login')}
>
Login
</button>
</div>
</div>
<div className="hero-gradient"></div>
</section>
{/* Features Section */}
<section className="features-section">
<h2 className="section-title">Game Features</h2>
<div className="features-grid">
<div className="feature-card">
<div className="feature-icon"></div>
<h3>Tactical Combat</h3>
<p>Engage in turn-based battles against mutated creatures and hostile survivors. Choose your actions wisely!</p>
<img src="/game-combat.png" alt="Combat gameplay" className="feature-screenshot" />
</div>
<div className="feature-card">
<div className="feature-icon">🎒</div>
<h3>Deep Inventory System</h3>
<p>Manage your equipment, craft items, and optimize your loadout for survival in the harsh wasteland.</p>
<img src="/game-inventory.png" alt="Inventory system" className="feature-screenshot" />
</div>
<div className="feature-card">
<div className="feature-icon">🗺</div>
<h3>Explore the Wasteland</h3>
<p>Navigate through dangerous locations, discover hidden treasures, and encounter other players in real-time.</p>
<img src="/game-exploration.png" alt="Exploration gameplay" className="feature-screenshot" />
</div>
<div className="feature-card">
<div className="feature-icon">🔧</div>
<h3>Crafting & Salvage</h3>
<p>Scavenge materials, repair equipment, and craft powerful items to gain an edge in the wasteland.</p>
</div>
<div className="feature-card">
<div className="feature-icon">📊</div>
<h3>Character Progression</h3>
<p>Level up your character, allocate stat points, and customize your build to match your playstyle.</p>
</div>
<div className="feature-card">
<div className="feature-icon">👥</div>
<h3>Multiplayer Interactions</h3>
<p>Trade with other players, engage in PvP combat, or cooperate to survive in the harsh world.</p>
</div>
</div>
</section>
{/* About Section */}
<section className="about-section">
<h2 className="section-title">About the Game</h2>
<div className="about-content">
<p>
In the aftermath of a catastrophic event that covered the world in ash,
humanity struggles to survive. Resources are scarce, dangers lurk around
every corner, and only the strongest and smartest will endure.
</p>
<p>
Create your character, explore the wasteland, battle mutated creatures,
and compete with other survivors. Will you become a legendary scavenger,
a feared warrior, or a cunning trader? The choice is yours.
</p>
<p>
Join thousands of players in this persistent online world where your
actions have consequences and your reputation matters.
</p>
</div>
</section>
{/* Footer */}
<footer className="landing-footer">
<p>&copy; 2025 Echoes of the Ash. All rights reserved.</p>
</footer>
</div>
)
}
export default LandingPage

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import GameHeader from './GameHeader';
import api from '../services/api';
import './Leaderboards.css';
import './Game.css';
@@ -53,19 +53,11 @@ export default function Leaderboards() {
setError(null);
try {
const token = localStorage.getItem('authToken');
const response = await fetch(`/api/leaderboard/${statName}?limit=100`, {
headers: {
'Authorization': `Bearer ${token}`,
},
const response = await api.get(`/api/leaderboard/${statName}`, {
params: { limit: 100 }
});
if (!response.ok) {
throw new Error('Failed to fetch leaderboard');
}
const data = await response.json();
setLeaderboard(data.leaderboard || []);
setLeaderboard(response.data.leaderboard || []);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
@@ -97,11 +89,11 @@ export default function Leaderboards() {
};
return (
<div className="game-container">
<GameHeader className={mobileHeaderOpen ? 'open' : ''} />
<div className="leaderboards-container">
{/* Game Header is now in GameLayout */}
{/* Mobile Header Toggle */}
<button
<button
className="mobile-header-toggle"
onClick={() => setMobileHeaderOpen(!mobileHeaderOpen)}
>
@@ -110,153 +102,70 @@ export default function Leaderboards() {
<main className="game-main">
<div className="leaderboards-container">
<div className="stat-selector">
<h3>Select Statistic</h3>
<div className={`stat-options ${statDropdownOpen ? 'expanded' : ''}`}>
{STAT_OPTIONS.map((stat) => (
<button
key={stat.key}
className={`stat-option ${selectedStat.key === stat.key ? 'active' : ''}`}
onClick={() => {
if (selectedStat.key === stat.key) {
// Toggle dropdown when clicking active item
setStatDropdownOpen(!statDropdownOpen);
} else {
// Select new stat and close dropdown
setSelectedStat(stat);
setStatDropdownOpen(false);
}
}}
style={{
borderColor: selectedStat.key === stat.key ? stat.color : 'rgba(255, 255, 255, 0.2)',
}}
>
<span className="stat-icon">{stat.icon}</span>
<span className="stat-label">{stat.label}</span>
</button>
))}
</div>
</div>
<div className="leaderboard-content">
<div
className={`leaderboard-title ${statDropdownOpen ? 'dropdown-open' : ''}`}
style={{ borderColor: selectedStat.color }}
>
<div
className="title-left clickable-title"
onClick={() => setStatDropdownOpen(!statDropdownOpen)}
>
<span className="title-icon">{selectedStat.icon}</span>
<h2>{selectedStat.label}</h2>
<span className="dropdown-arrow">{statDropdownOpen ? '▲' : '▼'}</span>
</div>
{/* Dropdown options */}
{statDropdownOpen && (
<div className="title-dropdown">
{STAT_OPTIONS.filter(stat => stat.key !== selectedStat.key).map((stat) => (
<button
key={stat.key}
className="title-dropdown-option"
onClick={() => {
<div className="stat-selector">
<h3>Select Statistic</h3>
<div className={`stat-options ${statDropdownOpen ? 'expanded' : ''}`}>
{STAT_OPTIONS.map((stat) => (
<button
key={stat.key}
className={`stat-option ${selectedStat.key === stat.key ? 'active' : ''}`}
onClick={() => {
if (selectedStat.key === stat.key) {
// Toggle dropdown when clicking active item
setStatDropdownOpen(!statDropdownOpen);
} else {
// Select new stat and close dropdown
setSelectedStat(stat);
setStatDropdownOpen(false);
}}
>
<span className="stat-icon">{stat.icon}</span>
<span className="stat-label">{stat.label}</span>
</button>
))}
</div>
)}
{!loading && !error && leaderboard.length > ITEMS_PER_PAGE && (
<div className="pagination pagination-top">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="pagination-btn"
}
}}
style={{
borderColor: selectedStat.key === stat.key ? stat.color : 'rgba(255, 255, 255, 0.2)',
}}
>
<span className="stat-icon">{stat.icon}</span>
<span className="stat-label">{stat.label}</span>
</button>
<span className="pagination-info">
{currentPage} / {Math.ceil(leaderboard.length / ITEMS_PER_PAGE)}
</span>
<button
onClick={() => setCurrentPage(p => Math.min(Math.ceil(leaderboard.length / ITEMS_PER_PAGE), p + 1))}
disabled={currentPage >= Math.ceil(leaderboard.length / ITEMS_PER_PAGE)}
className="pagination-btn"
>
</button>
</div>
)}
))}
</div>
</div>
{loading && (
<div className="leaderboard-loading">
<div className="spinner"></div>
<p>Loading leaderboard...</p>
</div>
)}
{error && (
<div className="leaderboard-error">
<p> {error}</p>
<button onClick={() => fetchLeaderboard(selectedStat.key)}>Retry</button>
</div>
)}
{!loading && !error && leaderboard.length === 0 && (
<div className="leaderboard-empty">
<p>📊 No data available yet</p>
</div>
)}
{!loading && !error && leaderboard.length > 0 && (
<>
<div className="leaderboard-table">
<div className="table-header">
<div className="col-rank">Rank</div>
<div className="col-player">Player</div>
<div className="col-level">Level</div>
<div className="col-value">Value</div>
</div>
{leaderboard
.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE)
.map((entry, index) => {
const rank = (currentPage - 1) * ITEMS_PER_PAGE + index + 1;
return (
<div
key={entry.player_id}
className={`table-row ${getRankClass(rank)}`}
onClick={() => navigate(`/profile/${entry.player_id}`)}
>
<div className="col-rank">
<span className="rank-badge">{getRankBadge(rank)}</span>
</div>
<div className="col-player">
<div className="player-name">{entry.name}</div>
<div className="player-username">@{entry.username}</div>
</div>
<div className="col-level">
<span className="level-badge">Lv {entry.level}</span>
</div>
<div className="col-value">
<span className="stat-value" style={{ color: selectedStat.color }}>
{formatStatValue(entry.value, selectedStat.key)}
</span>
</div>
</div>
);
})}
<div className="leaderboard-content">
<div
className={`leaderboard-title ${statDropdownOpen ? 'dropdown-open' : ''}`}
style={{ borderColor: selectedStat.color }}
>
<div
className="title-left clickable-title"
onClick={() => setStatDropdownOpen(!statDropdownOpen)}
>
<span className="title-icon">{selectedStat.icon}</span>
<h2>{selectedStat.label}</h2>
<span className="dropdown-arrow">{statDropdownOpen ? '▲' : '▼'}</span>
</div>
{Math.ceil(leaderboard.length / ITEMS_PER_PAGE) > 1 && (
<div className="pagination pagination-bottom">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
{/* Dropdown options */}
{statDropdownOpen && (
<div className="title-dropdown">
{STAT_OPTIONS.filter(stat => stat.key !== selectedStat.key).map((stat) => (
<button
key={stat.key}
className="title-dropdown-option"
onClick={() => {
setSelectedStat(stat);
setStatDropdownOpen(false);
}}
>
<span className="stat-icon">{stat.icon}</span>
<span className="stat-label">{stat.label}</span>
</button>
))}
</div>
)}
{!loading && !error && leaderboard.length > ITEMS_PER_PAGE && (
<div className="pagination pagination-top">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="pagination-btn"
>
@@ -265,8 +174,8 @@ export default function Leaderboards() {
<span className="pagination-info">
{currentPage} / {Math.ceil(leaderboard.length / ITEMS_PER_PAGE)}
</span>
<button
onClick={() => setCurrentPage(p => Math.min(Math.ceil(leaderboard.length / ITEMS_PER_PAGE), p + 1))}
<button
onClick={() => setCurrentPage(p => Math.min(Math.ceil(leaderboard.length / ITEMS_PER_PAGE), p + 1))}
disabled={currentPage >= Math.ceil(leaderboard.length / ITEMS_PER_PAGE)}
className="pagination-btn"
>
@@ -274,9 +183,92 @@ export default function Leaderboards() {
</button>
</div>
)}
</>
)}
</div>
</div>
{loading && (
<div className="leaderboard-loading">
<div className="spinner"></div>
<p>Loading leaderboard...</p>
</div>
)}
{error && (
<div className="leaderboard-error">
<p> {error}</p>
<button onClick={() => fetchLeaderboard(selectedStat.key)}>Retry</button>
</div>
)}
{!loading && !error && leaderboard.length === 0 && (
<div className="leaderboard-empty">
<p>📊 No data available yet</p>
</div>
)}
{!loading && !error && leaderboard.length > 0 && (
<>
<div className="leaderboard-table">
<div className="table-header">
<div className="col-rank">Rank</div>
<div className="col-player">Player</div>
<div className="col-level">Level</div>
<div className="col-value">Value</div>
</div>
{leaderboard
.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE)
.map((entry, index) => {
const rank = (currentPage - 1) * ITEMS_PER_PAGE + index + 1;
return (
<div
key={entry.player_id}
className={`table-row ${getRankClass(rank)}`}
onClick={() => navigate(`/profile/${entry.player_id}`)}
>
<div className="col-rank">
<span className="rank-badge">{getRankBadge(rank)}</span>
</div>
<div className="col-player">
<div className="player-name">{entry.name}</div>
<div className="player-username">@{entry.username}</div>
</div>
<div className="col-level">
<span className="level-badge">Lv {entry.level}</span>
</div>
<div className="col-value">
<span className="stat-value" style={{ color: selectedStat.color }}>
{formatStatValue(entry.value, selectedStat.key)}
</span>
</div>
</div>
);
})}
</div>
{Math.ceil(leaderboard.length / ITEMS_PER_PAGE) > 1 && (
<div className="pagination pagination-bottom">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="pagination-btn"
>
</button>
<span className="pagination-info">
{currentPage} / {Math.ceil(leaderboard.length / ITEMS_PER_PAGE)}
</span>
<button
onClick={() => setCurrentPage(p => Math.min(Math.ceil(leaderboard.length / ITEMS_PER_PAGE), p + 1))}
disabled={currentPage >= Math.ceil(leaderboard.length / ITEMS_PER_PAGE)}
className="pagination-btn"
>
</button>
</div>
)}
</>
)}
</div>
</div>
</main>
</div>

View File

@@ -70,12 +70,18 @@
cursor: not-allowed;
}
.password-strength {
margin-top: 0.5rem;
font-size: 0.85rem;
font-weight: 600;
}
@media (max-width: 480px) {
.login-card {
padding: 1.5rem;
}
.login-card h1 {
font-size: 1.5rem;
}
}
}

View File

@@ -4,28 +4,41 @@ import { useAuth } from '../hooks/useAuth'
import './Login.css'
function Login() {
const [isLogin, setIsLogin] = useState(true)
const [username, setUsername] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { login, register } = useAuth()
const { login } = useAuth()
const navigate = useNavigate()
const validateEmail = (email: string) => {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return re.test(email)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
// Validation
if (!validateEmail(email)) {
setError('Please enter a valid email address')
return
}
if (password.length < 6) {
setError('Password must be at least 6 characters')
return
}
setLoading(true)
try {
if (isLogin) {
await login(username, password)
} else {
await register(username, password)
}
navigate('/game')
await login(email, password)
// Navigate to character selection after successful login
navigate('/characters')
} catch (err: any) {
setError(err.response?.data?.detail || 'Authentication failed')
setError(err.response?.data?.detail || 'Login failed')
} finally {
setLoading(false)
}
@@ -34,23 +47,24 @@ function Login() {
return (
<div className="login-container">
<div className="login-card">
<h1>Echoes of the Ash</h1>
<p className="login-subtitle">A Post-Apocalyptic Survival RPG</p>
<h1>Welcome Back</h1>
<p className="login-subtitle">Login to continue your journey</p>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="username">Username</label>
<label htmlFor="email">Email Address</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your.email@example.com"
required
disabled={loading}
autoComplete="username"
autoComplete="email"
/>
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
@@ -58,16 +72,17 @@ function Login() {
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Your password"
required
disabled={loading}
autoComplete={isLogin ? 'current-password' : 'new-password'}
autoComplete="current-password"
/>
</div>
{error && <div className="error">{error}</div>}
<button type="submit" className="button-primary" disabled={loading}>
{loading ? 'Please wait...' : isLogin ? 'Login' : 'Register'}
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
@@ -75,10 +90,21 @@ function Login() {
<button
type="button"
className="button-link"
onClick={() => setIsLogin(!isLogin)}
onClick={() => navigate('/register')}
disabled={loading}
>
{isLogin ? "Don't have an account? Register" : 'Already have an account? Login'}
Don't have an account? Register
</button>
</div>
<div className="login-toggle">
<button
type="button"
className="button-link"
onClick={() => navigate('/')}
disabled={loading}
>
Back to Home
</button>
</div>
</div>

View File

@@ -1,7 +1,6 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import api from '../services/api'
import GameHeader from './GameHeader'
import './Profile.css'
import './Game.css'
@@ -103,10 +102,10 @@ function Profile() {
return (
<div className="game-container">
<GameHeader className={mobileHeaderOpen ? 'open' : ''} />
{/* Game Header is now in GameLayout */}
{/* Mobile Header Toggle */}
<button
<button
className="mobile-header-toggle"
onClick={() => setMobileHeaderOpen(!mobileHeaderOpen)}
>
@@ -115,107 +114,107 @@ function Profile() {
<main className="game-main">
<div className="profile-container">
<div className="profile-info-card">
<div className="profile-avatar">
<span className="avatar-icon">👤</span>
</div>
<h1 className="profile-name">{player.name}</h1>
<p className="profile-username">@{player.username}</p>
<div className="profile-level">Level {player.level}</div>
<div className="profile-meta">
<div className="meta-item">
<span className="meta-label">Member since</span>
<span className="meta-value">{formatDate(stats.created_at)}</span>
<div className="profile-info-card">
<div className="profile-avatar">
<span className="avatar-icon">👤</span>
</div>
<div className="meta-item">
<span className="meta-label">Last seen</span>
<span className="meta-value">{formatDate(stats.last_activity)}</span>
</div>
</div>
</div>
<div className="profile-stats-grid">
{/* Combat Stats */}
<div className="stats-section">
<h2 className="section-title"> Combat</h2>
<div className="stat-row">
<span className="stat-label">Enemies Killed</span>
<span className="stat-value">{stats.enemies_killed.toLocaleString()}</span>
</div>
<div className="stat-row">
<span className="stat-label">Combats Initiated</span>
<span className="stat-value">{stats.combats_initiated.toLocaleString()}</span>
</div>
<div className="stat-row">
<span className="stat-label">Damage Dealt</span>
<span className="stat-value highlight-red">{stats.damage_dealt.toLocaleString()}</span>
</div>
<div className="stat-row">
<span className="stat-label">Damage Taken</span>
<span className="stat-value">{stats.damage_taken.toLocaleString()}</span>
</div>
<div className="stat-row">
<span className="stat-label">Deaths</span>
<span className="stat-value">{stats.deaths.toLocaleString()}</span>
</div>
<div className="stat-row">
<span className="stat-label">Successful Flees</span>
<span className="stat-value highlight-green">{stats.successful_flees.toLocaleString()}</span>
</div>
<div className="stat-row">
<span className="stat-label">Failed Flees</span>
<span className="stat-value">{stats.failed_flees.toLocaleString()}</span>
<h1 className="profile-name">{player.name}</h1>
<p className="profile-username">@{player.username}</p>
<div className="profile-level">Level {player.level}</div>
<div className="profile-meta">
<div className="meta-item">
<span className="meta-label">Member since</span>
<span className="meta-value">{formatDate(stats.created_at)}</span>
</div>
<div className="meta-item">
<span className="meta-label">Last seen</span>
<span className="meta-value">{formatDate(stats.last_activity)}</span>
</div>
</div>
</div>
{/* Exploration Stats */}
<div className="stats-section">
<h2 className="section-title">🗺 Exploration</h2>
<div className="stat-row">
<span className="stat-label">Distance Walked</span>
<span className="stat-value highlight-blue">{stats.distance_walked.toLocaleString()}</span>
<div className="profile-stats-grid">
{/* Combat Stats */}
<div className="stats-section">
<h2 className="section-title"> Combat</h2>
<div className="stat-row">
<span className="stat-label">Enemies Killed</span>
<span className="stat-value">{stats.enemies_killed.toLocaleString()}</span>
</div>
<div className="stat-row">
<span className="stat-label">Combats Initiated</span>
<span className="stat-value">{stats.combats_initiated.toLocaleString()}</span>
</div>
<div className="stat-row">
<span className="stat-label">Damage Dealt</span>
<span className="stat-value highlight-red">{stats.damage_dealt.toLocaleString()}</span>
</div>
<div className="stat-row">
<span className="stat-label">Damage Taken</span>
<span className="stat-value">{stats.damage_taken.toLocaleString()}</span>
</div>
<div className="stat-row">
<span className="stat-label">Deaths</span>
<span className="stat-value">{stats.deaths.toLocaleString()}</span>
</div>
<div className="stat-row">
<span className="stat-label">Successful Flees</span>
<span className="stat-value highlight-green">{stats.successful_flees.toLocaleString()}</span>
</div>
<div className="stat-row">
<span className="stat-label">Failed Flees</span>
<span className="stat-value">{stats.failed_flees.toLocaleString()}</span>
</div>
</div>
<div className="stat-row">
<span className="stat-label">Playtime</span>
<span className="stat-value">{formatPlaytime(stats.total_playtime)}</span>
</div>
</div>
{/* Items Stats */}
<div className="stats-section">
<h2 className="section-title">📦 Items</h2>
<div className="stat-row">
<span className="stat-label">Items Collected</span>
<span className="stat-value">{stats.items_collected.toLocaleString()}</span>
{/* Exploration Stats */}
<div className="stats-section">
<h2 className="section-title">🗺 Exploration</h2>
<div className="stat-row">
<span className="stat-label">Distance Walked</span>
<span className="stat-value highlight-blue">{stats.distance_walked.toLocaleString()}</span>
</div>
<div className="stat-row">
<span className="stat-label">Playtime</span>
<span className="stat-value">{formatPlaytime(stats.total_playtime)}</span>
</div>
</div>
<div className="stat-row">
<span className="stat-label">Items Dropped</span>
<span className="stat-value">{stats.items_dropped.toLocaleString()}</span>
</div>
<div className="stat-row">
<span className="stat-label">Items Used</span>
<span className="stat-value">{stats.items_used.toLocaleString()}</span>
</div>
</div>
{/* Recovery Stats */}
<div className="stats-section">
<h2 className="section-title"> Recovery</h2>
<div className="stat-row">
<span className="stat-label">HP Restored</span>
<span className="stat-value highlight-hp">{stats.hp_restored.toLocaleString()}</span>
{/* Items Stats */}
<div className="stats-section">
<h2 className="section-title">📦 Items</h2>
<div className="stat-row">
<span className="stat-label">Items Collected</span>
<span className="stat-value">{stats.items_collected.toLocaleString()}</span>
</div>
<div className="stat-row">
<span className="stat-label">Items Dropped</span>
<span className="stat-value">{stats.items_dropped.toLocaleString()}</span>
</div>
<div className="stat-row">
<span className="stat-label">Items Used</span>
<span className="stat-value">{stats.items_used.toLocaleString()}</span>
</div>
</div>
<div className="stat-row">
<span className="stat-label">Stamina Used</span>
<span className="stat-value">{stats.stamina_used.toLocaleString()}</span>
</div>
<div className="stat-row">
<span className="stat-label">Stamina Restored</span>
<span className="stat-value highlight-stamina">{stats.stamina_restored.toLocaleString()}</span>
{/* Recovery Stats */}
<div className="stats-section">
<h2 className="section-title"> Recovery</h2>
<div className="stat-row">
<span className="stat-label">HP Restored</span>
<span className="stat-value highlight-hp">{stats.hp_restored.toLocaleString()}</span>
</div>
<div className="stat-row">
<span className="stat-label">Stamina Used</span>
<span className="stat-value">{stats.stamina_used.toLocaleString()}</span>
</div>
<div className="stat-row">
<span className="stat-label">Stamina Restored</span>
<span className="stat-value highlight-stamina">{stats.stamina_restored.toLocaleString()}</span>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
)

View File

@@ -0,0 +1,151 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../hooks/useAuth'
import './Login.css'
function Register() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { register } = useAuth()
const navigate = useNavigate()
const validateEmail = (email: string) => {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return re.test(email)
}
const getPasswordStrength = (password: string): { strength: string; color: string } => {
if (password.length === 0) return { strength: '', color: '' }
if (password.length < 6) return { strength: 'Weak', color: '#ff6b6b' }
if (password.length < 10) return { strength: 'Medium', color: '#ffd93d' }
return { strength: 'Strong', color: '#51cf66' }
}
const passwordStrength = getPasswordStrength(password)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
// Validation
if (!validateEmail(email)) {
setError('Please enter a valid email address')
return
}
if (password.length < 6) {
setError('Password must be at least 6 characters')
return
}
if (password !== confirmPassword) {
setError('Passwords do not match')
return
}
setLoading(true)
try {
await register(email, password)
// Navigate to character selection after successful registration
navigate('/characters')
} catch (err: any) {
setError(err.response?.data?.detail || 'Registration failed')
} finally {
setLoading(false)
}
}
return (
<div className="login-container">
<div className="login-card">
<h1>Create Account</h1>
<p className="login-subtitle">Join the survivors in the wasteland</p>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="email">Email Address</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your.email@example.com"
required
disabled={loading}
autoComplete="email"
/>
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="At least 6 characters"
required
disabled={loading}
autoComplete="new-password"
/>
{password && (
<div className="password-strength">
<span style={{ color: passwordStrength.color }}>
{passwordStrength.strength}
</span>
</div>
)}
</div>
<div className="form-group">
<label htmlFor="confirmPassword">Confirm Password</label>
<input
type="password"
id="confirmPassword"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Re-enter your password"
required
disabled={loading}
autoComplete="new-password"
/>
</div>
{error && <div className="error">{error}</div>}
<button type="submit" className="button-primary" disabled={loading}>
{loading ? 'Creating Account...' : 'Create Account'}
</button>
</form>
<div className="login-toggle">
<button
type="button"
className="button-link"
onClick={() => navigate('/login')}
disabled={loading}
>
Already have an account? Login
</button>
</div>
<div className="login-toggle">
<button
type="button"
className="button-link"
onClick={() => navigate('/')}
disabled={loading}
>
Back to Home
</button>
</div>
</div>
</div>
)
}
export default Register

View File

@@ -0,0 +1,345 @@
import { useState, useEffect } from 'react'
import CombatView from './CombatView'
import { CombatState, CombatLogEntry, Profile, Equipment, PlayerState } from './types'
import api from '../../services/api'
import './CombatEffects.css'
interface CombatProps {
combatState: CombatState
profile: Profile | null
playerState: PlayerState | null
equipment: Equipment
onCombatAction: (action: string) => Promise<any>
onExitCombat: () => void
onPvPAction: (action: string) => Promise<any>
onExitPvPCombat: () => void
combatLog: CombatLogEntry[]
addCombatLogEntry: (entry: CombatLogEntry) => void
updatePlayerState: (state: PlayerState) => void
updateCombatState: (state: CombatState) => void
}
const Combat = ({
combatState,
profile,
playerState,
equipment,
onCombatAction,
onExitCombat,
onPvPAction,
onExitPvPCombat,
combatLog,
addCombatLogEntry,
updatePlayerState,
updateCombatState
}: CombatProps) => {
// Local state for visual effects and logic
const [shake, setShake] = useState(false)
const [flash, setFlash] = useState(false)
const [floatingTexts, setFloatingTexts] = useState<{ id: number, text: string, x: number, y: number, type: 'damage-player' | 'damage-enemy' | 'damage-player-dealt' | 'heal' }[]>([])
const [processing, setProcessing] = useState(false)
const [pvpTimer, setPvpTimer] = useState<number | null>(null)
const [localEnemyTurnMessage, setLocalEnemyTurnMessage] = useState('')
// Temporary HP state to delay player HP updates during enemy turn
const [tempPlayerHP, setTempPlayerHP] = useState<number | null>(null)
// Turn timer state for PvE combat
const [turnTimeRemaining, setTurnTimeRemaining] = useState<number | null>(null)
// PvP Timer Effect
useEffect(() => {
if (combatState.is_pvp && combatState.pvp_combat) {
// Always set timer from server value
setPvpTimer(combatState.pvp_combat.time_remaining)
// Run countdown locally for smooth UI
const interval = setInterval(() => {
setPvpTimer(prev => (prev && prev > 0 ? prev - 1 : 0))
}, 1000)
return () => clearInterval(interval)
} else {
setPvpTimer(null)
}
}, [combatState.is_pvp, combatState.pvp_combat])
// PvE Timer Effect - Update from server-calculated time
// Reset timer whenever turn_time_remaining changes from server
useEffect(() => {
if (!combatState.is_pvp && combatState.combat?.turn === 'player' && combatState.combat?.turn_time_remaining !== undefined) {
// Always set the timer from server value to ensure it resets after each turn
setTurnTimeRemaining(combatState.combat.turn_time_remaining)
} else {
setTurnTimeRemaining(null)
}
}, [combatState.is_pvp, combatState.combat?.turn, combatState.combat?.turn_time_remaining])
// PvE Timer Countdown Effect - Decrement locally for smooth UI
useEffect(() => {
if (turnTimeRemaining !== null && turnTimeRemaining > 0) {
const interval = setInterval(() => {
setTurnTimeRemaining(prev => prev !== null && prev > 0 ? prev - 1 : 0)
}, 1000)
return () => clearInterval(interval)
}
}, [turnTimeRemaining])
// PvE Polling Effect - Poll when timeout is imminent (< 30s) to catch background task
useEffect(() => {
if (!combatState.is_pvp && turnTimeRemaining !== null && turnTimeRemaining < 30 && turnTimeRemaining >= 0) {
const pollInterval = setInterval(async () => {
try {
// Fetch updated combat state from API
const response = await api.get('/api/game/combat')
if (response.data.in_combat && response.data.combat) {
// Update combat state if turn changed (background task processed timeout)
if (response.data.combat.turn !== combatState.combat?.turn) {
updateCombatState({
...combatState,
combat: response.data.combat
})
}
}
} catch (error) {
console.error('Failed to poll combat state:', error)
}
}, 10000) // Poll every 10 seconds
return () => clearInterval(pollInterval)
}
}, [turnTimeRemaining, combatState, updateCombatState])
const addFloatingText = (text: string, x: number, y: number, type: 'damage-player' | 'damage-enemy' | 'damage-player-dealt' | 'heal') => {
const id = Date.now() + Math.random()
setFloatingTexts(prev => [...prev, { id, text, x, y, type }])
setTimeout(() => {
setFloatingTexts(prev => prev.filter(ft => ft.id !== id))
}, 2500)
}
const handlePvEAction = async (action: string) => {
if (processing) return
setProcessing(true)
try {
const data = await onCombatAction(action)
const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
// Parse messages
const messages = data.message.split('\n').filter((m: string) => m.trim())
// Handle failed flee special case - split combined message
const processedMessages: string[] = []
messages.forEach((msg: string) => {
// Check if message contains both flee failure and enemy attack
const fleeFailMatch = msg.match(/^(Failed to flee!)\s+(.+)$/)
if (fleeFailMatch) {
processedMessages.push(fleeFailMatch[1]) // "Failed to flee!"
processedMessages.push(fleeFailMatch[2]) // Enemy attack message
} else {
processedMessages.push(msg)
}
})
const playerMessages = processedMessages.filter((msg: string) =>
msg.includes('You ') || msg.includes('Your ') || msg === 'Failed to flee!'
)
const enemyMessages = processedMessages.filter((msg: string) =>
msg !== 'Failed to flee!' &&
(msg.includes('attacks') || msg.includes('hits') || msg.includes('misses') || msg.includes('The '))
)
// Check if this is a failed flee attempt
const isFailedFlee = playerMessages.some(msg => msg === 'Failed to flee!')
// 1. Immediate Player Feedback
playerMessages.forEach((msg: string) => {
addCombatLogEntry({ time: timeStr, message: msg, isPlayer: true })
// Only show attack animations for actual attacks, not flee failures
if (msg !== 'Failed to flee!') {
const damageMatch = msg.match(/(\d+) damage/)
if (damageMatch) {
addFloatingText(damageMatch[1], 50, 30, 'damage-player-dealt') // White text on enemy
setFlash(true)
setTimeout(() => setFlash(false), 300)
}
}
})
// Update Enemy HP immediately
if (data.combat && !data.combat_over) {
updateCombatState({
...combatState,
combat: {
...combatState.combat,
npc_hp: data.combat.npc_hp,
turn: data.combat.turn,
turn_time_remaining: data.combat.turn_time_remaining,
round: data.combat.round
}
})
// Store current player HP to prevent it from updating during enemy turn
if (data.player && playerState) {
setTempPlayerHP(playerState.health)
}
}
// 2. Enemy Turn Delay (including failed flee)
if ((enemyMessages.length > 0 || isFailedFlee) && !data.combat_over) {
setLocalEnemyTurnMessage(isFailedFlee ? "🗡️ Enemy's turn..." : "🗡️ Enemy's turn...")
await new Promise(resolve => setTimeout(resolve, 2000))
enemyMessages.forEach((msg: string) => {
addCombatLogEntry({ time: timeStr, message: msg, isPlayer: false })
const damageMatch = msg.match(/(\d+) damage/)
if (damageMatch) {
addFloatingText(damageMatch[1], 50, 50, 'damage-player') // Red text over player position
setShake(true)
setTimeout(() => setShake(false), 500)
}
})
setLocalEnemyTurnMessage('')
// Update Player HP after delay completes
if (data.player && playerState) {
setTempPlayerHP(null) // Clear temp HP
updatePlayerState({
...playerState,
health: data.player.hp,
max_health: data.player.max_hp ?? playerState.max_health
})
}
} else if (data.combat_over) {
// Combat ended (e.g. player won or fled)
const playerFled = data.message.toLowerCase().includes('fled') ||
data.message.toLowerCase().includes('escape') ||
data.player_fled === true
updateCombatState({
...combatState,
combat_over: true,
player_won: data.player_won || false,
player_fled: playerFled,
combat: {
...combatState.combat,
npc_hp: data.player_won ? 0 : (data.combat?.npc_hp ?? combatState.combat.npc_hp)
}
})
// Update player state immediately if combat is over
setTempPlayerHP(null) // Clear temp HP
if (data.player && playerState) {
updatePlayerState({
...playerState,
health: data.player.hp,
max_health: data.player.max_hp ?? playerState.max_health
})
}
}
} catch (error) {
console.error('Combat action failed:', error)
} finally {
setProcessing(false)
}
}
const handlePvPActionLocal = async (action: string) => {
if (processing) return
setProcessing(true)
try {
// Call the parent handler (which calls API)
// Note: onPvPAction in Game.tsx currently returns void, but we might need the response
// We'll modify onPvPAction to return the response or we'll rely on the websocket update for state
// BUT for animations we need the immediate response if possible, OR we parse the websocket message?
// The user request says "The timer is also not updating correctly, it should grab the latest data from the websocket update or from the action call."
// So let's assume onPvPAction CAN return data if we await it.
// Checking Game.tsx: onPvPAction calls api.post and sets message. It doesn't return data.
// We need to modify Game.tsx to return the data too?
// Actually, let's just trigger the action and let the websocket handle the state update,
// BUT for "floating text for damage", we usually get that from the immediate response in PvE.
// In PvP, the response might contain the damage info.
// Let's assume onPvPAction returns the response data now (we'll fix Game.tsx if needed, or just use what we have)
// Wait, Game.tsx onPvPAction is:
// onPvPAction={async (action: string) => {
// try {
// const response = await api.post('/api/game/pvp/action', { action })
// actions.setMessage(response.data.message || 'Action performed!')
// await actions.fetchGameData()
// } ...
// }}
// It doesn't return the data to the caller.
// We will modify Combat.tsx to accept a promise that returns data, OR we modify Game.tsx to return it.
// For now, let's just call it and see if we can parse the message from the state update?
// No, animations need to happen NOW.
// Let's change onPvPAction prop signature in Combat.tsx to return Promise<any>
// and update Game.tsx to return the response.data.
const data = await onPvPAction(action)
if (data) {
const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
// Parse message for damage
// Example: "You attacked X for 10 damage!"
const msg = data.message || ''
addCombatLogEntry({ time: timeStr, message: msg, isPlayer: true })
const damageMatch = msg.match(/(\d+) damage/)
if (damageMatch) {
addFloatingText(damageMatch[1], 50, 30, 'damage-player-dealt')
setFlash(true)
setTimeout(() => setFlash(false), 300)
}
// If we got hit back immediately (e.g. recoil? or just turn end?)
// Usually PvP is turn based, so we wait for opponent.
}
} catch (error) {
console.error('PvP action failed:', error)
} finally {
setProcessing(false)
}
}
return (
<div className={`combat-container ${shake ? 'shake-effect' : ''}`}>
<CombatView
combatState={combatState}
combatLog={combatLog}
profile={profile}
playerState={tempPlayerHP !== null && playerState ? {
...playerState,
health: tempPlayerHP
} : playerState}
equipment={equipment}
enemyName={combatState.combat?.npc_name || 'Enemy'}
enemyImage={combatState.combat?.npc_image || combatState.combat_image || ''}
enemyTurnMessage={localEnemyTurnMessage}
pvpTimeRemaining={pvpTimer}
turnTimeRemaining={turnTimeRemaining}
onCombatAction={handlePvEAction}
onFlee={async () => handlePvEAction('flee')}
onPvPAction={handlePvPActionLocal}
onExitCombat={onExitCombat}
onExitPvPCombat={onExitPvPCombat}
flashEnemy={flash}
buttonsDisabled={processing}
floatingTexts={floatingTexts}
/>
</div>
)
}
export default Combat

View File

@@ -0,0 +1,328 @@
/* Combat Visual Effects */
/* Screen Shake */
@keyframes shake {
0% {
transform: translate(1px, 1px) rotate(0deg);
}
10% {
transform: translate(-1px, -2px) rotate(-1deg);
}
20% {
transform: translate(-3px, 0px) rotate(1deg);
}
30% {
transform: translate(3px, 2px) rotate(0deg);
}
40% {
transform: translate(1px, -1px) rotate(1deg);
}
50% {
transform: translate(-1px, 2px) rotate(-1deg);
}
60% {
transform: translate(-3px, 1px) rotate(0deg);
}
70% {
transform: translate(3px, 1px) rotate(-1deg);
}
80% {
transform: translate(-1px, -1px) rotate(1deg);
}
90% {
transform: translate(1px, 2px) rotate(0deg);
}
100% {
transform: translate(1px, -2px) rotate(-1deg);
}
}
.shake-effect {
animation: shake 0.5s;
animation-iteration-count: 1;
}
/* Hit Flash */
@keyframes flash-red {
0% {
filter: brightness(1) sepia(0) hue-rotate(0deg) saturate(1);
}
50% {
filter: brightness(0.5) sepia(1) hue-rotate(-50deg) saturate(5);
}
/* Red tint */
100% {
filter: brightness(1) sepia(0) hue-rotate(0deg) saturate(1);
}
}
.flash-hit {
animation: flash-red 0.3s ease-out;
}
/* Dead Enemy Grayscale */
.enemy-dead {
filter: grayscale(100%);
transition: filter 0.5s ease-out;
}
/* Fled Enemy Blueish Tint */
.enemy-fled {
filter: sepia(1) saturate(3) hue-rotate(180deg) brightness(0.8);
transition: filter 0.5s ease-out;
}
/* Floating Damage Numbers */
@keyframes float-up {
0% {
opacity: 1;
transform: translateY(0) scale(1);
}
50% {
opacity: 1;
transform: translateY(-30px) scale(1.3);
}
100% {
opacity: 0;
transform: translateY(-60px) scale(1.5);
}
}
.floating-text-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
overflow: hidden;
z-index: 100;
}
.floating-text {
position: absolute;
font-weight: bold;
font-size: 2.5rem;
text-shadow: 3px 3px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000;
animation: float-up 2.5s ease-out forwards;
white-space: nowrap;
pointer-events: none;
z-index: 1000;
}
.floating-text.damage-player {
color: #ff4444;
}
.floating-text.damage-enemy {
color: #ff4444;
}
.floating-text.damage-player-dealt {
color: #ffffff;
}
.floating-text.heal {
color: #44ff44;
}
/* Intent Bubble */
.intent-bubble {
position: absolute;
top: -40px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
border: 2px solid #fff;
border-radius: 20px;
padding: 5px 15px;
color: #fff;
font-weight: bold;
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
z-index: 10;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.5);
animation: pop-in 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes pop-in {
0% {
transform: translateX(-50%) scale(0);
}
100% {
transform: translateX(-50%) scale(1);
}
}
.intent-icon {
font-size: 1.2em;
}
.intent-desc {
font-size: 0.9em;
text-transform: uppercase;
letter-spacing: 1px;
}
/* Intent Types */
.intent-attack {
border-color: #ff4444;
}
.intent-defend {
border-color: #4488ff;
}
.intent-special {
border-color: #ffaa00;
}
/* Container relative positioning for absolute children */
.combat-enemy-display-inline {
position: relative;
}
.combat-enemy-image-large {
position: relative;
display: inline-block;
max-width: 100%;
}
.combat-enemy-image-large img {
max-width: 100%;
height: auto;
display: block;
}
.combat-view {
position: relative;
/* For screen shake scope if applied here */
}
/* Combat Container */
.combat-container {
position: relative;
width: 100%;
}
/* Combat Content Wrapper - Groups enemy display, turn indicator, and combat log */
.combat-content-wrapper {
display: inline-flex;
flex-direction: column;
align-items: stretch;
gap: 1rem;
max-width: 800px;
margin: 0 auto;
}
/* Turn Indicator - Match Enemy Image Width */
.combat-turn-indicator-inline {
width: 100%;
display: flex;
justify-content: center;
}
/* Combat Log Styles */
.combat-log-wrapper {
width: 100%;
}
.combat-log-title {
margin: 0 0 10px 0;
font-size: 1.1em;
color: #aaa;
text-align: left;
}
.combat-log-inline {
background: rgba(0, 0, 0, 0.3);
padding: 15px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.log-entries {
max-height: 200px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
/* Custom scrollbar for combat log */
.log-entries::-webkit-scrollbar {
width: 8px;
}
.log-entries::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
.log-entries::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
.log-entries::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
.log-entry {
font-size: 0.9em;
padding: 6px 8px;
line-height: 1.5;
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
border-left: 3px solid transparent;
transition: background 0.2s ease;
display: flex;
align-items: flex-start;
gap: 8px;
width: 100%;
}
.log-entry:hover {
background: rgba(0, 0, 0, 0.35);
}
.log-time {
color: #888;
font-size: 0.85em;
font-family: monospace;
flex-shrink: 0;
white-space: nowrap;
}
.log-message {
flex: 1;
word-wrap: break-word;
}
.player-log {
color: #aaddff;
border-left-color: #4488ff;
}
.enemy-log {
color: #ffaaaa;
border-left-color: #ff4444;
}

View File

@@ -0,0 +1,339 @@
import type { CombatState, CombatLogEntry, Profile, Equipment, PlayerState } from './types'
interface CombatViewProps {
combatState: CombatState
combatLog: CombatLogEntry[]
profile: Profile | null
playerState: PlayerState | null
equipment: Equipment
enemyName: string
enemyImage: string
enemyTurnMessage: string
pvpTimeRemaining: number | null
turnTimeRemaining: number | null
onCombatAction: (action: string) => void
onFlee: () => void
onPvPAction: (action: string) => void
onExitCombat: () => void
onExitPvPCombat: () => void
flashEnemy?: boolean
buttonsDisabled?: boolean
floatingTexts?: { id: number, text: string, x: number, y: number, type: 'damage-player' | 'damage-enemy' | 'damage-player-dealt' | 'heal' }[]
}
function CombatView({
combatState,
combatLog,
profile: _profile,
playerState,
enemyName,
enemyImage,
enemyTurnMessage,
pvpTimeRemaining,
turnTimeRemaining,
onCombatAction,
onPvPAction,
onExitCombat,
onExitPvPCombat,
flashEnemy,
buttonsDisabled,
floatingTexts = []
}: CombatViewProps) {
return (
<div className="combat-view">
<div className="combat-header-inline">
<h2>
{combatState.is_pvp ? '⚔️ PvP Combat' : `⚔️ Combat - ${enemyName || combatState.combat?.npc_name || 'Enemy'}`}
</h2>
</div>
{combatState.is_pvp ? (
/* PvP Combat UI - Unified Layout */
<div className="combat-content-wrapper">
<div className="combat-enemy-display-inline">
{/* Opponent Display (using same structure as PvE Enemy) */}
<div className="combat-enemy-image-large">
{floatingTexts.map(ft => (
<div
key={ft.id}
className={`floating-text ${ft.type}`}
style={{ left: `${ft.x}%`, top: `${ft.y}%` }}>
{ft.text}
</div>
))}
{(() => {
if (!combatState.pvp_combat) return null
const opponent = combatState.pvp_combat.is_attacker ?
combatState.pvp_combat.defender :
combatState.pvp_combat.attacker
if (!opponent) return <div className="pvp-opponent-avatar"></div>
// Use a default avatar if no image, or maybe the class image if available?
// For now, let's use a placeholder or try to get it from profile if passed?
// The opponent object has: username, level, hp, max_hp.
// It might not have an image url.
return (
<div className="pvp-opponent-avatar" style={{ fontSize: '4rem', textAlign: 'center' }}>
👤
<div style={{ fontSize: '1rem', marginTop: '0.5rem' }}>{opponent.username} (Lv. {opponent.level})</div>
</div>
)
})()}
</div>
<div className="combat-enemy-info-inline">
{/* Opponent HP Bar */}
{(() => {
if (!combatState.pvp_combat) return null
const opponent = combatState.pvp_combat.is_attacker ?
combatState.pvp_combat.defender :
combatState.pvp_combat.attacker
if (!opponent) return null
return (
<div className="combat-hp-bar-container-inline enemy-hp-bar">
<div className="combat-hp-bar-inline">
<div className="combat-stat-label-inline">
{opponent.username}: {opponent.hp} / {opponent.max_hp}
</div>
<div
className="combat-hp-fill-inline"
style={{
width: `${(opponent.hp / opponent.max_hp) * 100}%`
}}
/>
</div>
</div>
)
})()}
{/* Player HP Bar */}
{(() => {
if (!combatState.pvp_combat) return null
const you = combatState.pvp_combat.is_attacker ?
combatState.pvp_combat.attacker :
combatState.pvp_combat.defender
if (!you) return null
return (
<div className="combat-hp-bar-container-inline player-hp-bar" style={{ marginTop: '0.75rem' }}>
<div className="combat-hp-bar-inline" style={{ background: 'rgba(255, 107, 107, 0.2)' }}>
<div className="combat-stat-label-inline" style={{ color: '#ff6b6b' }}>
You: {you.hp} / {you.max_hp}
</div>
<div
className="combat-hp-fill-inline"
style={{
width: `${(you.hp / you.max_hp) * 100}%`,
background: 'linear-gradient(90deg, #f44336, #ff6b6b)'
}}
/>
</div>
</div>
)
})()}
</div>
</div>
<div className="combat-turn-indicator-inline">
{combatState.pvp_combat.combat_over ? (
<span className={combatState.pvp_combat.attacker_fled || combatState.pvp_combat.defender_fled ? "your-turn" : "enemy-turn"}>
{combatState.pvp_combat.attacker_fled || combatState.pvp_combat.defender_fled ? "🏃 Combat Ended" : "💀 Combat Over"}
</span>
) : combatState.pvp_combat.your_turn ? (
<span className="your-turn"> Your Turn ({pvpTimeRemaining ?? combatState.pvp_combat.time_remaining}s)</span>
) : (
<span className="enemy-turn"> Opponent's Turn ({pvpTimeRemaining ?? combatState.pvp_combat.time_remaining}s)</span>
)}
</div>
<div className="combat-actions-inline">
{!combatState.pvp_combat.combat_over ? (
<>
<button
className="combat-action-btn attack-btn"
onClick={() => onPvPAction('attack')}
disabled={!combatState.pvp_combat.your_turn || buttonsDisabled}
>
⚔️ Attack
</button>
<button
className="combat-action-btn flee-btn"
onClick={() => onPvPAction('flee')}
disabled={!combatState.pvp_combat.your_turn || buttonsDisabled}
>
🏃 Flee
</button>
</>
) : (
<button
className="combat-action-btn exit-btn"
onClick={onExitPvPCombat}
>
{combatState.pvp_combat.attacker_fled || combatState.pvp_combat.defender_fled ? ' Continue' : '💀 Return'}
</button>
)}
</div>
{/* Combat Log */}
<div className="combat-log-wrapper">
<h3 className="combat-log-title">Combat Log</h3>
<div className="combat-log-inline">
<div className="log-entries">
{combatLog.map((entry: any, i: number) => (
<div key={i} className={`log-entry ${entry.isPlayer ? 'player-log' : 'enemy-log'}`}>
<span className="log-time">[{entry.time}]</span>
<span className="log-message">{entry.message}</span>
</div>
))}
{combatLog.length === 0 && <div className="log-entry"><span className="log-message">PvP Combat started...</span></div>}
</div>
</div>
</div>
</div>
) : (
/* PvE Combat UI */
<>
<div className="combat-content-wrapper">
<div className="combat-enemy-display-inline">
{/* Intent Bubble - Moved here to avoid overflow:hidden clipping */}
{combatState.combat?.npc_intent && !combatState.combat_over && (
<div className={`intent-bubble intent-${combatState.combat.npc_intent}`}>
<span className="intent-icon">
{combatState.combat.npc_intent === 'attack' ? '' :
combatState.combat.npc_intent === 'defend' ? '🛡' :
combatState.combat.npc_intent === 'special' ? '🔥' : ''}
</span>
<span className="intent-desc">{combatState.combat.npc_intent}</span>
</div>
)}
<div className="combat-enemy-image-large">
{floatingTexts.map(ft => (
<div
key={ft.id}
className={`floating-text ${ft.type}`}
style={{ left: `${ft.x}%`, top: `${ft.y}%` }}>
{ft.text}
</div>
))}
<img
src={enemyImage || combatState.combat?.npc_image || combatState.combat_image}
alt={enemyName || combatState.combat?.npc_name || 'Enemy'}
className={`${flashEnemy ? 'flash-hit' : ''
} ${combatState.combat_over && combatState.player_won ? 'enemy-dead' : ''
} ${combatState.combat_over && combatState.player_fled ? 'enemy-fled' : ''
}`}
/>
</div>
<div className="combat-enemy-info-inline">
<div className="combat-hp-bar-container-inline enemy-hp-bar">
<div className="combat-hp-bar-inline">
<div className="combat-stat-label-inline">
Enemy HP: {combatState.combat?.npc_hp || 0} / {combatState.combat?.npc_max_hp || 100}
</div>
<div
className="combat-hp-fill-inline"
style={{
width: `${((combatState.combat?.npc_hp || 0) / (combatState.combat?.npc_max_hp || 100)) * 100}%`
}}
/>
</div>
</div>
{playerState && (
<div className="combat-hp-bar-container-inline player-hp-bar" style={{ marginTop: '0.75rem' }}>
<div className="combat-hp-bar-inline" style={{ background: 'rgba(255, 107, 107, 0.2)' }}>
<div className="combat-stat-label-inline" style={{ color: '#ff6b6b' }}>
Your HP: {playerState.health} / {playerState.max_health}
</div>
<div
className="combat-hp-fill-inline"
style={{
width: `${(playerState.health / playerState.max_health) * 100}%`,
background: 'linear-gradient(90deg, #f44336, #ff6b6b)'
}}
/>
</div>
</div>
)}
</div>
</div>
<div className={`combat-turn-indicator-inline ${enemyTurnMessage ? 'enemy-turn-message' : ''}`}>
{!combatState.combat_over ? (
enemyTurnMessage ? (
<span className="enemy-turn">🗡️ Enemy's turn...</span>
) : combatState.combat?.turn === 'player' ? (
<>
<span className="your-turn"> Your Turn</span>
{turnTimeRemaining !== null && (
<span className="turn-timer" style={{ marginLeft: '1rem', fontSize: '0.9em', opacity: 0.8 }}>
{Math.floor(turnTimeRemaining / 60)}:{String(Math.floor(turnTimeRemaining % 60)).padStart(2, '0')}
</span>
)}
</>
) : (
<span className="enemy-turn"> Enemy Turn</span>
)
) : (
<span className={combatState.player_won ? "your-turn" : combatState.player_fled ? "your-turn" : "enemy-turn"}>
{combatState.player_won ? "✅ Victory!" : combatState.player_fled ? "🏃 Escaped!" : "💀 Defeated"}
</span>
)}
</div>
{/* PvE Combat Actions */}
<div className="combat-actions-inline">
{!combatState.combat_over ? (
<>
<button
className="combat-action-btn attack-btn"
onClick={() => onCombatAction('attack')}
disabled={combatState.combat?.turn !== 'player' || !!enemyTurnMessage || buttonsDisabled}
>
Attack
</button>
<button
className="combat-action-btn flee-btn"
onClick={() => onCombatAction('flee')}
disabled={combatState.combat?.turn !== 'player' || !!enemyTurnMessage || buttonsDisabled}
>
🏃 Flee
</button>
</>
) : (
<button
className="combat-action-btn exit-btn"
onClick={onExitCombat}
>
{combatState.player_won ? '✅ Continue' : combatState.player_fled ? '✅ Continue' : '💀 Return'}
</button>
)}
</div>
{/* Combat Log */}
<div className="combat-log-wrapper">
<h3 className="combat-log-title">Combat Log</h3>
<div className="combat-log-inline">
<div className="log-entries">
{combatLog.map((entry: any, i: number) => (
<div key={i} className={`log-entry ${entry.isPlayer ? 'player-log' : 'enemy-log'}`}>
<span className="log-time">[{entry.time}]</span>
<span className="log-message">{entry.message}</span>
</div>
))}
{combatLog.length === 0 && <div className="log-entry"><span className="log-message">Combat started...</span></div>}
</div>
</div>
</div>
</div>
</>
)}
</div>
)
}
export default CombatView

View File

@@ -0,0 +1,759 @@
/* Weight and Volume Progress Bars */
.sidebar-progress-fill.weight {
background: linear-gradient(90deg, #ff9800, #f57c00);
}
.sidebar-progress-fill.volume {
background: linear-gradient(90deg, #9c27b0, #7b1fa2);
}
/* Inventory Tab - Full View */
.inventory-tab {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
.inventory-tab h2 {
color: #6bb9f0;
margin-bottom: 1.5rem;
}
/* Modal Overlay */
.inventory-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.85);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
/* --- Redesigned Inventory Modal --- */
.inventory-modal-redesign {
display: flex;
flex-direction: column;
height: 85vh;
width: 95vw;
max-width: 1400px;
/* Match Workbench width */
background: linear-gradient(135deg, #1e2a38 0%, #121820 100%);
border: 1px solid #3a4b5c;
border-radius: 12px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.8);
overflow: hidden;
color: #e0e6ed;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
/* Top Bar */
.inventory-top-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
background: rgba(0, 0, 0, 0.3);
border-bottom: 1px solid #3a4b5c;
flex-shrink: 0;
}
.inventory-capacity-summary {
display: flex;
gap: 2rem;
flex: 1;
}
.capacity-metric {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
max-width: 300px;
}
.metric-icon {
font-size: 1.5rem;
}
.metric-bar-container {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.metric-text {
font-size: 0.85rem;
color: #a0aec0;
}
.metric-bar {
height: 8px;
background: #2d3748;
border-radius: 4px;
overflow: hidden;
}
.metric-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.metric-fill.weight {
background: linear-gradient(90deg, #48bb78, #38a169);
}
.metric-fill.volume {
background: linear-gradient(90deg, #4299e1, #3182ce);
}
.inventory-backpack-info {
display: flex;
align-items: center;
gap: 1.5rem;
}
.backpack-status {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 1rem;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
}
.backpack-status.active {
border: 1px solid #48bb78;
color: #48bb78;
}
.backpack-status.inactive {
border: 1px solid #e53e3e;
color: #e53e3e;
}
.backpack-name {
font-weight: 600;
color: #fff;
}
.backpack-stats {
font-size: 0.85rem;
color: #a0aec0;
}
.close-btn {
background: none;
border: none;
color: #a0aec0;
font-size: 1.5rem;
cursor: pointer;
transition: color 0.2s;
}
.close-btn:hover {
color: #fff;
}
/* Main Layout */
.inventory-main-layout {
display: flex;
flex: 1;
overflow: hidden;
}
/* Sidebar Filters */
.inventory-sidebar-filters {
width: 220px;
background: rgba(0, 0, 0, 0.2);
border-right: 1px solid #3a4b5c;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
overflow-y: auto;
}
.category-btn {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
color: #a0aec0;
cursor: pointer;
transition: all 0.2s;
text-align: left;
}
.category-btn:hover {
background: rgba(255, 255, 255, 0.05);
color: #fff;
}
.category-btn.active {
background: rgba(66, 153, 225, 0.15);
border-color: #4299e1;
color: #63b3ed;
}
.cat-icon {
font-size: 1.2rem;
width: 1.5rem;
display: flex;
justify-content: center;
align-items: center;
}
.cat-label {
font-weight: 500;
}
/* Content Area */
.inventory-content-area {
flex: 1;
display: flex;
flex-direction: column;
padding: 1.5rem;
overflow: hidden;
}
.inventory-search-bar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: rgba(0, 0, 0, 0.2);
border: 1px solid #3a4b5c;
border-radius: 8px;
margin-bottom: 1.5rem;
}
.inventory-search-bar input {
background: transparent;
border: none;
color: #fff;
font-size: 1rem;
flex: 1;
outline: none;
}
.inventory-items-grid {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding-right: 0.5rem;
}
/* Compact Item Card */
.inventory-item-card.compact {
display: flex;
flex-direction: row;
background-color: rgba(26, 32, 44, 0.8);
border: 1px solid #2d3748;
border-radius: 0.5rem;
padding: 0.75rem;
gap: 1rem;
align-items: stretch;
transition: all 0.2s ease;
margin-bottom: 0.75rem;
/* Add separation between cards */
}
.inventory-item-card.compact:hover {
border-color: #63b3ed;
background: rgba(255, 255, 255, 0.06);
transform: translateY(-1px);
}
.item-image-section.small {
width: 100px;
height: 100px;
flex-shrink: 0;
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.3);
border-radius: 6px;
border: 1px solid #4a5568;
margin: auto;
}
.item-img-thumb {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.item-icon-large {
font-size: 2rem;
display: flex;
align-items: center;
justify-content: center;
}
.item-icon-large.hidden {
display: none;
}
.item-quantity-badge {
position: absolute;
bottom: -5px;
right: -5px;
background: #2d3748;
border: 1px solid #4a5568;
color: #fff;
font-size: 0.75rem;
padding: 2px 6px;
border-radius: 10px;
font-weight: bold;
}
.item-info-section {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.4rem;
min-width: 0;
/* Prevent flex overflow */
}
.item-header-compact {
display: flex;
align-items: center;
gap: 0.5rem;
}
.item-emoji-inline {
font-size: 1.2rem;
}
.item-name-compact {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-description-compact {
margin: 0;
font-size: 0.85rem;
color: #a0aec0;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.item-tier-badge {
font-size: 0.7rem;
padding: 2px 6px;
border-radius: 4px;
background: #4a5568;
color: #e2e8f0;
font-weight: bold;
margin-left: auto;
}
/* Tier Colors */
.text-tier-0 {
color: #a0aec0;
}
/* Common - Gray */
.text-tier-1 {
color: #ffffff;
}
/* Uncommon - White */
.text-tier-2 {
color: #68d391;
}
/* Rare - Green */
.text-tier-3 {
color: #63b3ed;
}
/* Epic - Blue */
.text-tier-4 {
color: #9f7aea;
}
/* Legendary - Purple */
.text-tier-5 {
color: #ed8936;
}
/* Mythic - Orange */
.item-icon-large.tier-0 {
text-shadow: 0 0 10px rgba(160, 174, 192, 0.3);
}
.item-icon-large.tier-1 {
text-shadow: 0 0 10px rgba(255, 255, 255, 0.3);
}
.item-icon-large.tier-2 {
text-shadow: 0 0 10px rgba(104, 211, 145, 0.3);
}
.item-icon-large.tier-3 {
text-shadow: 0 0 10px rgba(99, 179, 237, 0.3);
}
.item-icon-large.tier-4 {
text-shadow: 0 0 10px rgba(159, 122, 234, 0.3);
}
.item-icon-large.tier-5 {
text-shadow: 0 0 10px rgba(237, 137, 54, 0.3);
}
.item-stats-row {
display: flex;
align-items: stretch;
/* Ensure separators stretch full height */
gap: 1rem;
margin-top: 0.25rem;
flex-wrap: nowrap;
/* Prevent wrapping to keep columns consistent */
}
.stat-group-fixed {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 140px;
border-right: 1px solid #4a5568;
padding-right: 1rem;
justify-content: center;
/* Center content vertically */
}
.stat-text {
font-size: 0.8rem;
color: #cbd5e0;
}
.stat-row-compact {
display: grid;
grid-template-columns: 20px 60px 1fr;
align-items: center;
font-size: 0.8rem;
color: #cbd5e0;
width: 100%;
}
.stat-row-compact .text-muted {
color: #718096;
font-size: 0.75rem;
text-align: left;
}
.item-description-compact {
font-size: 0.85rem;
color: #a0aec0;
margin-bottom: 0.5rem;
line-height: 1.4;
white-space: normal;
/* Ensure text wraps */
overflow-wrap: break-word;
/* Break long words if needed */
}
.stats-durability-column {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.25rem;
flex: 1;
min-width: 0;
justify-content: center;
/* Center content vertically */
}
.stat-badges-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
font-size: 0.75rem;
}
.stat-badge {
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid;
display: flex;
align-items: center;
gap: 0.375rem;
font-weight: 600;
background-color: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
color: #e2e8f0;
}
/* Variant Colors */
.stat-badge.capacity,
.stat-badge.endurance,
.stat-badge.health {
background-color: rgba(16, 185, 129, 0.2);
color: #6ee7b7;
border-color: rgba(16, 185, 129, 0.4);
}
.stat-badge.damage,
.stat-badge.penetration {
background-color: rgba(239, 68, 68, 0.2);
color: #fca5a5;
border-color: rgba(239, 68, 68, 0.4);
}
.stat-badge.armor {
background-color: rgba(59, 130, 246, 0.2);
color: #93c5fd;
border-color: rgba(59, 130, 246, 0.4);
}
.stat-badge.crit,
.stat-badge.stamina {
background-color: rgba(234, 179, 8, 0.2);
color: #fde047;
border-color: rgba(234, 179, 8, 0.4);
}
.stat-badge.accuracy {
background-color: rgba(20, 184, 166, 0.2);
color: #5eead4;
border-color: rgba(20, 184, 166, 0.4);
}
.stat-badge.dodge {
background-color: rgba(99, 102, 241, 0.2);
color: #a5b4fc;
border-color: rgba(99, 102, 241, 0.4);
}
.stat-badge.lifesteal {
background-color: rgba(236, 72, 153, 0.2);
color: #f9a8d4;
border-color: rgba(236, 72, 153, 0.4);
}
.stat-badge.strength {
background-color: rgba(249, 115, 22, 0.2);
color: #fdba74;
border-color: rgba(249, 115, 22, 0.4);
}
.stat-badge.agility {
background-color: rgba(6, 182, 212, 0.2);
color: #67e8f9;
border-color: rgba(6, 182, 212, 0.4);
}
/* Durability Bar Styles */
.durability-container {
width: 100%;
margin-top: 0.5rem;
}
.durability-header {
display: flex;
justify-content: space-between;
font-size: 0.65rem;
margin-bottom: 0.25rem;
color: rgba(255, 255, 255, 0.6);
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 500;
}
.durability-text-low {
color: #f87171;
}
.durability-track {
height: 0.5rem;
background-color: #374151;
border-radius: 9999px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.durability-fill {
height: 100%;
transition: width 0.3s ease, background-color 0.3s ease;
}
.durability-fill.high {
background-color: #10b981;
}
.durability-fill.medium {
background-color: #eab308;
}
.durability-fill.low {
background-color: #ef4444;
}
/* Actions Section */
.item-actions-section {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
border-left: 1px solid #4a5568;
padding-left: 1rem;
align-self: stretch;
flex-direction: column;
justify-content: flex-end;
width: 180px;
/* Fixed width for consistency */
min-width: 180px;
/* Ensure it doesn't shrink */
flex-shrink: 0;
}
.category-header {
grid-column: 1 / -1;
padding: 0.5rem 0;
color: #a0aec0;
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid #4a5568;
margin: 0.5rem 0;
}
.item-actions-section.bottom-right {
/* Deprecated class, keeping for safety but resetting styles if needed */
margin-top: 0;
align-self: center;
}
.action-btn {
padding: 0.4rem 0.8rem;
border: none;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.action-btn.use {
background: rgba(72, 187, 120, 0.2);
color: #48bb78;
border: 1px solid rgba(72, 187, 120, 0.4);
}
.action-btn.use:hover {
background: rgba(72, 187, 120, 0.3);
transform: translateY(-1px);
}
.action-btn.equip {
background: rgba(66, 153, 225, 0.2);
color: #4299e1;
border: 1px solid rgba(66, 153, 225, 0.4);
}
.action-btn.equip:hover {
background: rgba(66, 153, 225, 0.3);
transform: translateY(-1px);
}
.action-btn.unequip {
background: rgba(237, 137, 54, 0.2);
color: #ed8936;
border: 1px solid rgba(237, 137, 54, 0.4);
}
.action-btn.unequip:hover {
background: rgba(237, 137, 54, 0.3);
transform: translateY(-1px);
}
.drop-actions-group {
display: flex;
gap: 2px;
background: rgba(0, 0, 0, 0.2);
padding: 2px;
border-radius: 6px;
border: 1px solid rgba(245, 101, 101, 0.3);
}
.action-btn.drop {
background: transparent;
color: #f56565;
border: none;
padding: 0.3rem 0.6rem;
font-size: 0.75rem;
border-radius: 4px;
}
.action-btn.drop:hover {
background: rgba(245, 101, 101, 0.2);
}
.action-btn.drop.single {
/* Style for single drop button */
padding: 0.4rem 0.8rem;
font-size: 0.85rem;
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #718096;
gap: 1rem;
}
.empty-icon {
font-size: 3rem;
opacity: 0.5;
}
.item-card-equipped {
font-size: 0.7rem;
padding: 2px 6px;
border-radius: 4px;
background: rgba(66, 153, 225, 0.2);
color: #63b3ed;
border: 1px solid rgba(66, 153, 225, 0.4);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.item-stats-grid {
display: grid;
grid-template-columns: repeat(2, auto);
gap: 0.5rem 1rem;
align-items: center;
}

View File

@@ -0,0 +1,402 @@
import { MouseEvent, ChangeEvent } from 'react'
import { PlayerState, Profile, Equipment } from './types'
import './InventoryModal.css'
interface InventoryModalProps {
playerState: PlayerState
profile: Profile
equipment?: Equipment
inventoryFilter: string
inventoryCategoryFilter: string
onClose: () => void
onSetInventoryFilter: (filter: string) => void
onSetInventoryCategoryFilter: (category: string) => void
onUseItem: (itemId: number, invId: number) => void
onEquipItem: (invId: number) => void
onUnequipItem: (slot: string) => void
onDropItem: (itemId: number, invId: number, quantity: number) => void
}
function InventoryModal({
playerState,
profile,
equipment,
inventoryFilter,
inventoryCategoryFilter,
onClose,
onSetInventoryFilter,
onSetInventoryCategoryFilter,
onUseItem,
onEquipItem,
onUnequipItem,
onDropItem
}: InventoryModalProps) {
// Categories for the sidebar
const categories = [
{ id: 'all', label: 'All Items', icon: '🎒' },
{ id: 'weapon', label: 'Weapons', icon: '⚔️' },
{ id: 'armor', label: 'Armor', icon: '🛡️' },
{ id: 'clothing', label: 'Clothing', icon: '👕' },
{ id: 'backpack', label: 'Backpacks', icon: '🎒' },
{ id: 'tool', label: 'Tools', icon: '🛠️' },
{ id: 'consumable', label: 'Consumables', icon: '🍖' },
{ id: 'resource', label: 'Resources', icon: '📦' },
{ id: 'quest', label: 'Quest', icon: '📜' },
{ id: 'misc', label: 'Misc', icon: '📦' }
]
// Use inventory directly as it now includes equipped items
const allItems = playerState.inventory;
// Filter items based on search and category
const filteredItems = allItems
.filter((item: any) => {
const itemName = item.name || 'Unknown Item';
const matchesSearch = itemName.toLowerCase().includes(inventoryFilter.toLowerCase())
const matchesCategory = inventoryCategoryFilter === 'all' || item.type === inventoryCategoryFilter
return matchesSearch && matchesCategory
})
.sort((a: any, b: any) => {
// Equipped items first
if (a.is_equipped && !b.is_equipped) return -1;
if (!a.is_equipped && b.is_equipped) return 1;
return (a.name || '').localeCompare(b.name || '');
})
const renderItemCard = (item: any, i: number) => {
const maxDurability = item.max_durability;
const currentDurability = item.durability;
const hasDurability = maxDurability && maxDurability > 0;
return (
<div key={i} className={`inventory-item-card compact ${item.is_equipped ? 'equipped' : ''}`}>
{/* Left: Image/Icon */}
<div className="item-image-section small">
{item.image_path ? (
<img
src={item.image_path}
alt={item.name}
className="item-img-thumb"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
const icon = (e.target as HTMLImageElement).nextElementSibling;
if (icon) icon.classList.remove('hidden');
}}
/>
) : null}
<div className={`item-icon-large ${item.tier ? `tier-${item.tier}` : ''} ${item.image_path ? 'hidden' : ''}`}>
{item.emoji || '📦'}
</div>
{item.quantity > 1 && <div className="item-quantity-badge">x{item.quantity}</div>}
</div>
{/* Center: Info & Stats */}
<div className="item-info-section">
<div className="item-header-compact">
<span className="item-emoji-inline">{item.emoji}</span>
<h4 className={`item-name-compact text-tier-${item.tier || 0}`}>{item.name}</h4>
{item.is_equipped && <span className="item-card-equipped">Equipped</span>}
</div>
<div className="item-stats-row">
{/* Fixed Weight/Volume Column */}
<div className="stat-group-fixed">
<div className="stat-row-compact">
<span></span>
<span>{item.weight}kg</span>
{item.quantity > 1 && <span className="text-muted">| {(item.weight * item.quantity).toFixed(1)}kg</span>}
</div>
<div className="stat-row-compact">
<span>📦</span>
<span>{item.volume}L</span>
{item.quantity > 1 && <span className="text-muted">| {(item.volume * item.quantity).toFixed(1)}L</span>}
</div>
</div>
{/* Stats & Durability */}
<div className="stats-durability-column">
{item.description && <p className="item-description-compact">{item.description}</p>}
{/* Stats Row - Button-like Badges */}
<div className="stat-badges-container">
{/* Capacity */}
{(item.unique_stats?.weight_capacity || item.stats?.weight_capacity) && (
<span className="stat-badge capacity">
+{item.unique_stats?.weight_capacity || item.stats?.weight_capacity}kg
</span>
)}
{(item.unique_stats?.volume_capacity || item.stats?.volume_capacity) && (
<span className="stat-badge capacity">
📦 +{item.unique_stats?.volume_capacity || item.stats?.volume_capacity}L
</span>
)}
{/* Combat */}
{(item.unique_stats?.damage_min || item.stats?.damage_min) && (
<span className="stat-badge damage">
{item.unique_stats?.damage_min || item.stats?.damage_min}-{item.unique_stats?.damage_max || item.stats?.damage_max}
</span>
)}
{(item.unique_stats?.armor || item.stats?.armor) && (
<span className="stat-badge armor">
🛡 +{item.unique_stats?.armor || item.stats?.armor}
</span>
)}
{(item.unique_stats?.armor_penetration || item.stats?.armor_penetration) && (
<span className="stat-badge penetration">
💔 +{item.unique_stats?.armor_penetration || item.stats?.armor_penetration} Pen
</span>
)}
{(item.unique_stats?.crit_chance || item.stats?.crit_chance) && (
<span className="stat-badge crit">
🎯 +{Math.round((item.unique_stats?.crit_chance || item.stats?.crit_chance) * 100)}% Crit
</span>
)}
{(item.unique_stats?.accuracy || item.stats?.accuracy) && (
<span className="stat-badge accuracy">
👁 +{Math.round((item.unique_stats?.accuracy || item.stats?.accuracy) * 100)}% Acc
</span>
)}
{(item.unique_stats?.dodge_chance || item.stats?.dodge_chance) && (
<span className="stat-badge dodge">
💨 +{Math.round((item.unique_stats?.dodge_chance || item.stats?.dodge_chance) * 100)}% Dodge
</span>
)}
{(item.unique_stats?.lifesteal || item.stats?.lifesteal) && (
<span className="stat-badge lifesteal">
🧛 +{Math.round((item.unique_stats?.lifesteal || item.stats?.lifesteal) * 100)}% Life
</span>
)}
{/* Attributes */}
{(item.unique_stats?.strength_bonus || item.stats?.strength_bonus) && (
<span className="stat-badge strength">
💪 +{item.unique_stats?.strength_bonus || item.stats?.strength_bonus} STR
</span>
)}
{(item.unique_stats?.agility_bonus || item.stats?.agility_bonus) && (
<span className="stat-badge agility">
🏃 +{item.unique_stats?.agility_bonus || item.stats?.agility_bonus} AGI
</span>
)}
{(item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus) && (
<span className="stat-badge endurance">
🏋 +{item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus} END
</span>
)}
{(item.unique_stats?.hp_bonus || item.stats?.hp_bonus) && (
<span className="stat-badge health">
+{item.unique_stats?.hp_bonus || item.stats?.hp_bonus} HP max
</span>
)}
{(item.unique_stats?.stamina_bonus || item.stats?.stamina_bonus) && (
<span className="stat-badge stamina">
+{item.unique_stats?.stamina_bonus || item.stats?.stamina_bonus} Stm max
</span>
)}
{/* Consumables */}
{item.hp_restore && (
<span className="stat-badge health">
+{item.hp_restore} HP
</span>
)}
{item.stamina_restore && (
<span className="stat-badge stamina">
+{item.stamina_restore} Stm
</span>
)}
</div>
{/* Durability Bar */}
{hasDurability && (
<div className="durability-container">
<div className="durability-header">
<span>Durability</span>
<span className={
currentDurability < maxDurability * 0.2
? "durability-text-low"
: ""
}>
{currentDurability} / {maxDurability}
</span>
</div>
<div className="durability-track">
<div
className={`durability-fill ${currentDurability < maxDurability * 0.2
? "low"
: currentDurability < maxDurability * 0.5
? "medium"
: "high"
}`}
style={{
width: `${Math.min(100, Math.max(0, (currentDurability / maxDurability) * 100))}%`
}}
/>
</div>
</div>
)}
</div>
{/* Right: Actions */}
<div className="item-actions-section">
{item.consumable && (
<button className="action-btn use" onClick={() => onUseItem(item.item_id, item.id)}>Use</button>
)}
{item.equippable && !item.is_equipped && (
<button className="action-btn equip" onClick={() => onEquipItem(item.id)}>Equip</button>
)}
{item.is_equipped && (
<button className="action-btn unequip" onClick={() => onUnequipItem(item.slot)}>Unequip</button>
)}
<div className="drop-actions-group">
{item.quantity > 1 && (
<button className="action-btn drop" onClick={() => onDropItem(item.item_id, item.id, 1)}>x1</button>
)}
{item.quantity >= 5 && (
<button className="action-btn drop" onClick={() => onDropItem(item.item_id, item.id, 5)}>x5</button>
)}
{item.quantity >= 10 && (
<button className="action-btn drop" onClick={() => onDropItem(item.item_id, item.id, 10)}>x10</button>
)}
<button className={`action-btn drop ${item.quantity === 1 ? 'single' : ''}`} onClick={() => onDropItem(item.item_id, item.id, item.quantity === 1 ? 1 : item.quantity)}>
{item.quantity === 1 ? 'Drop' : 'All'}
</button>
</div>
</div>
</div>
</div>
</div>
)
}
return (
<div className="workbench-overlay" onClick={(e: MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) onClose()
}}>
<div className="workbench-menu inventory-modal-redesign">
{/* Top Bar: Capacity & Backpack Info */}
<div className="inventory-top-bar">
<div className="inventory-capacity-summary">
<div className="capacity-metric">
<span className="metric-icon"></span>
<div className="metric-bar-container">
<div className="metric-text">
Weight: {(profile.current_weight || 0).toFixed(1)} / {profile.max_weight || 0} kg
</div>
<div className="metric-bar">
<div
className="metric-fill weight"
style={{ width: `${Math.min(100, ((profile.current_weight || 0) / (profile.max_weight || 1)) * 100)}%` }}
></div>
</div>
</div>
</div>
<div className="capacity-metric">
<span className="metric-icon">📦</span>
<div className="metric-bar-container">
<div className="metric-text">
Volume: {(profile.current_volume || 0).toFixed(1)} / {profile.max_volume || 0} L
</div>
<div className="metric-bar">
<div
className="metric-fill volume"
style={{ width: `${Math.min(100, ((profile.current_volume || 0) / (profile.max_volume || 1)) * 100)}%` }}
></div>
</div>
</div>
</div>
</div>
<div className="inventory-backpack-info">
{equipment?.backpack ? (
<div className="backpack-status active">
<span className="backpack-icon">🎒</span>
<span className="backpack-name">{equipment.backpack.name}</span>
<span className="backpack-stats">
(+{equipment.backpack.unique_stats?.weight_capacity || equipment.backpack.stats?.weight_capacity || 0}kg /
+{equipment.backpack.unique_stats?.volume_capacity || equipment.backpack.stats?.volume_capacity || 0}L)
</span>
</div>
) : (
<div className="backpack-status inactive">
<span className="backpack-icon">🚫</span>
<span>No Backpack Equipped</span>
</div>
)}
<button className="close-btn" onClick={onClose}></button>
</div>
</div>
<div className="inventory-main-layout">
{/* Left Sidebar: Categories */}
<div className="inventory-sidebar-filters">
{categories.map(cat => (
<button
key={cat.id}
className={`category-btn ${inventoryCategoryFilter === cat.id ? 'active' : ''}`}
onClick={() => onSetInventoryCategoryFilter(cat.id)}
>
<span className="cat-icon">{cat.icon}</span>
<span className="cat-label">{cat.label}</span>
</button>
))}
</div>
{/* Right Content: Search & List */}
<div className="inventory-content-area">
<div className="inventory-search-bar">
<span className="search-icon">🔍</span>
<input
type="text"
placeholder="Search items..."
value={inventoryFilter}
onChange={(e: ChangeEvent<HTMLInputElement>) => onSetInventoryFilter(e.target.value)}
/>
</div>
<div className="inventory-items-grid">
{filteredItems.length === 0 ? (
<div className="empty-state">
<span className="empty-icon">📦</span>
<p>No items found in this category</p>
</div>
) : (
inventoryCategoryFilter === 'all' ? (
<>
{/* Equipped */}
{filteredItems.some((i: any) => i.is_equipped) && (
<>
<div className="category-header"> Equipped</div>
{filteredItems.filter((i: any) => i.is_equipped).map((item: any, i: number) => renderItemCard(item, i))}
</>
)}
{/* Categories */}
{categories.filter(c => c.id !== 'all').map(cat => {
const categoryItems = filteredItems.filter((i: any) => !i.is_equipped && i.type === cat.id);
if (categoryItems.length === 0) return null;
return (
<div key={cat.id}>
<div className="category-header">{cat.icon} {cat.label}</div>
{categoryItems.map((item: any, i: number) => renderItemCard(item, i))}
</div>
);
})}
</>
) : (
filteredItems.map((item: any, i: number) => renderItemCard(item, i))
)
)}
</div>
</div>
</div>
</div >
</div >
)
}
export default InventoryModal

View File

@@ -0,0 +1,453 @@
import type { Location, PlayerState, CombatState, Profile, WorkbenchTab } from './types'
import Workbench from './Workbench'
interface LocationViewProps {
location: Location
playerState: PlayerState | null
combatState: CombatState | null
message: string
locationMessages: Array<{ time: string; message: string }>
expandedCorpse: string | null
corpseDetails: any
mobileMenuOpen: string
showCraftingMenu: boolean
showRepairMenu: boolean
workbenchTab: WorkbenchTab
craftableItems: any[]
repairableItems: any[]
uncraftableItems: any[]
craftFilter: string
repairFilter: string
uncraftFilter: string
craftCategoryFilter: string
profile: Profile | null
onSetMessage: (msg: string) => void
onInitiateCombat: (npcId: number) => void
onInitiatePvP: (playerId: number) => void
onPickup: (itemId: number, quantity: number) => void
onLootCorpse: (corpseId: string) => void
onLootCorpseItem: (corpseId: string, itemIndex: number | null) => void
onSetExpandedCorpse: (corpseId: string | null) => void
onOpenCrafting?: () => void
onOpenRepair?: () => void
onCloseCrafting: () => void
onSwitchWorkbenchTab: (tab: WorkbenchTab) => void
onSetCraftFilter: (filter: string) => void
onSetRepairFilter: (filter: string) => void
onSetUncraftFilter: (filter: string) => void
onSetCraftCategoryFilter: (category: string) => void
onCraft: (itemId: number) => void
onRepair: (uniqueItemId: string, inventoryId: number) => void
onUncraft: (uniqueItemId: string, inventoryId: number) => void
}
function LocationView({
location,
message,
locationMessages,
expandedCorpse,
corpseDetails,
mobileMenuOpen,
showCraftingMenu,
showRepairMenu,
workbenchTab,
craftableItems,
repairableItems,
uncraftableItems,
craftFilter,
repairFilter,
uncraftFilter,
craftCategoryFilter,
profile,
onSetMessage,
onInitiateCombat,
onInitiatePvP,
onPickup,
onLootCorpse,
onLootCorpseItem,
onSetExpandedCorpse,
onOpenCrafting,
onOpenRepair,
onCloseCrafting,
onSwitchWorkbenchTab,
onSetCraftFilter,
onSetRepairFilter,
onSetUncraftFilter,
onSetCraftCategoryFilter,
onCraft,
onRepair,
onUncraft
}: LocationViewProps) {
return (
<div className="location-view">
<div className="location-info">
<h2 className="centered-heading">
{location.name}
{location.danger_level !== undefined && location.danger_level === 0 && (
<span className="danger-badge danger-safe" title="Safe Zone"> Safe</span>
)}
{location.danger_level !== undefined && location.danger_level > 0 && (
<span className={`danger-badge danger-${location.danger_level}`} title={`Danger Level: ${location.danger_level}`}>
{location.danger_level}
</span>
)}
</h2>
{location.tags && location.tags.length > 0 && (
<div className="location-tags">
{location.tags.map((tag: string, i: number) => {
const isClickable = tag === 'workbench' || tag === 'repair_station'
const handleClick = () => {
if (tag === 'workbench' && onOpenCrafting) onOpenCrafting()
else if (tag === 'repair_station' && onOpenRepair) onOpenRepair()
}
return (
<span
key={i}
className={`location-tag tag-${tag} ${isClickable ? 'clickable' : ''}`}
title={isClickable ? `Click to ${tag === 'workbench' ? 'craft items' : 'repair items'}` : `This location has: ${tag}`}
onClick={isClickable ? handleClick : undefined}
style={isClickable ? { cursor: 'pointer' } : undefined}
>
{tag === 'workbench' && '🔧 Workbench'}
{tag === 'repair_station' && '🛠️ Repair Station'}
{tag === 'safe_zone' && '🛡️ Safe Zone'}
{tag === 'shop' && '🏪 Shop'}
{tag === 'shelter' && '🏠 Shelter'}
{tag === 'medical' && '⚕️ Medical'}
{tag === 'storage' && '📦 Storage'}
{tag === 'water_source' && '💧 Water'}
{tag === 'food_source' && '🍎 Food'}
{tag !== 'workbench' && tag !== 'repair_station' && tag !== 'safe_zone' && tag !== 'shop' && tag !== 'shelter' && tag !== 'medical' && tag !== 'storage' && tag !== 'water_source' && tag !== 'food_source' && `🏷️ ${tag}`}
</span>
)
})}
</div>
)}
{location.image_url && (
<div className="location-image-container">
<img
src={location.image_url}
alt={location.name}
className="location-image"
onError={(e: any) => (e.currentTarget.style.display = 'none')}
/>
</div>
)}
<div className="location-description-box">
<p className="location-description">{location.description}</p>
</div>
</div>
{message && (
<div className="message-box" onClick={() => onSetMessage('')}>
{message}
</div>
)}
{locationMessages.length > 0 && (
<div className="location-messages-log">
<h4>📜 Recent Activity</h4>
<div className="messages-scroll">
{locationMessages.slice(-10).reverse().map((msg, idx) => (
<div key={idx} className="location-message-item">
<span className="message-time">{msg.time}</span>
<span className="message-text">{msg.message}</span>
</div>
))}
</div>
</div>
)}
<div className={`ground-entities mobile-menu-panel bottom ${mobileMenuOpen === 'bottom' ? 'open' : ''}`}>
{/* Enemies */}
{location.npcs.filter((npc: any) => npc.type === 'enemy').length > 0 && (
<div className="entity-section enemies-section">
<h3> Enemies</h3>
<div className="entity-list">
{location.npcs.filter((npc: any) => npc.type === 'enemy').map((enemy: any, i: number) => (
<div key={i} className="entity-card enemy-card">
{enemy.id && (
<div className="entity-image">
<img
src={enemy.image_path ? `/${enemy.image_path}` : `/images/npcs/${enemy.name.toLowerCase().replace(/ /g, '_')}.webp`}
alt={enemy.name}
onError={(e: any) => { e.currentTarget.style.display = 'none' }}
/>
</div>
)}
<div className="entity-info">
<div className="entity-name enemy-name">{enemy.name}</div>
{enemy.level && <div className="entity-level">Lv. {enemy.level}</div>}
</div>
<button
className="entity-action-btn combat-btn"
onClick={() => onInitiateCombat(enemy.id)}
>
Fight
</button>
</div>
))}
</div>
</div>
)}
{/* Corpses */}
{location.corpses && location.corpses.length > 0 && (
<div className="entity-section corpses-section">
<h3>💀 Corpses</h3>
<div className="entity-list">
{location.corpses.map((corpse: any) => (
<div key={corpse.id} className="corpse-container">
<div className="entity-card corpse-card">
<div className="entity-info">
<div className="entity-name">{corpse.emoji} {corpse.name}</div>
<div className="corpse-loot-count">{corpse.loot_count} item(s)</div>
</div>
<button
className="entity-action-btn loot-btn"
onClick={() => onLootCorpse(String(corpse.id))}
disabled={corpse.loot_count === 0}
>
🔍 Examine
</button>
</div>
{expandedCorpse === String(corpse.id) && corpseDetails && corpseDetails.loot_items && (
<div className="corpse-details">
<div className="corpse-details-header">
<h4>Lootable Items:</h4>
<button
className="close-btn"
onClick={() => {
onSetExpandedCorpse(null)
}}
>
</button>
</div>
<div className="corpse-items-list">
{corpseDetails.loot_items.map((item: any) => (
<div key={item.index} className={`corpse-item ${!item.can_loot ? 'locked' : ''}`}>
<div className="corpse-item-info">
<div className="corpse-item-name">
{item.emoji} {item.item_name}
</div>
<div className="corpse-item-qty">
Qty: {item.quantity_min}{item.quantity_min !== item.quantity_max ? `-${item.quantity_max}` : ''}
</div>
{item.required_tool && (
<div className={`corpse-item-tool ${item.has_tool ? 'has-tool' : 'needs-tool'}`}>
🔧 {item.required_tool_name} {item.has_tool ? '✓' : '✗'}
</div>
)}
</div>
<button
className="corpse-item-loot-btn"
onClick={() => onLootCorpseItem(String(corpse.id), item.index)}
disabled={!item.can_loot}
title={!item.can_loot ? `Requires ${item.required_tool_name}` : 'Loot this item'}
>
{item.can_loot ? '📦 Loot' : '🔒'}
</button>
</div>
))}
</div>
<button
className="loot-all-btn"
onClick={() => onLootCorpseItem(String(corpse.id), null)}
>
📦 Loot All Available
</button>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Friendly NPCs */}
{location.npcs.filter((npc: any) => npc.type !== 'enemy').length > 0 && (
<div className="entity-section npcs-section">
<h3>👥 NPCs</h3>
<div className="entity-list">
{location.npcs.filter((npc: any) => npc.type !== 'enemy').map((npc: any, i: number) => (
<div key={i} className="entity-card npc-card">
<span className="entity-icon">🧑</span>
<div className="entity-info">
<div className="entity-name">{npc.name}</div>
{npc.level && <div className="entity-level">Lv. {npc.level}</div>}
</div>
<button className="entity-action-btn">Talk</button>
</div>
))}
</div>
</div>
)}
{/* Items on Ground */}
{location.items.length > 0 && (
<div className="entity-section items-section">
<h3>📦 Items on Ground</h3>
<div className="entity-list">
{location.items.map((item: any, i: number) => (
<div key={i} className="entity-card item-card">
{item.image_path ? (
<img
src={item.image_path}
alt={item.name}
className="entity-icon"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
const icon = (e.target as HTMLImageElement).nextElementSibling;
if (icon) icon.classList.remove('hidden');
}}
/>
) : null}
<span className={`entity-icon ${item.image_path ? 'hidden' : ''}`}>{item.emoji || '📦'}</span>
<div className="entity-info">
<div className={`entity-name ${item.tier ? `tier-${item.tier}` : ''}`}>
{item.name || 'Unknown Item'}
</div>
{item.quantity > 1 && <div className="entity-quantity">×{item.quantity}</div>}
</div>
<div className="item-info-btn-container">
<button className="entity-action-btn info" title="Item Info">Info</button>
<div className="item-info-tooltip">
{item.description && <div className="item-tooltip-desc">{item.description}</div>}
{item.weight !== undefined && item.weight > 0 && (
<div className="item-tooltip-stat">
Weight: {item.weight}kg {item.quantity > 1 && `(Total: ${(item.weight * item.quantity).toFixed(2)}kg)`}
</div>
)}
{item.volume !== undefined && item.volume > 0 && (
<div className="item-tooltip-stat">
📦 Volume: {item.volume}L {item.quantity > 1 && `(Total: ${(item.volume * item.quantity).toFixed(2)}L)`}
</div>
)}
{item.hp_restore && item.hp_restore > 0 && (
<div className="item-tooltip-stat"> HP Restore: +{item.hp_restore}</div>
)}
{item.stamina_restore && item.stamina_restore > 0 && (
<div className="item-tooltip-stat"> Stamina Restore: +{item.stamina_restore}</div>
)}
{item.damage_min !== undefined && item.damage_max !== undefined && (item.damage_min > 0 || item.damage_max > 0) && (
<div className="item-tooltip-stat">
Damage: {item.damage_min}-{item.damage_max}
</div>
)}
{item.durability !== undefined && item.durability !== null && item.max_durability !== undefined && item.max_durability !== null && (
<div className="item-tooltip-stat">
🔧 Durability: {item.durability}/{item.max_durability}
</div>
)}
{item.tier !== undefined && item.tier !== null && item.tier > 0 && (
<div className="item-tooltip-stat"> Tier: {item.tier}</div>
)}
</div>
</div>
{item.quantity === 1 ? (
<button
className="entity-action-btn pickup"
onClick={() => onPickup(item.id, 1)}
>
Pick Up
</button>
) : (
<div className="item-pickup-btn-container">
<button className="entity-action-btn pickup">Pick Up </button>
<div className="item-pickup-menu">
<button className="item-pickup-option" onClick={() => onPickup(item.id, 1)}>Pick Up 1</button>
{item.quantity >= 5 && (
<button className="item-pickup-option" onClick={() => onPickup(item.id, 5)}>Pick Up 5</button>
)}
{item.quantity >= 10 && (
<button className="item-pickup-option" onClick={() => onPickup(item.id, 10)}>Pick Up 10</button>
)}
<button className="item-pickup-option" onClick={() => onPickup(item.id, item.quantity)}>
Pick Up All ({item.quantity})
</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Other Players */}
{location.other_players && location.other_players.length > 0 && (
<div className="entity-section players-section">
<h3>👥 Other Players</h3>
<div className="entity-list">
{location.other_players.map((player: any, i: number) => (
<div key={i} className="entity-card player-card">
<span className="entity-icon">🧍</span>
<div className="entity-info">
<div className="entity-name">{player.name || player.username}</div>
<div className="entity-level">Lv. {player.level}</div>
{player.level_diff !== undefined && (
<div className="level-diff">
{player.level_diff > 0 ? `+${player.level_diff}` : player.level_diff} levels
</div>
)}
</div>
{player.can_pvp && (
<button
className="pvp-btn"
onClick={() => onInitiatePvP(player.id)}
title={`Attack ${player.name || player.username}`}
>
Attack
</button>
)}
{!player.can_pvp && player.level_diff !== undefined && Math.abs(player.level_diff) > 3 && (
<div className="pvp-disabled-reason">Level difference too high</div>
)}
{!player.can_pvp && location.danger_level !== undefined && location.danger_level < 3 && (
<div className="pvp-disabled-reason">Area too safe for PvP</div>
)}
</div>
))}
</div>
</div>
)}
</div>
{(showCraftingMenu || showRepairMenu) && (
<Workbench
showCraftingMenu={showCraftingMenu}
showRepairMenu={showRepairMenu}
workbenchTab={workbenchTab}
craftableItems={craftableItems}
repairableItems={repairableItems}
uncraftableItems={uncraftableItems}
craftFilter={craftFilter}
repairFilter={repairFilter}
uncraftFilter={uncraftFilter}
craftCategoryFilter={craftCategoryFilter}
profile={profile}
onCloseCrafting={onCloseCrafting}
onSwitchTab={onSwitchWorkbenchTab}
onSetCraftFilter={onSetCraftFilter}
onSetRepairFilter={onSetRepairFilter}
onSetUncraftFilter={onSetUncraftFilter}
onSetCraftCategoryFilter={onSetCraftCategoryFilter}
onCraft={onCraft}
onRepair={onRepair}
onUncraft={onUncraft}
/>
)}
</div>
)
}
export default LocationView

View File

@@ -0,0 +1,255 @@
import type { Location, Profile, CombatState } from './types'
import { useState, useEffect } from 'react'
interface MovementControlsProps {
location: Location
profile: Profile
combatState: CombatState | null
movementCooldown: number
interactableCooldowns: Record<string, number>
onMove: (direction: string) => void
onInteract?: (interactableId: string, actionId: string) => void
}
function MovementControls({
location,
profile,
combatState,
movementCooldown,
interactableCooldowns,
onMove,
onInteract
}: MovementControlsProps) {
// Force re-render every second to update cooldown timers
const [, forceUpdate] = useState(0)
useEffect(() => {
const timer = setInterval(() => {
forceUpdate(prev => prev + 1)
}, 1000)
return () => clearInterval(timer)
}, [])
// Helper function to get direction details
const getDirectionDetail = (direction: string) => {
if (!location.directions_detailed) return null
return location.directions_detailed.find(d => d.direction === direction)
}
// Helper function to get stamina cost for a direction
const getStaminaCost = (direction: string): number => {
const detail = getDirectionDetail(direction)
return detail ? detail.stamina_cost : 5
}
// Helper function to get destination name for a direction
const getDestinationName = (direction: string): string => {
const detail = getDirectionDetail(direction)
return detail ? (detail.destination_name || detail.destination) : ''
}
// Helper function to get distance for a direction
const getDistance = (direction: string): number => {
const detail = getDirectionDetail(direction)
return detail ? detail.distance : 0
}
// Helper function to check if direction is available
const hasDirection = (direction: string): boolean => {
return location.directions.includes(direction)
}
// Helper function to render compass button
const renderCompassButton = (direction: string, arrow: string, className: string) => {
const available = hasDirection(direction)
const stamina = getStaminaCost(direction)
const destination = getDestinationName(direction)
const distance = getDistance(direction)
const insufficientStamina = profile ? profile.stamina < stamina : false
const disabled = !available || !!combatState || movementCooldown > 0 || insufficientStamina || (profile?.is_dead ?? false)
// Build detailed tooltip text
const tooltipText = profile?.is_dead ? 'You are dead' :
movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` :
combatState ? 'Cannot travel during combat' :
insufficientStamina ? `Not enough stamina (need ${stamina}, have ${profile?.stamina ?? 0})` :
available ? `${destination}\nDistance: ${distance}m\nStamina: ${stamina}` :
`Cannot go ${direction}`
return (
<button
onClick={() => onMove(direction)}
disabled={disabled}
className={`compass-btn ${className} ${disabled ? 'disabled' : ''}`}
title={tooltipText}
>
<span className="compass-arrow">{arrow}</span>
{available && movementCooldown > 0 ? (
<span className="compass-cost">{movementCooldown}s</span>
) : available && (
<span className="compass-cost">{stamina}</span>
)}
</button>
)
}
return (
<>
<div className="movement-controls">
<h3>🧭 Travel</h3>
<div className="compass-grid">
{/* Top row */}
{renderCompassButton('northwest', '↖️', 'nw')}
{renderCompassButton('north', '⬆️', 'n')}
{renderCompassButton('northeast', '↗️', 'ne')}
{/* Middle row */}
{renderCompassButton('west', '⬅️', 'w')}
<div className="compass-center">
<div className="compass-icon">🧭</div>
</div>
{renderCompassButton('east', '➡️', 'e')}
{/* Bottom row */}
{renderCompassButton('southwest', '↙️', 'sw')}
{renderCompassButton('south', '⬇️', 's')}
{renderCompassButton('southeast', '↘️', 'se')}
</div>
{/* Cooldown indicator */}
{movementCooldown > 0 && (
<div className="cooldown-indicator">
Wait {movementCooldown}s before moving
</div>
)}
{/* Special movements */}
<div className="special-moves">
{location.directions.includes('up') && (
<button
onClick={() => onMove('up')}
className="special-btn"
disabled={!!combatState || movementCooldown > 0}
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Go up\nStamina: ${getStaminaCost('up')}`}
>
Up <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('up')}`}</span>
</button>
)}
{location.directions.includes('down') && (
<button
onClick={() => onMove('down')}
className="special-btn"
disabled={!!combatState || movementCooldown > 0}
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Go down\nStamina: ${getStaminaCost('down')}`}
>
Down <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('down')}`}</span>
</button>
)}
{location.directions.includes('enter') && (
<button
onClick={() => onMove('enter')}
className="special-btn"
disabled={!!combatState || movementCooldown > 0}
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Enter\nStamina: ${getStaminaCost('enter')}`}
>
🚪 Enter <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('enter')}`}</span>
</button>
)}
{location.directions.includes('inside') && (
<button
onClick={() => onMove('inside')}
className="special-btn"
disabled={!!combatState || movementCooldown > 0}
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Go inside\nStamina: ${getStaminaCost('inside')}`}
>
🚪 Inside <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('inside')}`}</span>
</button>
)}
{location.directions.includes('exit') && (
<button
onClick={() => onMove('exit')}
className="special-btn"
disabled={!!combatState || movementCooldown > 0}
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : 'Exit'}
>
🚪 Exit
</button>
)}
{location.directions.includes('outside') && (
<button
onClick={() => onMove('outside')}
className="special-btn"
disabled={!!combatState || movementCooldown > 0}
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Go outside\nStamina: ${getStaminaCost('outside')}`}
>
🚪 Outside <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('outside')}`}</span>
</button>
)}
</div>
</div>
{/* Surroundings - outside movement controls */}
{location.interactables && location.interactables.length > 0 && (
<div className="interactables-section">
<h3>🌿 Surroundings</h3>
{location.interactables.map((interactable: any) => (
<div key={interactable.instance_id} className="interactable-card">
{interactable.image_path && (
<div className="interactable-image-container">
<img
src={`/${interactable.image_path}`}
alt={interactable.name}
className="interactable-image"
onError={(e: any) => {
e.currentTarget.style.display = 'none'
}}
/>
</div>
)}
<div className="interactable-content">
<div className="interactable-header">
<span className="interactable-name">{interactable.name}</span>
</div>
{interactable.actions && interactable.actions.length > 0 && (
<div className="interactable-actions">
{interactable.actions.map((action: any) => {
const cooldownKey = `${interactable.instance_id}:${action.id}`
const cooldownExpiry = interactableCooldowns[cooldownKey]
const now = Date.now() / 1000
const cooldownRemaining = cooldownExpiry && cooldownExpiry > now
? Math.ceil(cooldownExpiry - now)
: 0
return (
<button
key={action.id}
className="interact-btn"
disabled={!!combatState || cooldownRemaining > 0}
onClick={() => onInteract && onInteract(interactable.instance_id, action.id)}
title={
combatState
? 'Cannot interact during combat'
: cooldownRemaining > 0
? `Wait ${cooldownRemaining}s`
: action.description
}
>
{action.name}
<span className="stamina-cost">
{cooldownRemaining > 0 ? `${cooldownRemaining}s` : `${action.stamina_cost}`}
</span>
</button>
)
})}
</div>
)}
</div>
</div>
))}
</div>
)}
</>
)
}
export default MovementControls

View File

@@ -0,0 +1,327 @@
import { useState } from 'react'
import type { PlayerState, Profile, Equipment } from './types'
import InventoryModal from './InventoryModal'
interface PlayerSidebarProps {
playerState: PlayerState
profile: Profile | null
equipment: Equipment
inventoryFilter: string
inventoryCategoryFilter: string
mobileMenuOpen: string
onSetInventoryFilter: (filter: string) => void
onSetInventoryCategoryFilter: (category: string) => void
onUseItem: (itemId: number, invId: number) => void
onEquipItem: (invId: number) => void
onUnequipItem: (slot: string) => void
onDropItem: (itemId: number, invId: number, quantity: number) => void
onSpendPoint: (stat: string) => void
}
function PlayerSidebar({
playerState,
profile,
equipment,
inventoryFilter,
inventoryCategoryFilter,
mobileMenuOpen,
onSetInventoryFilter,
onSetInventoryCategoryFilter,
onUseItem,
onEquipItem,
onUnequipItem,
onDropItem,
onSpendPoint
}: PlayerSidebarProps) {
const [showInventory, setShowInventory] = useState(false)
const renderEquipmentSlot = (slot: string, item: any, emoji: string, label: string) => (
<div className={`equipment-slot ${item ? 'filled' : 'empty'}`}>
{item ? (
<>
<button className="equipment-unequip-btn" onClick={() => onUnequipItem(slot)} title="Unequip"></button>
<div className="equipment-item-content">
{item.image_path ? (
<img
src={item.image_path}
alt={item.name}
className="equipment-emoji"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
const icon = (e.target as HTMLImageElement).nextElementSibling;
if (icon) icon.classList.remove('hidden');
}}
/>
) : null}
<span className={`equipment-emoji ${item.image_path ? 'hidden' : ''}`}>{item.emoji}</span>
<span className={`equipment-name ${item.tier ? `tier-${item.tier}` : ''}`}>{item.name}</span>
{item.durability && item.durability !== null && (
<span className="equipment-durability">{item.durability}/{item.max_durability}</span>
)}
</div>
<div className="equipment-tooltip">
{item.description && <div className="item-tooltip-desc">{item.description}</div>}
{/* Use unique_stats if available, otherwise fall back to base stats */}
{(item.unique_stats || item.stats) && Object.keys(item.unique_stats || item.stats).length > 0 && (
<>
{(item.unique_stats?.armor || item.stats?.armor) && (
<div className="item-tooltip-stat">
🛡 Armor: +{item.unique_stats?.armor || item.stats?.armor}
</div>
)}
{(item.unique_stats?.hp_max || item.stats?.hp_max) && (
<div className="item-tooltip-stat">
Max HP: +{item.unique_stats?.hp_max || item.stats?.hp_max}
</div>
)}
{(item.unique_stats?.stamina_max || item.stats?.stamina_max) && (
<div className="item-tooltip-stat">
Max Stamina: +{item.unique_stats?.stamina_max || item.stats?.stamina_max}
</div>
)}
{(item.unique_stats?.damage_min !== undefined || item.stats?.damage_min !== undefined) &&
(item.unique_stats?.damage_max !== undefined || item.stats?.damage_max !== undefined) && (
<div className="item-tooltip-stat">
Damage: {item.unique_stats?.damage_min || item.stats?.damage_min}-{item.unique_stats?.damage_max || item.stats?.damage_max}
</div>
)}
{(item.unique_stats?.weight_capacity || item.stats?.weight_capacity) && (
<div className="item-tooltip-stat">
Weight: +{item.unique_stats?.weight_capacity || item.stats?.weight_capacity}kg
</div>
)}
{(item.unique_stats?.volume_capacity || item.stats?.volume_capacity) && (
<div className="item-tooltip-stat">
📦 Volume: +{item.unique_stats?.volume_capacity || item.stats?.volume_capacity}L
</div>
)}
</>
)}
{item.durability !== undefined && item.durability !== null && (
<div className="item-tooltip-stat">
🔧 Durability: {item.durability}/{item.max_durability}
</div>
)}
{item.tier !== undefined && item.tier !== null && item.tier > 0 && (
<div className="item-tooltip-stat">
Tier: {item.tier}
</div>
)}
</div>
</>
) : (
<>
<span className="equipment-emoji">{emoji}</span>
<span className="equipment-slot-label">{label}</span>
</>
)}
</div>
)
return (
<div className={`right-sidebar mobile-menu-panel ${mobileMenuOpen === 'right' ? 'open' : ''}`}>
{/* Profile Stats */}
<div className="profile-sidebar">
<h3>👤 Character</h3>
<div className="sidebar-stat-bars">
<div className="sidebar-stat-bar">
<div className="sidebar-stat-header">
<span className="sidebar-stat-label"> HP</span>
<span className="sidebar-stat-numbers">{playerState.health}/{playerState.max_health}</span>
</div>
<div className="sidebar-progress-bar">
<div
className="sidebar-progress-fill health"
style={{ width: `${(playerState.health / playerState.max_health) * 100}%` }}
></div>
<span className="progress-percentage">{Math.round((playerState.health / playerState.max_health) * 100)}%</span>
</div>
</div>
<div className="sidebar-stat-bar">
<div className="sidebar-stat-header">
<span className="sidebar-stat-label"> Stamina</span>
<span className="sidebar-stat-numbers">{playerState.stamina}/{playerState.max_stamina}</span>
</div>
<div className="sidebar-progress-bar">
<div
className="sidebar-progress-fill stamina"
style={{ width: `${(playerState.stamina / playerState.max_stamina) * 100}%` }}
></div>
<span className="progress-percentage">{Math.round((playerState.stamina / playerState.max_stamina) * 100)}%</span>
</div>
</div>
</div>
{profile && (
<div className="sidebar-stats">
<div className="sidebar-stat-row">
<span className="sidebar-label">Level:</span>
<span className="sidebar-value">{profile.level}</span>
</div>
<div className="sidebar-stat-bar">
<div className="sidebar-stat-header">
<span className="sidebar-stat-label"> XP</span>
<span className="sidebar-stat-numbers">{profile.xp} / {(profile.level * 100)}</span>
</div>
<div className="sidebar-progress-bar">
<div
className="sidebar-progress-fill xp"
style={{ width: `${(profile.xp / (profile.level * 100)) * 100}%` }}
></div>
<span className="progress-percentage">{Math.round((profile.xp / (profile.level * 100)) * 100)}%</span>
</div>
</div>
{profile.unspent_points > 0 && (
<div className="sidebar-stat-row highlight">
<span className="sidebar-label"> Unspent:</span>
<span className="sidebar-value">{profile.unspent_points}</span>
</div>
)}
<div className="sidebar-divider"></div>
{/* Compact 2x2 Stats Grid */}
<div className="stats-grid">
<div className="sidebar-stat-row compact">
<span className="sidebar-label">💪 STR:</span>
<span className="sidebar-value">{profile.strength}</span>
{profile.unspent_points > 0 && (
<button className="stat-plus-btn" onClick={() => onSpendPoint('strength')}>+</button>
)}
</div>
<div className="sidebar-stat-row compact">
<span className="sidebar-label">🏃 AGI:</span>
<span className="sidebar-value">{profile.agility}</span>
{profile.unspent_points > 0 && (
<button className="stat-plus-btn" onClick={() => onSpendPoint('agility')}>+</button>
)}
</div>
<div className="sidebar-stat-row compact">
<span className="sidebar-label">🛡 END:</span>
<span className="sidebar-value">{profile.endurance}</span>
{profile.unspent_points > 0 && (
<button className="stat-plus-btn" onClick={() => onSpendPoint('endurance')}>+</button>
)}
</div>
<div className="sidebar-stat-row compact">
<span className="sidebar-label">🧠 INT:</span>
<span className="sidebar-value">{profile.intellect}</span>
{profile.unspent_points > 0 && (
<button className="stat-plus-btn" onClick={() => onSpendPoint('intellect')}>+</button>
)}
</div>
</div>
<div className="sidebar-divider"></div>
{/* Inventory Capacity - matching HP/Stamina/XP style */}
<div className="sidebar-stat-bar">
<div className="sidebar-stat-header">
<span className="sidebar-stat-label"> Weight</span>
<span className="sidebar-stat-numbers">{(profile.current_weight || 0).toFixed(1)}/{profile.max_weight || 0}kg</span>
</div>
<div className="sidebar-progress-bar">
<div
className="sidebar-progress-fill weight"
style={{ width: `${Math.min(100, ((profile.current_weight || 0) / (profile.max_weight || 1)) * 100)}%` }}
></div>
<span className="progress-percentage">{Math.round(Math.min(100, ((profile.current_weight || 0) / (profile.max_weight || 1)) * 100))}%</span>
</div>
</div>
<div className="sidebar-stat-bar">
<div className="sidebar-stat-header">
<span className="sidebar-stat-label">📦 Volume</span>
<span className="sidebar-stat-numbers">{(profile.current_volume || 0).toFixed(1)}/{profile.max_volume || 0}L</span>
</div>
<div className="sidebar-progress-bar">
<div
className="sidebar-progress-fill volume"
style={{ width: `${Math.min(100, ((profile.current_volume || 0) / (profile.max_volume || 1)) * 100)}%` }}
></div>
<span className="progress-percentage">{Math.round(Math.min(100, ((profile.current_volume || 0) / (profile.max_volume || 1)) * 100))}%</span>
</div>
</div>
<button
className="open-inventory-btn"
onClick={() => setShowInventory(true)}
style={{
width: '100%',
padding: '1rem',
marginTop: '1rem',
backgroundColor: '#2c3e50',
border: '1px solid #34495e',
borderRadius: '8px',
color: '#ecf0f1',
fontSize: '1.1rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
transition: 'all 0.2s'
}}
>
🎒 Open Inventory
</button>
</div>
)}
</div>
{/* Equipment Display - Proper Grid Layout */}
<div className="equipment-sidebar">
<h3> Equipment</h3>
<div className="equipment-grid">
{/* Row 1: Head */}
<div className="equipment-row">
{renderEquipmentSlot('head', equipment.head, '🪖', 'Head')}
</div>
{/* Row 2: Weapon, Torso, Backpack */}
<div className="equipment-row three-cols">
{renderEquipmentSlot('weapon', equipment.weapon, '⚔️', 'Weapon')}
{renderEquipmentSlot('torso', equipment.torso, '👕', 'Torso')}
{renderEquipmentSlot('backpack', equipment.backpack, '🎒', 'Backpack')}
</div>
{/* Row 3: Legs & Feet */}
<div className="equipment-row two-cols">
{renderEquipmentSlot('legs', equipment.legs, '👖', 'Legs')}
{renderEquipmentSlot('feet', equipment.feet, '👟', 'Feet')}
</div>
</div>
</div>
{/* Inventory Modal */}
{showInventory && profile && (
<InventoryModal
playerState={playerState}
profile={profile}
equipment={equipment}
inventoryFilter={inventoryFilter}
inventoryCategoryFilter={inventoryCategoryFilter}
onClose={() => setShowInventory(false)}
onSetInventoryFilter={onSetInventoryFilter}
onSetInventoryCategoryFilter={onSetInventoryCategoryFilter}
onUseItem={onUseItem}
onEquipItem={onEquipItem}
onUnequipItem={onUnequipItem}
onDropItem={onDropItem}
/>
)}
</div>
)
}
export default PlayerSidebar

View File

@@ -0,0 +1,262 @@
import type { PlayerState, Profile, Equipment } from './types'
interface PlayerSidebarProps {
playerState: PlayerState
profile: Profile | null
equipment: Equipment
inventoryFilter: string
inventoryCategoryFilter: string
mobileMenuOpen: string
onSetInventoryFilter: (filter: string) => void
onSetInventoryCategoryFilter: (category: string) => void
onUseItem: (itemId: number, invId: number) => void
onEquipItem: (itemId: number, invId: number) => void
onUnequipItem: (slot: string) => void
onDropItem: (itemId: number, invId: number, quantity: number) => void
onSpendPoint: (stat: string) => void
}
function PlayerSidebar({
playerState,
profile,
equipment,
inventoryFilter,
inventoryCategoryFilter,
mobileMenuOpen,
onSetInventoryFilter,
onSetInventoryCategoryFilter,
onUseItem,
onEquipItem,
onUnequipItem,
onDropItem,
onSpendPoint
}: PlayerSidebarProps) {
return (
<div className={`right-sidebar mobile-menu-panel ${mobileMenuOpen === 'right' ? 'open' : ''}`}>
{/* Profile Stats */}
<div className="profile-sidebar">
<h3>👤 Character</h3>
<div className="sidebar-stat-bars">
<div className="sidebar-stat-bar">
<div className="sidebar-stat-header">
<span className="sidebar-stat-label"> HP</span>
<span className="sidebar-stat-numbers">{playerState.health}/{playerState.max_health}</span>
</div>
<div className="sidebar-progress-bar">
<div
className="sidebar-progress-fill health"
style={{ width: `${(playerState.health / playerState.max_health) * 100}%` }}
></div>
<span className="progress-percentage">{Math.round((playerState.health / playerState.max_health) * 100)}%</span>
</div>
</div>
<div className="sidebar-stat-bar">
<div className="sidebar-stat-header">
<span className="sidebar-stat-label"> Stamina</span>
<span className="sidebar-stat-numbers">{playerState.stamina}/{playerState.max_stamina}</span>
</div>
<div className="sidebar-progress-bar">
<div
className="sidebar-progress-fill stamina"
style={{ width: `${(playerState.stamina / playerState.max_stamina) * 100}%` }}
></div>
<span className="progress-percentage">{Math.round((playerState.stamina / playerState.max_stamina) * 100)}%</span>
</div>
</div>
</div>
{profile && (
<div className="sidebar-stats">
<div className="sidebar-stat-row">
<span className="sidebar-label">Level:</span>
<span className="sidebar-value">{profile.level}</span>
</div>
<div className="sidebar-stat-bar">
<div className="sidebar-stat-header">
<span className="sidebar-stat-label"> XP</span>
<span className="sidebar-stat-numbers">{profile.xp} / {(profile.level * 100)}</span>
</div>
<div className="sidebar-progress-bar">
<div
className="sidebar-progress-fill xp"
style={{ width: `${(profile.xp / (profile.level * 100)) * 100}%` }}
></div>
<span className="progress-percentage">{Math.round((profile.xp / (profile.level * 100)) * 100)}%</span>
</div>
</div>
{profile.unspent_points > 0 && (
<div className="sidebar-stat-row highlight">
<span className="sidebar-label"> Unspent:</span>
<span className="sidebar-value">{profile.unspent_points}</span>
</div>
)}
<div className="sidebar-divider"></div>
<div className="sidebar-stat-row">
<span className="sidebar-label">💪 STR:</span>
<span className="sidebar-value">{profile.strength}</span>
{profile.unspent_points > 0 && (
<button className="stat-plus-btn" onClick={() => onSpendPoint('strength')}>+</button>
)}
</div>
<div className="sidebar-stat-row">
<span className="sidebar-label">🏃 AGI:</span>
<span className="sidebar-value">{profile.agility}</span>
{profile.unspent_points > 0 && (
<button className="stat-plus-btn" onClick={() => onSpendPoint('agility')}>+</button>
)}
</div>
<div className="sidebar-stat-row">
<span className="sidebar-label">🛡 END:</span>
<span className="sidebar-value">{profile.endurance}</span>
{profile.unspent_points > 0 && (
<button className="stat-plus-btn" onClick={() => onSpendPoint('endurance')}>+</button>
)}
</div>
<div className="sidebar-stat-row">
<span className="sidebar-label">🧠 INT:</span>
<span className="sidebar-value">{profile.intellect}</span>
{profile.unspent_points > 0 && (
<button className="stat-plus-btn" onClick={() => onSpendPoint('intellect')}>+</button>
)}
</div>
</div>
)}
</div>
{/* Equipment Display */}
<div className="equipment-sidebar">
<h3> Equipment</h3>
<div className="equipment-grid">
{Object.entries(equipment).map(([slot, item]: [string, any]) => (
<div key={slot} className="equipment-row">
<div className={`equipment-slot ${item ? 'filled' : 'empty'}`}>
{item ? (
<>
<button className="equipment-unequip-btn" onClick={() => onUnequipItem(slot)} title="Unequip"></button>
<div className="equipment-item-content">
<span className="equipment-emoji">{item.emoji}</span>
<span className={`equipment-name ${item.tier ? `tier-${item.tier}` : ''}`}>{item.name}</span>
{item.durability && item.durability !== null && (
<span className="equipment-durability">{item.durability}/{item.max_durability}</span>
)}
</div>
</>
) : (
<div className="equipment-slot-label">{slot}</div>
)}
</div>
</div>
))}
</div>
</div>
{/* Inventory */}
<div className="inventory-sidebar">
<h3>🎒 Inventory</h3>
{profile && (
<div className="inventory-capacity">
<div className="capacity-info">
<span> {(profile.current_weight || 0).toFixed(1)}/{profile.max_weight}kg</span>
<span>📦 {(profile.current_volume || 0).toFixed(1)}/{profile.max_volume}L</span>
</div>
</div>
)}
<div className="filter-box">
<input
type="text"
placeholder="🔍 Filter items..."
value={inventoryFilter}
onChange={(e) => onSetInventoryFilter(e.target.value)}
className="filter-input"
/>
<select
value={inventoryCategoryFilter}
onChange={(e) => onSetInventoryCategoryFilter(e.target.value)}
className="filter-select"
>
<option value="all">All</option>
<option value="weapon"> Weapons</option>
<option value="armor">🛡 Armor</option>
<option value="consumable">🍎 Consumables</option>
<option value="material">🪵 Materials</option>
<option value="tool">🔨 Tools</option>
<option value="other">📦 Other</option>
</select>
</div>
<div className="inventory-list">
{playerState.inventory
.filter((item: any) =>
item.name.toLowerCase().includes(inventoryFilter.toLowerCase()) &&
(inventoryCategoryFilter === 'all' || item.category === inventoryCategoryFilter)
)
.map((item: any, idx: number) => (
<div key={idx} className="inventory-item">
<div className="inventory-item-header">
<span className={`item-name ${item.tier ? `tier-${item.tier}` : ''}`}>
{item.emoji} {item.name}
</span>
{item.quantity > 1 && <span className="item-quantity">×{item.quantity}</span>}
</div>
{item.description && <p className="item-description">{item.description}</p>}
{item.durability !== undefined && item.durability !== null && (
<div className="durability-display">
<div className="durability-bar">
<div
className={`durability-fill ${(item.durability / item.max_durability * 100) === 100 ? 'full' : ''}`}
style={{ width: `${(item.durability / item.max_durability) * 100}%` }}
></div>
<span className="durability-text">{item.durability}/{item.max_durability}</span>
</div>
</div>
)}
<div className="inventory-item-actions">
{item.category === 'consumable' && (
<button
className="item-action-btn use-btn"
onClick={() => onUseItem(item.item_id, item.inventory_id)}
>
Use
</button>
)}
{item.slot && !item.is_equipped && (
<button
className="item-action-btn equip-btn"
onClick={() => onEquipItem(item.item_id, item.inventory_id)}
>
Equip
</button>
)}
<button
className="item-action-btn drop-btn"
onClick={() => {
const qty = item.quantity > 1
? parseInt(prompt(`Drop how many? (1-${item.quantity})`, '1') || '1')
: 1
if (qty > 0 && qty <= item.quantity) {
onDropItem(item.item_id, item.inventory_id, qty)
}
}}
>
Drop
</button>
</div>
</div>
))}
</div>
</div>
</div>
)
}
export default PlayerSidebar

View File

@@ -0,0 +1,608 @@
/* Workbench Overlay */
.workbench-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.85);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.workbench-menu {
width: 95vw;
max-width: 1400px;
height: 85vh;
background: linear-gradient(135deg, #1a202c 0%, #2d3748 100%);
border: 1px solid #4a5568;
border-radius: 12px;
display: flex;
flex-direction: column;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
overflow: hidden;
}
.workbench-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
background: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid #4a5568;
}
.workbench-header h3 {
margin: 0;
font-size: 1.5rem;
color: #e2e8f0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.workbench-tabs {
display: flex;
gap: 0.5rem;
}
.tab-btn {
background: transparent;
border: 1px solid transparent;
color: #a0aec0;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
}
.tab-btn:hover {
color: #fff;
background: rgba(255, 255, 255, 0.05);
}
.tab-btn.active {
background: #3182ce;
color: #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.close-btn {
background: transparent;
border: none;
color: #a0aec0;
font-size: 1.5rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 4px;
transition: all 0.2s;
}
.close-btn:hover {
background: rgba(245, 101, 101, 0.2);
color: #f56565;
}
/* Workbench Layout */
.workbench-content-grid {
display: grid;
grid-template-columns: 220px 350px 1fr;
height: 100%;
overflow: hidden;
background: rgba(0, 0, 0, 0.2);
}
/* Column 1: Sidebar */
.workbench-sidebar {
background: rgba(0, 0, 0, 0.2);
border-right: 1px solid #3a4b5c;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
overflow-y: auto;
}
.sidebar-title {
color: #a0aec0;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 0.5rem;
padding-left: 0.5rem;
}
.category-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.workbench-sidebar .category-btn {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
color: #a0aec0;
cursor: pointer;
transition: all 0.2s;
text-align: left;
}
.workbench-sidebar .category-btn:hover {
background: rgba(255, 255, 255, 0.05);
color: #fff;
}
.workbench-sidebar .category-btn.active {
background: rgba(66, 153, 225, 0.15);
border-color: #4299e1;
color: #63b3ed;
}
.workbench-sidebar .cat-icon {
font-size: 1.2rem;
width: 1.5rem;
display: flex;
justify-content: center;
align-items: center;
}
.workbench-sidebar .cat-label {
font-weight: 500;
}
/* Column 2: Items List */
.workbench-items-column {
display: flex;
flex-direction: column;
border-right: 1px solid #3a4b5c;
background: rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.workbench-filters {
padding: 1rem;
border-bottom: 1px solid #3a4b5c;
background: rgba(0, 0, 0, 0.1);
}
.workbench-items-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.workbench-item-card {
display: flex;
align-items: center;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
gap: 0.5rem;
}
.workbench-item-card:hover {
background: rgba(255, 255, 255, 0.06);
transform: translateX(2px);
}
.workbench-item-card.selected {
background: rgba(66, 153, 225, 0.1);
border-color: #4299e1;
}
.workbench-item-card.craftable {
border-left: 3px solid #4caf50;
}
.workbench-item-card.repairable {
border-left: 3px solid #ff9800;
}
.workbench-item-card.salvageable {
border-left: 3px solid #9c27b0;
}
.item-card-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.item-header-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.item-emoji {
font-size: 1.2rem;
}
.item-name {
font-weight: 600;
color: #e2e8f0;
font-size: 0.95rem;
}
.item-meta-row {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
}
.tier-badge {
background: #2d3748;
padding: 1px 4px;
border-radius: 3px;
color: #a0aec0;
font-weight: bold;
}
.tier-badge.tier-1 {
color: #fff;
}
.tier-badge.tier-2 {
color: #68d391;
}
.tier-badge.tier-3 {
color: #63b3ed;
}
.tier-badge.tier-4 {
color: #9f7aea;
}
.tier-badge.tier-5 {
color: #ed8936;
}
.equipped-badge {
color: #48bb78;
font-weight: bold;
background: rgba(72, 187, 120, 0.1);
padding: 1px 4px;
border-radius: 3px;
}
.item-stats-mini {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin-top: 0.25rem;
}
.stat-mini {
font-size: 0.7rem;
padding: 2px 6px;
background: rgba(0, 0, 0, 0.3);
border-radius: 3px;
color: #cbd5e0;
white-space: nowrap;
}
.stat-mini.durability {
color: #fbbf24;
}
.status-icon {
font-size: 1.2rem;
margin-left: 0.5rem;
}
/* Item Image Thumbnail */
.item-image-thumb {
width: 50px;
height: 50px;
flex-shrink: 0;
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.3);
border-radius: 6px;
border: 1px solid #4a5568;
margin-right: 0.75rem;
}
.item-thumb-img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.item-thumb-emoji {
font-size: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.item-thumb-emoji.hidden {
display: none;
}
/* Tier Colors for Item Names */
.item-name.text-tier-0 {
color: #a0aec0;
}
.item-name.text-tier-1 {
color: #ffffff;
}
.item-name.text-tier-2 {
color: #68d391;
}
.item-name.text-tier-3 {
color: #63b3ed;
}
.item-name.text-tier-4 {
color: #9f7aea;
}
.item-name.text-tier-5 {
color: #ed8936;
}
/* Condition Text for Salvage Tab */
.condition-text {
font-size: 0.75rem;
color: #a0aec0;
font-weight: 500;
}
/* Mini Progress Bar */
.mini-progress-bar {
height: 3px;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
overflow: hidden;
margin-top: 0.25rem;
width: 100%;
}
.mini-progress-fill {
height: 100%;
border-radius: 2px;
}
.mini-progress-fill.good {
background: #48bb78;
}
.mini-progress-fill.warning {
background: #ed8936;
}
.mini-progress-fill.critical {
background: #f56565;
}
/* Repair Preview - Dual Color Durability Bar */
.repair-preview-bar {
height: 12px;
background: rgba(255, 255, 255, 0.1);
border-radius: 6px;
overflow: hidden;
position: relative;
margin-bottom: 0.5rem;
}
.repair-preview-current {
position: absolute;
height: 100%;
background: #ed8936;
transition: width 0.3s ease;
}
.repair-preview-restored {
position: absolute;
height: 100%;
background: #48bb78;
transition: width 0.3s ease, left 0.3s ease;
}
.repair-preview-text {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: #a0aec0;
margin-bottom: 0.5rem;
}
.repair-preview-text .current {
color: #ed8936;
}
.repair-preview-text .restored {
color: #48bb78;
}
/* Column 3: Details */
.workbench-details-column {
padding: 2rem;
overflow-y: auto;
display: flex;
flex-direction: column;
align-items: center;
background: rgba(0, 0, 0, 0.2);
}
.detail-header {
text-align: center;
margin-bottom: 2rem;
width: 100%;
max-width: 600px;
}
.detail-image-container {
width: 120px;
height: 120px;
margin: 0 auto 1.5rem auto;
border-radius: 12px;
overflow: hidden;
border: 2px solid #4a5568;
background: rgba(0, 0, 0, 0.3);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
}
.detail-image {
width: 100%;
height: 100%;
object-fit: contain;
padding: 10px;
}
.detail-title {
font-size: 1.8rem;
color: #fff;
margin-bottom: 0.5rem;
}
.detail-description {
color: #a0aec0;
font-style: italic;
line-height: 1.5;
}
.detail-requirements {
width: 100%;
max-width: 600px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
}
.detail-requirements h4 {
color: #63b3ed;
margin-bottom: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding-bottom: 0.5rem;
}
.requirement-item {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
font-size: 0.95rem;
}
.requirement-item.met {
color: #48bb78;
}
.requirement-item.missing {
color: #f56565;
}
.detail-actions {
width: 100%;
max-width: 600px;
margin-top: auto;
}
.craft-btn,
.repair-btn,
.uncraft-btn {
width: 100%;
padding: 1rem;
border-radius: 8px;
border: none;
font-weight: bold;
cursor: pointer;
transition: all 0.2s;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.craft-btn {
background: linear-gradient(135deg, #ecc94b 0%, #d69e2e 100%);
color: #1a202c;
}
.craft-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(236, 201, 75, 0.3);
}
.repair-btn {
background: linear-gradient(135deg, #48bb78 0%, #38a169 100%);
color: #fff;
}
.repair-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(72, 187, 120, 0.3);
}
.uncraft-btn {
background: linear-gradient(135deg, #f56565 0%, #c53030 100%);
color: #fff;
}
.uncraft-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(245, 101, 101, 0.3);
}
.craft-btn:disabled,
.repair-btn:disabled,
.uncraft-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
box-shadow: none;
background: #4a5568;
color: #a0aec0;
}
.workbench-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #718096;
text-align: center;
}
/* Equipped Badge - Matches InventoryModal */
.item-card-equipped {
font-size: 0.7rem;
padding: 2px 6px;
border-radius: 4px;
background: rgba(66, 153, 225, 0.2);
color: #63b3ed;
border: 1px solid rgba(66, 153, 225, 0.4);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
display: inline-block;
vertical-align: middle;
}

View File

@@ -0,0 +1,641 @@
import { useState, useEffect, type MouseEvent, type ChangeEvent } from 'react'
import type { Profile, WorkbenchTab } from './types'
import './Workbench.css'
interface WorkbenchProps {
showCraftingMenu: boolean
showRepairMenu: boolean
workbenchTab: WorkbenchTab
craftableItems: any[]
repairableItems: any[]
uncraftableItems: any[]
craftFilter: string
repairFilter: string
uncraftFilter: string
craftCategoryFilter: string
profile: Profile | null
onCloseCrafting: () => void
onSwitchTab: (tab: WorkbenchTab) => void
onSetCraftFilter: (filter: string) => void
onSetRepairFilter: (filter: string) => void
onSetUncraftFilter: (filter: string) => void
onSetCraftCategoryFilter: (category: string) => void
onCraft: (itemId: number) => void
onRepair: (uniqueItemId: string, inventoryId: number) => void
onUncraft: (uniqueItemId: string, inventoryId: number) => void
}
function Workbench({
showCraftingMenu,
showRepairMenu,
workbenchTab,
craftableItems,
repairableItems,
uncraftableItems,
craftFilter,
repairFilter,
uncraftFilter,
craftCategoryFilter,
profile,
onCloseCrafting,
onSwitchTab,
onSetCraftFilter,
onSetRepairFilter,
onSetUncraftFilter,
onSetCraftCategoryFilter,
onCraft,
onRepair,
onUncraft
}: WorkbenchProps) {
const [selectedItem, setSelectedItem] = useState<any>(null)
// Reset selection when tab changes
useEffect(() => {
setSelectedItem(null)
}, [workbenchTab])
// Update selectedItem when items list changes (after repair/craft/salvage)
useEffect(() => {
if (selectedItem) {
const items = getItems()
// Find the updated item by unique_item_id or inventory_id
const updatedItem = items.find(item => {
if (selectedItem.unique_item_id && item.unique_item_id) {
return item.unique_item_id === selectedItem.unique_item_id
}
if (selectedItem.inventory_id && item.inventory_id) {
return item.inventory_id === selectedItem.inventory_id
}
return item.item_id === selectedItem.item_id
})
if (updatedItem) {
setSelectedItem(updatedItem)
} else {
// Item no longer exists (e.g., was salvaged)
setSelectedItem(null)
}
}
}, [craftableItems, repairableItems, uncraftableItems])
if (!showCraftingMenu && !showRepairMenu) return null
const getItems = () => {
switch (workbenchTab) {
case 'craft':
return craftableItems.filter(item =>
item.name.toLowerCase().includes(craftFilter.toLowerCase()) &&
(craftCategoryFilter === 'all' || item.category === craftCategoryFilter)
)
case 'repair':
return repairableItems
.filter(item => item.name.toLowerCase().includes(repairFilter.toLowerCase()))
.sort((a, b) => {
if (a.needs_repair && !b.needs_repair) return -1
if (!a.needs_repair && b.needs_repair) return 1
return 0
})
case 'uncraft':
return uncraftableItems.filter(item =>
item.name.toLowerCase().includes(uncraftFilter.toLowerCase())
)
default:
return []
}
}
const items = getItems()
const renderItemDetails = () => {
if (!selectedItem) {
return (
<div className="workbench-empty-state">
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>🔧</div>
<h3>Select an item to view details</h3>
<p>Choose an item from the list on the left</p>
</div>
)
}
const item = selectedItem
const imagePath = item.image_path || `/images/items/${item.item_id || item.id}.webp`
return (
<>
<div className="detail-header">
<div className="detail-image-container">
{imagePath ? (
<img
src={imagePath}
alt={item.name}
className="detail-image"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
const icon = (e.target as HTMLImageElement).nextElementSibling;
if (icon) icon.classList.remove('hidden');
}}
/>
) : null}
<div className={`detail-image-fallback ${imagePath ? 'hidden' : ''}`} style={{ fontSize: '4rem' }}>
{item.emoji || '📦'}
</div>
</div>
<h2 className="detail-title">{item.emoji} {item.name}</h2>
{item.description && <p className="detail-description">{item.description}</p>}
{/* Base Stats Display for Crafting */}
{workbenchTab === 'craft' && (item.base_stats || item.stats) && (
<div style={{ marginTop: '1rem' }}>
<div className="item-stats" style={{ display: 'flex', gap: '1rem', justifyContent: 'center', flexWrap: 'wrap' }}>
{Object.entries(item.base_stats || item.stats).map(([key, value]) => {
const icons: Record<string, string> = {
weight_capacity: '⚖️ Weight',
volume_capacity: '📦 Volume',
armor: '🛡️ Armor',
hp_max: '❤️ Max HP',
stamina_max: '⚡ Max Stamina',
damage_min: '⚔️ Damage Min',
damage_max: '⚔️ Damage Max'
}
const label = icons[key] || key.replace('_', ' ')
const unit = key.includes('weight') ? 'kg' : key.includes('volume') ? 'L' : ''
return (
<div key={key} className="stat-badge" style={{ background: 'rgba(0,0,0,0.3)', padding: '0.3rem 0.6rem', borderRadius: '4px', fontSize: '0.9rem', color: '#ccc' }}>
<span style={{ color: '#aaa' }}>{label}:</span> <span style={{ color: '#fff', fontWeight: 'bold' }}>+{Math.round(Number(value))}{unit}</span>
</div>
)
})}
</div>
<p style={{ fontSize: '0.8rem', color: '#888', fontStyle: 'italic', marginTop: '0.5rem', textAlign: 'center' }}>
* Potential base stats. Actual stats may vary.
</p>
</div>
)}
{/* Stats Display for Repair/Salvage */}
{workbenchTab !== 'craft' && (item.unique_item_data?.unique_stats || item.unique_item_data || item.base_stats || item.stats) && (
<div className="item-stats" style={{ display: 'flex', gap: '1rem', justifyContent: 'center', marginTop: '1rem', flexWrap: 'wrap' }}>
{Object.entries(item.unique_item_data?.unique_stats ?? item.unique_item_data ?? item.base_stats ?? item.stats ?? {}).filter(([k]) => !['id', 'item_id', 'durability', 'max_durability', 'created_at', 'tier'].includes(k)).map(([key, value]) => {
const icons: Record<string, string> = {
weight_capacity: '⚖️ Weight',
volume_capacity: '📦 Volume',
armor: '🛡️ Armor',
hp_max: '❤️ Max HP',
stamina_max: '⚡ Max Stamina',
damage_min: '⚔️ Damage Min',
damage_max: '⚔️ Damage Max'
}
const label = icons[key] || key.replace('_', ' ')
const unit = key.includes('weight') ? 'kg' : key.includes('volume') ? 'L' : ''
return (
<div key={key} className="stat-badge" style={{ background: 'rgba(0,0,0,0.3)', padding: '0.3rem 0.6rem', borderRadius: '4px', fontSize: '0.9rem', color: '#ccc' }}>
<span style={{ color: '#aaa' }}>{label}:</span> <span style={{ color: '#fff', fontWeight: 'bold' }}>+{Math.round(Number(value))}{unit}</span>
</div>
)
})}
</div>
)}
</div>
{workbenchTab === 'craft' && (
<>
<div className="detail-requirements">
<h4>📊 Requirements</h4>
{item.craft_level && item.craft_level > 1 && (
<div className={`requirement-item ${item.meets_level ? 'met' : 'missing'}`}>
<span>Level {item.craft_level} Required</span>
<span>{item.meets_level ? '✅' : `❌ (Lv. ${profile?.level || 1})`}</span>
</div>
)}
{item.tools && item.tools.length > 0 && (
<>
<h5 style={{ marginTop: '1rem', marginBottom: '0.5rem', color: '#aaa' }}>Tools</h5>
{item.tools.map((tool: any, i: number) => (
<div key={i} className={`requirement-item ${tool.has_tool ? 'met' : 'missing'}`}>
<span>{tool.emoji} {tool.name}</span>
<span>
{tool.has_tool ? `${tool.tool_durability}/${tool.max_durability} (Cost: ${tool.durability_cost})` : `❌ Missing (Cost: ${tool.durability_cost})`}
</span>
</div>
))}
</>
)}
<h5 style={{ marginTop: '1rem', marginBottom: '0.5rem', color: '#aaa' }}>Materials</h5>
{item.materials && item.materials.length > 0 ? (
item.materials.map((mat: any, i: number) => (
<div key={i} className={`requirement-item ${mat.has_enough ? 'met' : 'missing'}`}>
<span>{mat.emoji} {mat.name}</span>
<span>{mat.available} / {mat.required}</span>
</div>
))
) : (
<div className="requirement-item met">
<span>No materials required</span>
</div>
)}
</div>
<div className="detail-actions">
<button
className="craft-btn"
disabled={!item.can_craft || (profile?.stamina || 0) < (item.stamina_cost || 1)}
onClick={() => onCraft(item.item_id)}
style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
>
<span>
{!item.meets_level ? `Need Level ${item.craft_level}` :
!item.can_craft ? 'Missing Requirements' : '🔨 Craft Item'}
</span>
{item.can_craft && (
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}>
{item.stamina_cost || 5} Stamina
</span>
)}
</button>
</div>
</>
)}
{workbenchTab === 'repair' && (
<>
<div className="detail-requirements">
<h4>🔧 Repair Status</h4>
{!item.needs_repair ? (
<p className="repair-info full-durability" style={{ textAlign: 'center', marginBottom: '1rem' }}> Item is in perfect condition</p>
) : (
<>
<div className="repair-preview-text">
<span className="current">Current: {item.durability_percent}%</span>
<span className="restored">After Repair: {Math.min(100, item.durability_percent + (item.repair_percentage || 0))}%</span>
</div>
<div className="repair-preview-bar">
<div
className="repair-preview-current"
style={{ width: `${item.durability_percent}%` }}
></div>
<div
className="repair-preview-restored"
style={{
left: `${item.durability_percent}%`,
width: `${Math.min(100 - item.durability_percent, item.repair_percentage || 0)}%`
}}
></div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem', fontSize: '0.85rem', color: '#aaa' }}>
<span>{item.current_durability}/{item.max_durability}</span>
<span>+{Math.round((item.max_durability || 0) * ((item.repair_percentage || 0) / 100))} durability</span>
</div>
</>
)}
{item.needs_repair && (
<>
{item.tools && item.tools.length > 0 && (
<>
<h5 style={{ marginTop: '1rem', marginBottom: '0.5rem', color: '#aaa' }}>Tools</h5>
{item.tools.map((tool: any, i: number) => (
<div key={i} className={`requirement-item ${tool.has_tool ? 'met' : 'missing'}`}>
<span>{tool.emoji} {tool.name}</span>
<span>
{tool.has_tool ? `${tool.tool_durability}/${tool.max_durability} (Cost: ${tool.durability_cost})` : `❌ Missing (Cost: ${tool.durability_cost})`}
</span>
</div>
))}
</>
)}
<h5 style={{ marginTop: '1rem', marginBottom: '0.5rem', color: '#aaa' }}>Materials</h5>
{item.materials.map((mat: any, i: number) => (
<div key={i} className={`requirement-item ${mat.has_enough ? 'met' : 'missing'}`}>
<span>{mat.emoji} {mat.name}</span>
<span>{mat.available} / {mat.quantity}</span>
</div>
))}
</>
)}
</div>
<div className="detail-actions">
<button
className="repair-btn"
disabled={!item.can_repair || (profile?.stamina || 0) < (item.stamina_cost || 1)}
onClick={() => onRepair(item.unique_item_id, item.inventory_id)}
style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
>
<span>
{!item.needs_repair ? 'Already Full' :
!item.can_repair ? 'Missing Requirements' : '🛠️ Repair Item'}
</span>
{item.needs_repair && item.can_repair && (
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}>
{item.stamina_cost || 3} Stamina
</span>
)}
</button>
</div>
</>
)}
{workbenchTab === 'uncraft' && (
<>
<div className="detail-requirements">
<h4> Salvage Preview</h4>
{/* Show durability bar if we have durability data */}
{(item.unique_item_data || item.durability_percent !== undefined) && (
<div className="durability-display" style={{ marginBottom: '1rem' }}>
<div className="durability-bar" style={{ height: '8px' }}>
<div
className={`durability-fill ${(item.unique_item_data?.durability_percent || item.durability_percent) === 100 ? 'full' : ''}`}
style={{ width: `${item.unique_item_data?.durability_percent || item.durability_percent || 0}%` }}
></div>
</div>
<div style={{ textAlign: 'right', fontSize: '0.8rem', marginTop: '0.2rem', color: '#aaa' }}>
Condition: {item.unique_item_data?.durability_percent || item.durability_percent || 0}%
</div>
</div>
)}
<div className="materials-list">
{(() => {
const durabilityRatio = item.unique_item_data?.durability_percent !== undefined
? (item.unique_item_data.durability_percent || 0) / 100
: item.durability_percent !== undefined
? (item.durability_percent || 0) / 100
: 1.0
const adjustedYield = (item.uncraft_yield || item.base_yield || []).map((mat: any) => ({
...mat,
adjusted_quantity: Math.round((mat.quantity || 0) * durabilityRatio)
}))
return (
<>
{durabilityRatio < 1.0 && (
<div className="uncraft-warning" style={{ marginBottom: '1rem', color: '#ffc107' }}>
Yield reduced by {Math.round((1 - durabilityRatio) * 100)}% due to damage
</div>
)}
{item.loss_chance && (
<div className="uncraft-warning" style={{ marginBottom: '1rem', color: '#ff9800' }}>
{Math.round(item.loss_chance * 100)}% chance to lose each material
</div>
)}
{adjustedYield.map((mat: any, i: number) => (
<div key={i} className="requirement-item met">
<span>{mat.emoji} {mat.name}</span>
<span>x{mat.adjusted_quantity}</span>
</div>
))}
</>
)
})()}
</div>
</div>
<div className="detail-actions">
<button
className="uncraft-btn"
disabled={(profile?.stamina || 0) < (item.stamina_cost || 1)}
onClick={() => {
if (window.confirm(`Are you sure you want to salvage ${item.name}? This cannot be undone.`)) {
onUncraft(item.unique_item_id, item.inventory_id)
}
}}
style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', backgroundColor: '#d32f2f', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
>
<span> Salvage Item</span>
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}>
{item.stamina_cost || 2} Stamina
</span>
</button>
</div>
</>
)}
</>
)
}
const categories = [
{ id: 'all', label: 'All', icon: '🎒' },
{ id: 'weapon', label: 'Weapons', icon: '⚔️' },
{ id: 'armor', label: 'Armor', icon: '🛡️' },
{ id: 'clothing', label: 'Clothing', icon: '👕' },
{ id: 'tool', label: 'Tools', icon: '🛠️' },
{ id: 'consumable', label: 'Consumables', icon: '🍖' },
{ id: 'resource', label: 'Resources', icon: '📦' },
{ id: 'misc', label: 'Misc', icon: '📦' }
]
return (
<div className="workbench-overlay" onClick={(e: MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) onCloseCrafting()
}}>
<div className="workbench-menu">
<div className="workbench-header">
<h3>🔧 Workbench</h3>
<div className="workbench-tabs">
<button
className={`tab-btn ${workbenchTab === 'craft' ? 'active' : ''}`}
onClick={() => onSwitchTab('craft')}
>
🔨 Craft
</button>
<button
className={`tab-btn ${workbenchTab === 'repair' ? 'active' : ''}`}
onClick={() => onSwitchTab('repair')}
>
🛠 Repair
</button>
<button
className={`tab-btn ${workbenchTab === 'uncraft' ? 'active' : ''}`}
onClick={() => onSwitchTab('uncraft')}
>
Salvage
</button>
</div>
<button className="close-btn" onClick={onCloseCrafting}></button>
</div>
<div className="workbench-content-grid">
{/* Column 1: Categories Sidebar */}
<div className="workbench-sidebar">
<h4 className="sidebar-title">Categories</h4>
<div className="category-list">
{categories.map(cat => (
<button
key={cat.id}
className={`category-btn ${craftCategoryFilter === cat.id ? 'active' : ''}`}
onClick={() => onSetCraftCategoryFilter(cat.id)}
>
<span className="cat-icon">{cat.icon}</span>
<span className="cat-label">{cat.label}</span>
</button>
))}
</div>
</div>
{/* Column 2: Items List */}
<div className="workbench-items-column">
<div className="workbench-filters">
<input
type="text"
placeholder="🔍 Filter items..."
value={workbenchTab === 'craft' ? craftFilter : workbenchTab === 'repair' ? repairFilter : uncraftFilter}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
if (workbenchTab === 'craft') onSetCraftFilter(e.target.value)
else if (workbenchTab === 'repair') onSetRepairFilter(e.target.value)
else onSetUncraftFilter(e.target.value)
}}
className="filter-input"
/>
</div>
<div className="workbench-items-list">
{items.filter(item => {
// Text search filter
const searchFilter = workbenchTab === 'craft' ? craftFilter : workbenchTab === 'repair' ? repairFilter : uncraftFilter
const matchesSearch = !searchFilter || item.name.toLowerCase().includes(searchFilter.toLowerCase())
// Category filter (apply to all tabs)
let matchesCategory = true
if (craftCategoryFilter !== 'all') {
// Assuming item has a 'type' property that matches category IDs
matchesCategory = item.type === craftCategoryFilter
}
return matchesSearch && matchesCategory
}).length === 0 ? (
<div className="empty-state">
{workbenchTab === 'craft' ? 'No craftable items found.' :
workbenchTab === 'repair' ? 'No repairable items found.' :
'No salvageable items found.'}
</div>
) : (
items
.filter(item => {
// Text search filter
const searchFilter = workbenchTab === 'craft' ? craftFilter : workbenchTab === 'repair' ? repairFilter : uncraftFilter
const matchesSearch = !searchFilter || item.name.toLowerCase().includes(searchFilter.toLowerCase())
// Category filter (apply to all tabs)
let matchesCategory = true
if (craftCategoryFilter !== 'all') {
// Assuming item has a 'type' property that matches category IDs
matchesCategory = item.type === craftCategoryFilter
}
return matchesSearch && matchesCategory
})
.map((item: any, idx: number) => {
const imagePath = item.image_path || `/images/items/${item.item_id || item.id}.webp`
return (
<div
key={item.unique_item_id || item.item_id || idx}
className={`workbench-item-card ${selectedItem === item ? 'selected' : ''} ${workbenchTab === 'craft' && item.can_craft ? 'craftable' : ''} ${workbenchTab === 'repair' && item.needs_repair ? 'repairable' : ''} ${workbenchTab === 'uncraft' && item.can_uncraft ? 'salvageable' : ''}`}
onClick={() => setSelectedItem(item)}
>
{/* Item Image/Icon */}
<div className="item-image-thumb">
{imagePath ? (
<img
src={imagePath}
alt={item.name}
className="item-thumb-img"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
const icon = (e.target as HTMLImageElement).nextElementSibling;
if (icon) icon.classList.remove('hidden');
}}
/>
) : null}
<div className={`item-thumb-emoji ${imagePath ? 'hidden' : ''}`}>
{item.emoji || '📦'}
</div>
</div>
<div className="item-card-content">
<div className="item-header-row">
<span
className={`item-name ${(workbenchTab === 'repair' || workbenchTab === 'uncraft') && item.tier ? `text-tier-${item.tier}` : ''}`}
>
{item.name}
</span>
{item.location === 'equipped' && <span className="item-card-equipped" style={{ marginLeft: '0.5rem' }}>Equipped</span>}
</div>
<div className="item-meta-row">
</div>
{/* Stats display for repair/salvage items */}
{(workbenchTab === 'repair' || workbenchTab === 'uncraft') && (() => {
const statsSource = item.unique_item_data?.unique_stats ?? item.unique_item_data ?? item.base_stats ?? item.stats ?? {};
const damage_min = statsSource.damage_min;
const damage_max = statsSource.damage_max;
const armor = statsSource.armor;
return (damage_min || armor) ? (
<div className="item-stats-mini">
{damage_min && (
<span className="stat-mini"> {damage_min}-{damage_max}</span>
)}
{armor && (
<span className="stat-mini">🛡 {armor}</span>
)}
</div>
) : null;
})()}
{/* Condition bar for Salvage tab */}
{workbenchTab === 'uncraft' && item.durability_percent !== undefined && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div className="mini-progress-bar" style={{ flex: 1 }}>
<div
className={`mini-progress-fill ${item.durability_percent < 30 ? 'critical' : item.durability_percent < 70 ? 'warning' : 'good'}`}
style={{ width: `${item.durability_percent}%` }}
></div>
</div>
{(item.current_durability !== undefined && item.current_durability !== null) && (
<span className="stat-mini durability" style={{ flexShrink: 0 }}>🔧 {item.current_durability}/{item.max_durability}</span>
)}
</div>
)}
{/* Progress Bar for Repair tab */}
{workbenchTab === 'repair' && item.durability_percent !== undefined && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div className="mini-progress-bar" style={{ flex: 1 }}>
<div
className={`mini-progress-fill ${item.durability_percent < 30 ? 'critical' : item.durability_percent < 70 ? 'warning' : 'good'}`}
style={{ width: `${item.durability_percent}%` }}
></div>
</div>
{(item.current_durability !== undefined && item.current_durability !== null) && (
<span className="stat-mini durability" style={{ flexShrink: 0 }}>🔧 {item.current_durability}/{item.max_durability}</span>
)}
</div>
)}
</div>
</div>
)
})
)}
</div>
</div>
{/* Column 3: Details */}
<div className="workbench-details-column">
{renderItemDetails()}
</div>
</div>
</div>
</div>
)
}
export default Workbench

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,82 @@
// Game-related TypeScript interfaces and types
export interface PlayerState {
location_id: string
location_name: string
health: number
max_health: number
stamina: number
max_stamina: number
inventory: any[]
status_effects: any[]
}
export interface DirectionDetail {
direction: string
stamina_cost: number
distance: number
destination: string
destination_name?: string
}
export interface Location {
id: string
name: string
description: string
directions: string[]
directions_detailed?: DirectionDetail[]
danger_level?: number
npcs: any[]
items: any[]
image_url?: string
interactables?: any[]
other_players?: any[]
corpses?: any[]
tags?: string[]
}
export interface Profile {
name: string
level: number
xp: number
hp: number
max_hp: number
stamina: number
max_stamina: number
strength: number
agility: number
endurance: number
intellect: number
unspent_points: number
is_dead: boolean
max_weight?: number
current_weight?: number
max_volume?: number
current_volume?: number
}
export interface CombatLogEntry {
time: string
message: string
isPlayer: boolean
}
export interface LocationMessage {
time: string
message: string
}
export interface Equipment {
[slot: string]: any
}
export interface CombatState {
is_pvp?: boolean
in_pvp_combat?: boolean
pvp_combat?: any
combat_over?: boolean
[key: string]: any
}
export type WorkbenchTab = 'craft' | 'repair' | 'uncraft'
export type MobileMenuState = 'none' | 'left' | 'right' | 'bottom'