This commit is contained in:
Joan
2026-02-23 15:42:21 +01:00
parent a725ae5836
commit d38d4cc288
102 changed files with 4511 additions and 4454 deletions

View File

@@ -3,331 +3,358 @@
max-width: 1200px;
margin: 0 auto;
color: #fff;
min-height: calc(100vh - 80px);
/* Account for header */
}
/* Ensure the main container inherits the global border radius/clip-path correctly without duplicating it */
.account-container {
background: rgba(0, 0, 0, 0.8);
border-radius: 8px;
padding: 2rem;
backdrop-filter: blur(10px);
max-width: 1000px;
width: 100%;
margin: 0 auto;
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
}
.account-panel-override {
border-radius: 0;
/* Let the game-panel class handle the shape */
}
/* Clip paths for inner containers */
.game-panel.inner {
border-radius: 0;
clip-path: var(--game-clip-path);
background: rgba(0, 0, 0, 0.4) !important;
}
.account-header-top {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 2rem;
background: rgba(0, 0, 0, 0.4);
border-bottom: 1px solid var(--game-border-color);
}
.account-top-actions {
display: flex;
gap: 1rem;
align-items: center;
}
.account-title {
font-size: 2.5rem;
margin-bottom: 2rem;
text-align: center;
color: #e0e0e0;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
font-size: 2rem;
text-transform: uppercase;
letter-spacing: 2px;
margin: 0;
color: var(--game-color-primary);
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
}
.account-layout {
display: flex;
flex-direction: column;
}
@media (min-width: 768px) {
.account-layout {
flex-direction: row;
min-height: 550px;
/* Force minimum height to prevent jumping */
}
}
/* Tabs Navigation */
.account-tabs {
display: flex;
flex-direction: row;
background: rgba(0, 0, 0, 0.3);
border-bottom: 1px solid var(--game-border-color);
overflow-x: auto;
}
@media (min-width: 768px) {
.account-tabs {
flex-direction: column;
width: 250px;
min-width: 250px;
border-bottom: none;
border-right: 1px solid var(--game-border-color);
}
}
.account-tab {
display: flex;
align-items: center;
gap: 0.8rem;
padding: 1.2rem 1.5rem;
background: transparent;
border: none;
color: var(--game-text-secondary);
font-size: 1.1rem;
text-transform: uppercase;
letter-spacing: 1px;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
text-align: left;
border-bottom: 2px solid transparent;
/* default for mobile */
}
@media (min-width: 768px) {
.account-tab {
border-bottom: none;
border-left: 3px solid transparent;
}
}
.account-tab:hover {
background: rgba(255, 255, 255, 0.05);
color: #fff;
}
.account-tab.active {
color: var(--game-color-primary);
background: rgba(var(--game-color-primary-rgb), 0.1);
}
@media (max-width: 767px) {
.account-tab.active {
border-bottom: 2px solid var(--game-color-primary);
}
}
@media (min-width: 768px) {
.account-tab.active {
border-left: 3px solid var(--game-color-primary);
}
}
.tab-icon {
font-size: 1.2rem;
opacity: 0.8;
}
.account-tab.active .tab-icon {
opacity: 1;
}
/* Content Area */
.account-content {
flex: 1;
padding: 2rem;
background: rgba(0, 0, 0, 0.2);
}
.account-section {
margin-bottom: 3rem;
padding-bottom: 2rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.account-section:last-child {
border-bottom: none;
/* Make sure container heights don't jump on tab swaps */
.fixed-height-section {
min-height: 400px;
}
.section-title {
font-size: 1.5rem;
margin-bottom: 1.5rem;
color: #bbb;
border-left: 4px solid #4a9eff;
padding-left: 1rem;
text-transform: uppercase;
letter-spacing: 1px;
color: #fff;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding-bottom: 0.5rem;
margin-bottom: 0.5rem;
}
.subsection-title {
font-size: 1.2rem;
color: var(--game-text-secondary);
margin: 1.5rem 0 1rem;
}
/* General Tab Adjustments */
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
border: 1px solid var(--game-border-color);
/* Kept borders around clipping */
padding: 1.5rem;
}
.info-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
gap: 0.4rem;
}
.info-label {
color: #888;
font-size: 0.9rem;
font-size: 0.85rem;
text-transform: uppercase;
color: var(--game-text-secondary);
letter-spacing: 0.5px;
}
.info-value {
font-size: 1.1rem;
font-weight: 500;
color: #fff;
}
.info-value.premium {
color: #ffd700;
text-shadow: 0 0 10px rgba(255, 215, 0, 0.3);
color: var(--game-color-success);
font-weight: 700;
text-shadow: 0 0 8px rgba(var(--game-color-success-rgb), 0.4);
}
/* Characters Grid */
.characters-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
.character-actions-area {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
/* Shared Settings Components */
.setting-item-ui {
border: 1px solid var(--game-border-color);
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.character-card {
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
padding: 1.5rem;
transition: transform 0.2s, background 0.2s;
.setting-item-ui:last-child {
margin-bottom: 0;
}
.character-card:hover {
transform: translateY(-2px);
background: rgba(255, 255, 255, 0.1);
}
.character-header {
.setting-header-ui {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.character-header h3 {
margin: 0;
color: #fff;
}
.character-level {
background: #4a9eff;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
font-weight: bold;
}
.character-stats {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
.stat {
display: flex;
gap: 0.5rem;
color: #aba;
}
.character-attributes {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
font-size: 0.9rem;
color: #888;
margin-bottom: 1rem;
}
.no-characters {
text-align: center;
color: #888;
padding: 2rem;
background: rgba(0, 0, 0, 0.2);
border-radius: 6px;
margin-bottom: 1.5rem;
}
/* Settings */
.setting-item {
background: rgba(255, 255, 255, 0.05);
padding: 1.5rem;
border-radius: 6px;
margin-bottom: 1.5rem;
}
.setting-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.setting-header h3 {
.setting-header-ui h3 {
margin: 0;
font-size: 1.2rem;
}
.setting-form {
background: rgba(0, 0, 0, 0.2);
padding: 1.5rem;
border-radius: 4px;
margin-top: 1rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #bbb;
}
.form-group input {
width: 100%;
padding: 0.8rem;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
color: #fff;
}
.form-group input:focus {
border-color: #4a9eff;
outline: none;
}
/* Audio Settings */
.audio-settings {
.setting-form-ui {
display: flex;
flex-direction: column;
gap: 1rem;
gap: 1.2rem;
max-width: 400px;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.mute-toggle {
.form-group-ui {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group-ui label {
font-size: 0.9rem;
color: var(--game-text-secondary);
}
/* Remove border-radius rounded corners on inputs explicitly */
.squared-input {
border-radius: 0 !important;
background: rgba(0, 0, 0, 0.6) !important;
}
.audio-settings {
border: 1px solid var(--game-border-color);
padding: 1.5rem;
}
.volume-sliders-ui {
display: flex;
flex-direction: column;
gap: 1.5rem;
margin-top: 1.5rem;
/* Limit max width specifically to avoid slider overflow on desktop */
max-width: 90%;
}
.slider-group-ui {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
/* Ensure group spans correctly */
}
/* The wrapper contains the slider input to avoid bleeding over its container */
.slider-wrapper {
width: 100%;
padding: 0 10px;
/* Prevent thumb from bleeding outside at 100% width edge */
}
.slider-group-ui label {
font-size: 0.95rem;
color: #fff;
font-family: var(--game-font-primary);
}
.game-slider {
width: 100%;
box-sizing: border-box;
/* Prevent the thumb width from pushing it out */
margin: 0;
}
.mute-toggle-ui {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
user-select: none;
color: var(--game-text-secondary);
}
.mute-toggle input {
.mute-toggle-ui input {
cursor: pointer;
width: 1.2rem;
height: 1.2rem;
cursor: pointer;
}
.volume-sliders {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 2rem;
}
.slider-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.slider-group label {
font-size: 0.9rem;
color: #bbb;
}
.slider-group input[type="range"] {
width: 100%;
cursor: pointer;
height: 6px;
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
appearance: none;
}
.slider-group input[type="range"]::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
background: #4a9eff;
border-radius: 50%;
cursor: pointer;
transition: transform 0.1s;
}
.slider-group input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
/* Actions */
.account-actions {
display: flex;
justify-content: space-between;
margin-top: 2rem;
}
/* Buttons */
.button-primary,
.button-secondary,
.button-danger,
.button-link {
padding: 0.8rem 1.5rem;
border-radius: 4px;
border: none;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.button-primary {
background: #4a9eff;
color: #fff;
}
.button-primary:hover {
background: #3a8eef;
}
.button-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.button-secondary {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.button-secondary:hover {
background: rgba(255, 255, 255, 0.2);
}
.button-danger {
background: rgba(220, 53, 69, 0.2);
color: #ff6b6b;
border: 1px solid rgba(220, 53, 69, 0.3);
}
.button-danger:hover {
background: rgba(220, 53, 69, 0.3);
}
.button-link {
background: none;
color: #4a9eff;
padding: 0;
text-decoration: underline;
}
.button-link:hover {
text-decoration: none;
}
/* Notifications */
.error {
background: rgba(220, 53, 69, 0.1);
color: #ff6b6b;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
text-align: center;
.error-message-ui {
background: rgba(var(--game-color-danger-rgb), 0.1);
color: var(--game-color-danger);
padding: 0.8rem;
border-left: 3px solid var(--game-color-danger);
font-size: 0.9rem;
}
.message-success {
background: rgba(40, 167, 69, 0.1);
color: #5ddc6c;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
text-align: center;
.message-success-ui {
background: rgba(var(--game-color-success-rgb), 0.1);
color: var(--game-color-success);
padding: 0.8rem;
border-left: 3px solid var(--game-color-success);
font-size: 0.9rem;
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out forwards;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -1,18 +1,23 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../hooks/useAuth'
import { useAudio } from '../contexts/AudioContext'
import { authApi, Account, Character } from '../services/api'
import { authApi, Account } from '../services/api'
import { GameButton } from './common/GameButton'
import './AccountPage.css'
function AccountPage() {
const { t } = useTranslation()
const navigate = useNavigate()
const { logout } = useAuth()
const { currentCharacter } = useAuth()
const [account, setAccount] = useState<Account | null>(null)
const [characters, setCharacters] = useState<Character[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
// Tab State
const [activeTab, setActiveTab] = useState<'general' | 'audio' | 'security'>('general')
// Email change state
const [showEmailChange, setShowEmailChange] = useState(false)
const [newEmail, setNewEmail] = useState('')
@@ -47,10 +52,10 @@ function AccountPage() {
setLoading(true)
const data = await authApi.getAccount()
setAccount(data.account)
setCharacters(data.characters)
// characters are returned as data.characters but we don't display the list here anymore
setError('')
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to load account data')
setError(err.response?.data?.detail || t('common.error'))
} finally {
setLoading(false)
}
@@ -62,7 +67,7 @@ function AccountPage() {
setEmailSuccess('')
if (!newEmail || !emailPassword) {
setEmailError('Please fill in all fields')
setEmailError(t('common.error'))
return
}
@@ -76,7 +81,7 @@ function AccountPage() {
// Refresh account data
await fetchAccountData()
} catch (err: any) {
setEmailError(err.response?.data?.detail || 'Failed to change email')
setEmailError(err.response?.data?.detail || t('common.error'))
} finally {
setEmailLoading(false)
}
@@ -88,17 +93,17 @@ function AccountPage() {
setPasswordSuccess('')
if (!currentPassword || !newPassword || !confirmNewPassword) {
setPasswordError('Please fill in all fields')
setPasswordError(t('common.error'))
return
}
if (newPassword !== confirmNewPassword) {
setPasswordError('New passwords do not match')
setPasswordError(t('auth.errors.passwordMatch'))
return
}
if (newPassword.length < 6) {
setPasswordError('New password must be at least 6 characters')
setPasswordError(t('auth.errors.passwordLength'))
return
}
@@ -111,7 +116,7 @@ function AccountPage() {
setConfirmNewPassword('')
setShowPasswordChange(false)
} catch (err: any) {
setPasswordError(err.response?.data?.detail || 'Failed to change password')
setPasswordError(err.response?.data?.detail || t('common.error'))
} finally {
setPasswordLoading(false)
}
@@ -135,7 +140,7 @@ function AccountPage() {
if (loading) {
return (
<div className="account-page">
<div className="account-loading">Loading account...</div>
<div className="account-loading game-panel">{t('common.loading')}</div>
</div>
)
}
@@ -143,10 +148,12 @@ function AccountPage() {
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 className="account-error game-panel">
<h2 className="error-title">{t('common.error')}</h2>
<p>{error || t('common.error')}</p>
<GameButton variant="secondary" onClick={() => navigate(currentCharacter ? '/game' : '/characters')}>
{t('common.back')}
</GameButton>
</div>
</div>
)
@@ -154,273 +161,268 @@ function AccountPage() {
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 className="account-container game-panel account-panel-override">
<div className="account-header-top">
<h1 className="account-title">{t('common.accountSettings')}</h1>
<div className="account-top-actions">
<GameButton variant="secondary" onClick={() => navigate(currentCharacter ? '/game' : '/characters')}>
{currentCharacter ? t('game.dialog.back') : t('common.back')}
</GameButton>
{/* Logout removed from here, user wants it only in header */}
</div>
</section>
</div>
{/* 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 className="account-layout">
{/* Tabs Navigation */}
<div className="account-tabs">
<button
className={`account-tab ${activeTab === 'general' ? 'active' : ''}`}
onClick={() => setActiveTab('general')}
>
<span className="tab-icon">👤</span> {t('common.general')}
</button>
<button
className={`account-tab ${activeTab === 'audio' ? 'active' : ''}`}
onClick={() => setActiveTab('audio')}
>
<span className="tab-icon">🎵</span> {t('common.audio')}
</button>
<button
className={`account-tab ${activeTab === 'security' ? 'active' : ''}`}
onClick={() => setActiveTab('security')}
>
<span className="tab-icon">🔒</span> {t('common.security')}
</button>
</div>
{/* Tab Content Areas */}
<div className="account-content">
{/* GENERAL TAB */}
{activeTab === 'general' && (
<section className="account-section animate-fade-in fixed-height-section">
<h2 className="section-title">{t('auth.accountInfo')}</h2>
<div className="info-grid game-panel inner">
<div className="info-item">
<span className="info-label">{t('auth.email')}</span>
<span className="info-value">{account.email}</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 className="info-item">
<span className="info-label">{t('auth.accountType')}</span>
<span className="info-value">{getAccountTypeDisplay(account.account_type)}</span>
</div>
<div className="info-item">
<span className="info-label">{t('auth.premiumStatus')}</span>
<span className={`info-value ${account.premium_expires_at ? 'premium' : ''}`}>
{account.premium_expires_at && Number(account.premium_expires_at) > Date.now() / 1000
? t('auth.premiumActive')
: t('auth.freeAccount')}
</span>
</div>
<div className="info-item">
<span className="info-label">{t('auth.created')}</span>
<span className="info-value">{formatDate(account.created_at)}</span>
</div>
<div className="info-item">
<span className="info-label">{t('auth.lastLogin')}</span>
<span className="info-value">{formatDate(account.last_login_at)}</span>
</div>
</div>
<div className="character-actions-area">
<h3 className="subsection-title">{t('auth.gameActions')}</h3>
<GameButton variant="primary" onClick={() => navigate('/characters')}>
{t('auth.switchCharacter')}
</GameButton>
</div>
</section>
)}
{/* AUDIO TAB */}
{activeTab === 'audio' && (
<section className="account-section animate-fade-in fixed-height-section">
<h2 className="section-title">{t('auth.audioSettings')}</h2>
<div className="audio-settings game-panel inner">
<div className="setting-header-ui">
<h3>{t('auth.volumeControls')}</h3>
<label className="mute-toggle-ui">
<input
type="checkbox"
checked={isMuted}
onChange={(e) => setIsMuted(e.target.checked)}
/>
<span>{t('auth.muteAll')}</span>
</label>
</div>
<div className="volume-sliders-ui">
<div className="slider-group-ui">
<label>{t('auth.masterVolume')}: {Math.round(masterVolume * 100)}%</label>
<div className="slider-wrapper">
<input
type="range"
min="0"
max="1"
step="0.01"
className="game-slider"
value={masterVolume}
onChange={(e) => setMasterVolume(parseFloat(e.target.value))}
disabled={isMuted}
/>
</div>
</div>
<div className="stat">
<span className="stat-label">Stamina:</span>
<span className="stat-value">{char.stamina}/{char.max_stamina}</span>
<div className="slider-group-ui">
<label>{t('auth.musicVolume')}: {Math.round(musicVolume * 100)}%</label>
<div className="slider-wrapper">
<input
type="range"
min="0"
max="1"
step="0.01"
className="game-slider"
value={musicVolume}
onChange={(e) => setMusicVolume(parseFloat(e.target.value))}
disabled={isMuted}
/>
</div>
</div>
<div className="slider-group-ui">
<label>{t('auth.sfxVolume')}: {Math.round(sfxVolume * 100)}%</label>
<div className="slider-wrapper">
<input
type="range"
min="0"
max="1"
step="0.01"
className="game-slider"
value={sfxVolume}
onChange={(e) => setSfxVolume(parseFloat(e.target.value))}
disabled={isMuted}
/>
</div>
</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>
</section>
)}
{/* SECURITY TAB */}
{activeTab === 'security' && (
<section className="account-section animate-fade-in fixed-height-section">
<h2 className="section-title">{t('auth.securitySettings')}</h2>
{/* Email Change */}
<div className="setting-item-ui game-panel inner">
<div className="setting-header-ui">
<h3>{t('auth.changeEmail')}</h3>
<GameButton
variant="secondary"
size="sm"
onClick={() => setShowEmailChange(!showEmailChange)}
>
{showEmailChange ? t('auth.cancel') : t('auth.change')}
</GameButton>
</div>
<button
className="button-secondary"
onClick={() => navigate(`/profile/${char.id}`)}
>
View Profile
</button>
{showEmailChange && (
<form onSubmit={handleEmailChange} className="setting-form-ui">
<div className="form-group-ui">
<label htmlFor="newEmail">{t('auth.email')}</label>
<input
type="email"
id="newEmail"
className="game-input squared-input"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
placeholder={t('auth.emailPlaceholder')}
required
disabled={emailLoading}
/>
</div>
<div className="form-group-ui">
<label htmlFor="emailPassword">{t('auth.currentPassword')}</label>
<input
type="password"
id="emailPassword"
className="game-input squared-input"
value={emailPassword}
onChange={(e) => setEmailPassword(e.target.value)}
placeholder={t('auth.verifyIdentity')}
required
disabled={emailLoading}
/>
</div>
{emailError && <div className="error-message-ui">{emailError}</div>}
{emailSuccess && <div className="message-success-ui">{emailSuccess}</div>}
<GameButton variant="primary" disabled={emailLoading} onClick={() => { }}>
{emailLoading ? t('auth.updating') : t('auth.updateEmail')}
</GameButton>
</form>
)}
</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">Audio Settings</h2>
<div className="audio-settings">
<div className="setting-item">
<div className="setting-header">
<h3>Volume Controls</h3>
<label className="mute-toggle">
<input
type="checkbox"
checked={isMuted}
onChange={(e) => setIsMuted(e.target.checked)}
/>
<span>Mute All</span>
</label>
</div>
<div className="volume-sliders">
<div className="slider-group">
<label>Master Volume: {Math.round(masterVolume * 100)}%</label>
<input
type="range"
min="0"
max="1"
step="0.01"
value={masterVolume}
onChange={(e) => setMasterVolume(parseFloat(e.target.value))}
disabled={isMuted}
/>
{/* Password Change */}
<div className="setting-item-ui game-panel inner">
<div className="setting-header-ui">
<h3>{t('auth.changePassword')}</h3>
<GameButton
variant="secondary"
size="sm"
onClick={() => setShowPasswordChange(!showPasswordChange)}
>
{showPasswordChange ? t('auth.cancel') : t('auth.change')}
</GameButton>
</div>
{showPasswordChange && (
<form onSubmit={handlePasswordChange} className="setting-form-ui">
<div className="form-group-ui">
<label htmlFor="currentPassword">{t('auth.currentPassword')}</label>
<input
type="password"
id="currentPassword"
className="game-input squared-input"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder={t('auth.passwordPlaceholderLogin')}
required
disabled={passwordLoading}
/>
</div>
<div className="form-group-ui">
<label htmlFor="newPassword">{t('auth.newPassword')}</label>
<input
type="password"
id="newPassword"
className="game-input squared-input"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder={t('auth.passwordPlaceholder')}
required
disabled={passwordLoading}
/>
</div>
<div className="form-group-ui">
<label htmlFor="confirmNewPassword">{t('auth.confirmNewPassword')}</label>
<input
type="password"
id="confirmNewPassword"
className="game-input squared-input"
value={confirmNewPassword}
onChange={(e) => setConfirmNewPassword(e.target.value)}
placeholder={t('auth.confirmPasswordPlaceholder')}
required
disabled={passwordLoading}
/>
</div>
{passwordError && <div className="error-message-ui">{passwordError}</div>}
{passwordSuccess && <div className="message-success-ui">{passwordSuccess}</div>}
<GameButton variant="primary" disabled={passwordLoading} onClick={() => { }}>
{passwordLoading ? t('auth.updating') : t('auth.updatePassword')}
</GameButton>
</form>
)}
</div>
<div className="slider-group">
<label>Music Volume: {Math.round(musicVolume * 100)}%</label>
<input
type="range"
min="0"
max="1"
step="0.01"
value={musicVolume}
onChange={(e) => setMusicVolume(parseFloat(e.target.value))}
disabled={isMuted}
/>
</div>
<div className="slider-group">
<label>SFX Volume: {Math.round(sfxVolume * 100)}%</label>
<input
type="range"
min="0"
max="1"
step="0.01"
value={sfxVolume}
onChange={(e) => setSfxVolume(parseFloat(e.target.value))}
disabled={isMuted}
/>
</div>
</div>
</div>
</div>
</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="message-success">{emailSuccess}</div>}
<button type="submit" className="button-primary" disabled={emailLoading}>
{emailLoading ? 'Updating...' : 'Update Email'}
</button>
</form>
</section>
)}
</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="message-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>
</div>
)

View File

@@ -1,203 +1,156 @@
.character-creation-container {
.char-creation-page {
min-height: 100vh;
padding: 3rem 1rem;
background: radial-gradient(circle at center, rgba(225, 29, 72, 0.08) 0%, var(--game-bg-app) 100%);
font-family: var(--game-font-main);
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;
.char-creation-container {
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;
.char-creation-card {
padding: 3rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.creation-title {
font-size: 3rem;
text-transform: uppercase;
letter-spacing: 2px;
text-align: center;
margin-bottom: 0.5rem;
margin: 0;
color: var(--game-text-primary);
text-shadow: 0 0 10px rgba(225, 29, 72, 0.2);
}
.subtitle {
.creation-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;
color: var(--game-text-secondary);
text-transform: uppercase;
letter-spacing: 1px;
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 {
.creation-form {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
flex-direction: column;
gap: 2rem;
}
.stat-icon {
font-size: 1.5rem;
}
.stat-header label {
font-size: 1.1rem;
font-weight: 600;
color: #fff;
}
.stat-control {
.form-group-creation {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
flex-direction: column;
gap: 0.75rem;
}
.stat-control input {
flex: 1;
text-align: center;
font-size: 1.2rem;
font-weight: bold;
padding: 0.5rem;
margin: 0;
.form-group-creation label {
text-transform: uppercase;
letter-spacing: 1px;
color: var(--game-text-secondary);
font-size: 1rem;
}
.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;
.attributes-section {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 1.5rem;
}
.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 {
.attributes-header {
display: flex;
justify-content: space-between;
align-items: center;
background-color: rgba(0, 0, 0, 0.3);
padding: 0.75rem 1rem;
border-radius: 8px;
border-bottom: 1px solid var(--game-border-color);
padding-bottom: 0.5rem;
}
.preview-label {
font-weight: 600;
color: #aaa;
.attributes-header h3 {
text-transform: uppercase;
letter-spacing: 1px;
margin: 0;
color: var(--game-color-primary);
}
.preview-value {
font-size: 1.2rem;
font-weight: bold;
color: #646cff;
.points-remaining {
font-weight: 700;
color: var(--game-text-primary);
}
.form-actions {
display: flex;
.points-remaining.zero {
color: var(--game-color-success);
}
.attributes-grid-creation {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1rem;
margin-top: 2rem;
}
.form-actions button {
.attribute-control {
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
background: var(--game-bg-glass) !important;
}
.attr-info {
display: flex;
justify-content: space-between;
align-items: center;
}
.attr-name {
text-transform: uppercase;
letter-spacing: 1px;
color: var(--game-text-secondary);
}
.attr-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--game-text-primary);
}
.attr-buttons {
display: flex;
gap: 0.5rem;
}
.attr-buttons button {
flex: 1;
}
@media (max-width: 768px) {
.character-creation-container {
padding: 1rem;
}
.character-creation-card {
.error-message-creation {
color: var(--game-text-danger);
background: rgba(239, 68, 68, 0.1);
padding: 1rem;
border-left: 3px solid var(--game-text-danger);
font-size: 0.95rem;
}
.creation-actions {
display: flex;
gap: 1.5rem;
}
.create-submit-btn {
flex: 2;
}
@media (max-width: 600px) {
.char-creation-card {
padding: 1.5rem;
}
.character-creation-card h1 {
font-size: 1.5rem;
.creation-actions {
flex-direction: column-reverse;
}
.stat-control input {
font-size: 1rem;
}
.preview-stats {
grid-template-columns: 1fr;
}
}
}

View File

@@ -1,89 +1,50 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../hooks/useAuth'
import { GameButton } from './common/GameButton'
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 [stats, setStats] = useState({
strength: 10,
agility: 10,
endurance: 10,
intellect: 10
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const navigate = useNavigate()
const TOTAL_POINTS = 20
const usedPoints = strength + agility + endurance + intellect
const remainingPoints = TOTAL_POINTS - usedPoints
const totalPoints = 40
const pointsUsed = stats.strength + stats.agility + stats.endurance + stats.intellect
const pointsRemaining = totalPoints - pointsUsed
const calculateHP = (str: number) => 30 + (str * 2)
const calculateStamina = (end: number) => 20 + (end * 1)
const handleStatChange = (stat: keyof typeof stats, delta: number) => {
const newValue = stats[stat] + delta
if (newValue < 5 || newValue > 20) return
if (delta > 0 && pointsRemaining <= 0) return
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
}
setStats({
...stats,
[stat]: newValue
})
}
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')
if (pointsRemaining !== 0) {
setError('You must use all attribute points')
return
}
setLoading(true)
setError('')
try {
await createCharacter({
name,
strength,
agility,
endurance,
intellect,
})
navigate('/characters')
await createCharacter({ ...stats, name })
navigate('/game')
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to create character')
} finally {
@@ -91,175 +52,98 @@ function CharacterCreation() {
}
}
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>
<div className="char-creation-page">
<div className="char-creation-container">
<div className="char-creation-card game-panel">
<h1 className="creation-title">Character Creation</h1>
<p className="creation-subtitle">Forge your survivor for the wasteland</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"
<form onSubmit={handleSubmit} className="creation-form">
<div className="form-group-creation">
<label htmlFor="name">Survivor Name</label>
<input
type="text"
id="name"
className="game-input"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter survivor name..."
required
disabled={loading}
maxLength={20}
/>
</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 className="attributes-section">
<div className="attributes-header">
<h3>Attributes</h3>
<div className={`points-remaining ${pointsRemaining === 0 ? 'zero' : ''}`}>
Points Remaining: {pointsRemaining}
</div>
</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 className="attributes-grid-creation">
{(Object.keys(stats) as Array<keyof typeof stats>).map((stat) => (
<div key={stat} className="attribute-control game-panel">
<div className="attr-info">
<span className="attr-name">{stat}</span>
<span className="attr-value">{stats[stat]}</span>
</div>
<div className="attr-buttons">
<GameButton
variant="secondary"
size="sm"
onClick={(e) => {
e.preventDefault()
handleStatChange(stat, -1)
}}
disabled={loading || stats[stat] <= 5}
>
-
</GameButton>
<GameButton
variant="secondary"
size="sm"
onClick={(e) => {
e.preventDefault()
handleStatChange(stat, 1)
}}
disabled={loading || stats[stat] >= 20 || pointsRemaining <= 0}
>
+
</GameButton>
</div>
</div>
))}
</div>
</div>
</div>
{error && <div className="error">{error}</div>}
{error && <div className="error-message-creation">{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 className="creation-actions">
<GameButton
variant="secondary"
onClick={() => navigate('/characters')}
disabled={loading}
>
Cancel
</GameButton>
<GameButton
variant="primary"
size="lg"
className="create-submit-btn"
disabled={loading || pointsRemaining !== 0 || !name.trim()}
onClick={() => { }} // Handled by form submit
>
{loading ? 'Forging...' : 'Create Survivor'}
</GameButton>
</div>
</form>
</div>
</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

@@ -1,239 +1,277 @@
.character-selection-container {
/* Character Selection Page Styles */
/* Base container */
.char-selection-page {
min-height: 100vh;
padding: 2rem;
background: linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%);
padding: 3rem 2rem;
background: radial-gradient(circle at center, rgba(225, 29, 72, 0.08) 0%, var(--game-bg-app) 100%);
font-family: var(--game-font-main);
display: flex;
justify-content: center;
}
.character-selection-header {
.char-selection-container {
max-width: 1200px;
width: 100%;
display: flex;
flex-direction: column;
gap: 2.5rem;
}
/* Header */
.char-selection-header {
padding: 2.5rem;
text-align: center;
margin-bottom: 3rem;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
border-bottom: 2px solid var(--game-color-primary-low);
}
.title-main {
font-size: 3.5rem;
margin: 0;
text-transform: uppercase;
letter-spacing: 3px;
color: var(--game-text-primary);
text-shadow: 0 0 15px rgba(225, 29, 72, 0.3);
}
.subtitle-sub {
font-size: 1.25rem;
color: var(--game-text-secondary);
text-transform: uppercase;
letter-spacing: 4px;
margin: 0;
}
/* Cards Grid */
.char-cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 2rem;
}
/* Character Card */
.char-card-ui {
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
background: var(--game-bg-glass) !important;
transition: all 0.3s ease;
border: 1px solid rgba(255, 255, 255, 0.08);
/* Default border */
height: 100%;
/* Ensure uniform height */
}
.char-card-ui:hover {
border-color: var(--game-color-primary);
transform: translateY(-5px);
box-shadow: var(--game-shadow-glow);
}
/* Avatar Section */
.char-avatar-box {
width: 100%;
aspect-ratio: 16/9;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid var(--game-border-color);
overflow: hidden;
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 {
.char-avatar-box img {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.8;
transition: all 0.3s ease;
}
.avatar-placeholder {
font-size: 2rem;
font-weight: bold;
color: white;
.char-card-ui:hover .char-avatar-box img {
opacity: 1;
transform: scale(1.05);
}
.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 {
.avatar-placeholder-ui {
font-size: 4rem;
font-weight: 700;
color: var(--game-border-color);
opacity: 0.5;
cursor: not-allowed;
}
.create-character-card {
cursor: pointer;
border: 2px dashed #646cff;
background-color: rgba(100, 108, 255, 0.1);
/* Info Section */
.char-info-box {
padding: 1.5rem;
flex: 1;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.char-meta-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.char-meta-header h3 {
font-size: 1.6rem;
margin: 0;
color: var(--game-text-primary);
text-transform: uppercase;
}
.level-badge {
background: var(--game-color-primary);
color: #fff;
padding: 0.15rem 0.6rem;
font-weight: 700;
font-size: 0.9rem;
clip-path: var(--game-clip-path-sm);
}
/* Stats Preview */
.char-stats-preview {
display: flex;
background: rgba(0, 0, 0, 0.3);
border: 1px solid var(--game-border-color);
clip-path: var(--game-clip-path-sm);
}
.stat-preview {
flex: 1;
padding: 0.6rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
}
.stat-preview .label {
font-size: 0.75rem;
text-transform: uppercase;
color: var(--game-text-secondary);
}
.stat-preview .value {
font-size: 1.1rem;
font-weight: 700;
}
/* Attributes Grid */
.char-attr-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.attr-item {
background: rgba(255, 255, 255, 0.03);
padding: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
clip-path: var(--game-clip-path-sm);
}
.last-played {
font-size: 0.85rem;
color: var(--game-text-secondary);
}
/* Actions Section */
.char-card-actions {
padding: 1rem;
display: flex;
gap: 1rem;
width: 100%;
justify-content: center;
min-height: 250px;
border-top: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(0, 0, 0, 0.2);
}
.create-character-card:hover {
background-color: rgba(100, 108, 255, 0.2);
border-color: #535bf2;
/* Create Card Styles */
.create-card {
align-items: center;
justify-content: center;
min-height: 400px;
/* Approximate height of other cards */
cursor: pointer;
border: 2px dashed rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.2) !important;
}
.create-character-icon {
font-size: 4rem;
color: #646cff;
margin-bottom: 1rem;
.create-card:hover {
border-color: var(--game-color-primary);
background: rgba(225, 29, 72, 0.05) !important;
}
.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 {
.create-icon-wrapper {
width: 80px;
height: 80px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.05);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.5rem;
transition: all 0.3s ease;
}
.create-card:hover .create-icon-wrapper {
background: var(--game-color-primary);
transform: scale(1.1);
box-shadow: 0 0 15px rgba(225, 29, 72, 0.4);
}
.create-icon {
font-size: 3rem;
font-weight: 300;
color: var(--game-text-primary);
line-height: 1;
}
.create-card h3 {
font-size: 1.5rem;
color: var(--game-text-primary);
text-transform: uppercase;
margin-bottom: 0.5rem;
}
.create-subtitle {
color: var(--game-text-secondary);
font-size: 1rem;
}
.premium-banner button {
background-color: white;
color: #646cff;
font-weight: bold;
}
.premium-banner button:hover {
background-color: #f0f0f0;
}
.no-characters {
/* Premium Banner */
.premium-banner-ui {
grid-column: 1 / -1;
text-align: center;
color: #888;
padding: 3rem;
max-width: 500px;
margin: 0 auto;
padding: 2rem;
margin-top: 2rem;
background: linear-gradient(135deg, rgba(37, 99, 235, 0.1) 0%, rgba(37, 99, 235, 0.05) 100%);
border: 1px solid rgba(37, 99, 235, 0.3);
}
.no-characters p {
margin-bottom: 1rem;
font-size: 1.1rem;
.premium-banner-ui h3 {
color: #60a5fa;
margin-bottom: 0.5rem;
}
@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;
}
}
/* No Characters Empty State */
.no-chars-box {
grid-column: 1 / -1;
text-align: center;
padding: 4rem;
color: var(--game-text-secondary);
}

View File

@@ -1,16 +1,21 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../hooks/useAuth'
import { Character } from '../services/api'
import './CharacterSelection.css'
import { GameButton } from './common/GameButton'
import { GameTooltip } from './common/GameTooltip'
import { GameModal } from './game/GameModal'
import './CharacterSelection.css'
function CharacterSelection() {
const { characters, account, selectCharacter, deleteCharacter, logout } = useAuth()
const { characters, account, selectCharacter, deleteCharacter } = useAuth()
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [deletingId, setDeletingId] = useState<number | null>(null)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [characterToDelete, setCharacterToDelete] = useState<number | null>(null)
const navigate = useNavigate()
const { t } = useTranslation()
const handleSelectCharacter = async (characterId: number) => {
setLoading(true)
@@ -20,26 +25,31 @@ function CharacterSelection() {
await selectCharacter(characterId)
navigate('/game')
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to select character')
setError(err.response?.data?.detail || t('common.error'))
} 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
}
const confirmDelete = (characterId: number) => {
setCharacterToDelete(characterId)
setShowDeleteModal(true)
}
setDeletingId(characterId)
const handleDeleteCharacter = async () => {
if (!characterToDelete) return
setLoading(true)
setError('')
try {
await deleteCharacter(characterId)
await deleteCharacter(characterToDelete)
setShowDeleteModal(false)
setCharacterToDelete(null)
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to delete character')
setError(err.response?.data?.detail || t('common.error'))
} finally {
setDeletingId(null)
setLoading(false)
}
}
@@ -52,53 +62,73 @@ function CharacterSelection() {
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>
<div className="char-selection-page">
<div className="char-selection-container">
<div className="char-selection-header game-panel">
<h1 className="title-main">{t('characters.title')}</h1>
<p className="subtitle-sub">Echoes of the Ash</p>
</div>
{error && <div className="error">{error}</div>}
{error && <div className="error-banner game-panel">{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}
/>
))}
<div className="char-cards-grid">
{characters.map((character) => (
<CharacterCard
key={character.id}
character={character}
onSelect={() => handleSelectCharacter(character.id)}
onDelete={() => confirmDelete(character.id)}
loading={loading}
/>
))}
{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>
{canCreateCharacter && (
<div className="char-card-ui create-card game-panel" onClick={handleCreateCharacter}>
<div className="create-icon-wrapper">
<span className="create-icon">+</span>
</div>
<h3>{t('characters.create.title', 'Create New')}</h3>
<p className="create-subtitle">
{characters.length} / {maxCharacters} {t('characters.create.slots', 'slots used')}
</p>
</div>
)}
</div>
{!canCreateCharacter && !isPremium && (
<div className="premium-banner-ui game-panel">
<h3>{t('characters.premium.title', 'Character Limit Reached')}</h3>
<p>{t('characters.premium.description', 'Upgrade to Premium to create up to 10 characters!')}</p>
<GameButton variant="primary" onClick={() => { }}>{t('characters.premium.upgrade', 'Upgrade to Premium')}</GameButton>
</div>
)}
{characters.length === 0 && (
<div className="no-chars-box game-panel">
<p>{t('characters.noCharacters')}</p>
<p>{t('characters.createFirst')}</p>
</div>
)}
{showDeleteModal && (
<GameModal
title={t('characters.deleteModal.title', 'Delete Character')}
onClose={() => setShowDeleteModal(false)}
footer={
<div style={{ display: 'flex', gap: '1rem', justifyContent: 'flex-end', width: '100%' }}>
<GameButton variant="secondary" onClick={() => setShowDeleteModal(false)}>
{t('common.cancel')}
</GameButton>
<GameButton variant="danger" onClick={handleDeleteCharacter}>
{t('common.confirm')}
</GameButton>
</div>
}
>
<p>{t('characters.deleteModal.confirm', 'Are you sure you want to delete this character? This action cannot be undone.')}</p>
</GameModal>
)}
</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>
)
}
@@ -114,64 +144,97 @@ function CharacterCard({
onDelete: () => void
loading: boolean
}) {
const { t } = useTranslation()
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString()
try {
if (!dateString) return 'Never'
// Timestamp from API is float seconds, convert to ms
const timestamp = typeof dateString === 'number' ? dateString * 1000 : dateString
const date = new Date(timestamp)
// Check if date is valid (1970 usually means 0 timestamp/invalid in some contexts, but let's just check validity)
if (isNaN(date.getTime()) || date.getFullYear() === 1970) return 'Never'
return date.toLocaleDateString()
} catch (e) {
return 'Invalid Date'
}
}
return (
<div className="character-card">
<div className="character-avatar">
<div className="char-card-ui game-panel">
<div className="char-avatar-box">
{character.avatar_data?.image ? (
<img src={character.avatar_data.image} alt={character.name} />
) : (
<div className="avatar-placeholder">
<div className="avatar-placeholder-ui">
{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 className="char-info-box">
<div className="char-meta-header">
<h3>{character.name}</h3>
<span className="level-badge" style={{ clipPath: 'var(--game-clip-path-sm)', borderRadius: 0 }}>
{t('stats.level')} {character.level}
</span>
</div>
<div className="char-stats-preview">
<div className="stat-preview">
<span className="label text-red-400">{t('stats.hp')}</span>
<span className="value">{character.hp}/{character.max_hp}</span>
</div>
<div className="stat-preview">
<span className="label text-yellow-400">{t('stats.stamina')}</span>
<span className="value">{character.stamina}/{character.max_stamina}</span>
</div>
<div className="stat-preview">
<span className="label text-blue-400">{t('stats.weight')}</span>
<span className="value">{character.weight}/{character.max_weight}</span>
</div>
<div className="stat-preview">
<span className="label text-purple-400">{t('stats.volume')}</span>
<span className="value">{character.volume}/{character.max_volume}</span>
</div>
</div>
<div className="character-attributes">
<GameTooltip content="Strength">
<span className="stat-icon">💪 {character.strength}</span>
<div className="char-attr-grid">
<GameTooltip content={t('stats.strength')}>
<div className="attr-item">💪 {character.strength}</div>
</GameTooltip>
<GameTooltip content="Agility">
<span>⚡ {character.agility}</span>
<GameTooltip content={t('stats.agility')}>
<div className="attr-item"> {character.agility}</div>
</GameTooltip>
<GameTooltip content="Endurance">
<span>🛡️ {character.endurance}</span>
<GameTooltip content={t('stats.endurance')}>
<div className="attr-item">🛡 {character.endurance}</div>
</GameTooltip>
<GameTooltip content="Intellect">
<span>🧠 {character.intellect}</span>
<GameTooltip content={t('stats.intellect')}>
<div className="attr-item">🧠 {character.intellect}</div>
</GameTooltip>
</div>
<p className="character-meta">
Last played: {formatDate(character.last_played_at)}
<p className="last-played">
{t('characters.lastActive')}: {formatDate(character.last_played_at)}
</p>
</div>
<div className="character-actions">
<button
className="button-primary"
<div className="char-card-actions">
<GameButton
variant="primary"
onClick={onSelect}
disabled={loading}
className="play-btn"
>
{loading ? 'Loading...' : 'Play'}
</button>
<button
className="button-danger"
{loading ? t('common.loading') : t('characters.play')}
</GameButton>
<GameButton
variant="danger"
onClick={onDelete}
disabled={loading}
>
Delete
</button>
{t('characters.delete')}
</GameButton>
</div>
</div>
)

View File

@@ -317,7 +317,7 @@ html {
.game-main {
flex: 1;
padding: 1.5rem;
padding: var(--game-padding-md);
overflow: hidden;
min-height: 0;
}
@@ -332,7 +332,7 @@ html {
transition: all 0.2s ease;
height: 40px;
position: relative;
clip-path: var(--game-clip-path-sm);
clip-path: var(--game-clip-path);
}
.game-search-container:focus-within {
@@ -406,17 +406,6 @@ html {
}
/* Mobile fallback */
@media (max-width: 1200px) {
.explore-tab-desktop {
grid-template-columns: 1fr;
gap: 1rem;
}
.left-sidebar,
.right-sidebar {
padding: 0;
}
}
.location-info {
background: var(--game-bg-panel);
@@ -699,7 +688,7 @@ html {
margin: 0 auto;
aspect-ratio: 16 / 9;
overflow: hidden;
border: 2px solid rgba(255, 107, 107, 0.3);
border: 1px solid rgba(255, 107, 107, 0.3);
clip-path: var(--game-clip-path);
flex-shrink: 0;
}
@@ -1213,6 +1202,7 @@ body.no-scroll {
transition: all 0.3s;
min-width: 320px;
clip-path: var(--game-clip-path);
animation: fadeIn 0.2s ease-out;
}
.entity-card:hover {
@@ -1379,10 +1369,12 @@ body.no-scroll {
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@@ -2031,8 +2023,8 @@ body.no-scroll {
/* Changed from center to space-between */
gap: 0.25rem;
/* Fixed dimensions for consistent sizing */
height: 100px;
width: 100%;
height: 90px;
width: 90px;
box-sizing: border-box;
transition: all 0.2s;
cursor: pointer;
@@ -2072,14 +2064,17 @@ body.no-scroll {
}
.equipment-slot.filled {
border-color: rgba(255, 107, 107, 0.5);
padding: 0;
border: none;
background: transparent;
clip-path: none;
}
.equipment-slot.filled:hover {
border-color: #ff6b6b;
background: rgba(255, 107, 107, 0.1);
transform: scale(1.02);
box-shadow: 0 0 15px rgba(255, 107, 107, 0.3);
transform: none;
box-shadow: none;
background: transparent;
border: none;
z-index: 10001;
}
@@ -2096,36 +2091,6 @@ body.no-scroll {
/* Space out elements */
}
/* New unequip button in top-right corner */
.equipment-unequip-btn {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
background: rgba(244, 67, 54, 0.9);
border: 1px solid rgba(255, 255, 255, 0.3);
/* clip-path removed to ensure square box */
aspect-ratio: 1;
color: #fff;
font-size: 0.7rem;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
padding: 0;
z-index: 10;
line-height: 1;
}
.equipment-unequip-btn:hover {
background: rgba(244, 67, 54, 1);
transform: scale(1.1);
box-shadow: 0 2px 8px rgba(244, 67, 54, 0.6);
}
/* Equipment tooltip - shows on slot hover */
.equipment-tooltip {
display: none;
@@ -3623,33 +3588,6 @@ body.no-scroll {
}
/* Responsive Combat View */
@media (max-width: 768px) {
.combat-view {
padding: 1rem;
}
.combat-header-inline h2 {
font-size: 1.5rem;
}
.combat-enemy-info-inline h3 {
font-size: 1.3rem;
}
.combat-actions-inline {
grid-template-columns: 1fr 1fr;
/* Side by side on mobile */
gap: 0.75rem;
}
.combat-log-container {
padding: 0.75rem;
}
.combat-log-messages {
max-height: 200px;
}
}
/* Centered headings for consistency */
.centered-heading {
@@ -3835,9 +3773,7 @@ body.no-scroll {
margin-bottom: 1rem;
}
/* ============= MOBILE SLIDING MENUS ============= */
/* Hide mobile menu buttons on desktop */
/* Hide mobile menu elements (desktop-only game) */
.mobile-menu-buttons {
display: none;
}
@@ -3846,477 +3782,14 @@ body.no-scroll {
display: none;
}
/* Mobile menu overlay (darkens background when menu is open) */
.mobile-menu-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 998;
backdrop-filter: blur(2px);
}
/* Mobile header toggle button */
.mobile-header-toggle {
display: none;
}
/* Mobile Styles */
@media (max-width: 768px) {
/* Tab-style navigation bar at bottom */
.mobile-menu-buttons {
display: flex;
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: rgba(20, 20, 20, 1) !important;
/* Fully opaque */
border-top: 2px solid rgba(255, 107, 107, 0.5);
z-index: 1000;
/* Always on top */
padding: 0.5rem 0;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.8);
justify-content: space-around;
gap: 0;
height: 65px;
}
.mobile-menu-btn {
flex: 1;
height: 55px;
border: none;
border-radius: 0;
background: transparent;
color: rgba(255, 255, 255, 0.6);
font-size: 1.5rem;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.2rem;
position: relative;
}
.mobile-menu-btn::after {
content: '';
position: absolute;
bottom: 0;
left: 10%;
right: 10%;
height: 3px;
background: transparent;
border-radius: 3px 3px 0 0;
transition: all 0.2s ease;
}
.mobile-menu-btn:active {
background: rgba(255, 255, 255, 0.1);
}
.mobile-menu-btn.left-btn::after {
background: rgba(255, 107, 107, 0.8);
}
.mobile-menu-btn.bottom-btn::after {
background: rgba(255, 193, 7, 0.8);
}
.mobile-menu-btn.right-btn::after {
background: rgba(107, 147, 255, 0.8);
}
/* Active tab styles */
.mobile-menu-btn.left-btn.active {
color: rgb(255, 107, 107);
background: rgba(255, 107, 107, 0.1);
}
.mobile-menu-btn.bottom-btn.active {
color: rgb(255, 193, 7);
background: rgba(255, 193, 7, 0.1);
}
.mobile-menu-btn.right-btn.active {
color: rgb(107, 147, 255);
background: rgba(107, 147, 255, 0.1);
}
.mobile-menu-btn.active::after {
opacity: 1;
}
.mobile-menu-btn:not(.active)::after {
opacity: 0;
}
/* Disable bottom-btn during combat */
.mobile-menu-btn.bottom-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
pointer-events: none;
}
/* Show overlay when any menu is open */
.mobile-menu-overlay {
display: block;
}
/* Hide desktop 3-column layout on mobile */
.explore-tab-desktop {
display: block !important;
position: relative;
grid-template-columns: 1fr !important;
}
/* Mobile panels - hidden by default, slide in when open */
.mobile-menu-panel {
position: fixed;
top: 0;
bottom: 65px;
/* Stop 65px from bottom (above tab bar) */
width: 85vw;
max-width: 400px;
background: linear-gradient(135deg, rgba(20, 20, 20, 0.98), rgba(40, 40, 40, 0.98));
z-index: 999;
/* Below tab bar */
overflow-y: auto;
transition: transform 0.3s ease;
padding: 1rem;
padding-bottom: 1rem;
/* No extra padding needed */
box-shadow: 0 0 30px rgba(0, 0, 0, 0.8);
}
/* Left sidebar - slides from left */
.left-sidebar.mobile-menu-panel {
left: 0;
transform: translateX(-100%);
border-right: 3px solid rgba(255, 107, 107, 0.5);
}
.left-sidebar.mobile-menu-panel.open {
transform: translateX(0);
}
/* Right sidebar - slides from right */
.right-sidebar.mobile-menu-panel {
right: 0;
transform: translateX(100%);
border-left: 3px solid rgba(107, 147, 255, 0.5);
}
.right-sidebar.mobile-menu-panel.open {
transform: translateX(0);
}
/* Bottom panel (ground entities) - slides from bottom */
.ground-entities.mobile-menu-panel.bottom {
top: auto;
bottom: 65px;
/* Start 65px from bottom (above tab bar) */
left: 0;
right: 0;
width: 100%;
max-width: 100%;
height: calc(70vh - 65px);
/* Height minus tab bar */
transform: translateY(calc(100% + 65px));
/* Hide below screen */
border-top: 3px solid rgba(255, 193, 7, 0.5);
border-radius: 20px 20px 0 0;
padding-bottom: 1rem;
}
.ground-entities.mobile-menu-panel.bottom.open {
transform: translateY(0);
/* Slide up to bottom: 65px position */
}
/* Keep center content always visible on mobile */
.center-content {
display: block !important;
padding: 0;
}
/* Hide sidebars and ground entities by default on mobile (until menu opened) */
.left-sidebar:not(.open),
.right-sidebar:not(.open),
.ground-entities:not(.open) {
display: none;
}
/* When panel is open, show it */
.mobile-menu-panel.open {
display: block !important;
}
/* Adjust center content to be full width on mobile */
.location-info,
.message-box {
margin: 0.5rem;
}
/* Make compass slightly smaller on mobile when in panel */
.mobile-menu-panel .compass-grid {
grid-template-columns: repeat(3, 70px);
gap: 0.5rem;
}
.mobile-menu-panel .compass-btn {
width: 70px;
height: 70px;
}
.mobile-menu-panel .compass-center {
width: 70px;
height: 70px;
}
/* Always show item action buttons on mobile (no hover needed) */
.inventory-item-row-hover .item-actions-hover {
display: flex !important;
position: static;
margin-top: 0.5rem;
justify-content: flex-end;
}
.inventory-item-row-hover {
flex-direction: column;
align-items: stretch;
}
.item-action-btn {
min-width: 70px;
padding: 0.4rem 0.6rem;
font-size: 0.8rem;
}
/* Ensure right sidebar has proper background */
.right-sidebar {
background: linear-gradient(135deg, rgba(20, 20, 20, 0.98), rgba(40, 40, 40, 0.98));
padding: 1rem;
}
/* Make combat view always visible and prominent on mobile */
.combat-view {
position: relative;
z-index: 1;
}
/* Combat mode - maintain tab bar */
.game-main:has(.combat-view) .mobile-menu-buttons {
opacity: 0.9;
}
/* Fix item tooltips on mobile - allow overflow and reposition */
.inventory-items-scrollable {
overflow: visible !important;
}
.inventory-panel {
overflow: visible !important;
}
.right-sidebar.mobile-menu-panel {
overflow-y: auto !important;
overflow-x: visible !important;
}
.item-info-btn-container .item-info-tooltip {
right: auto;
left: 50%;
transform: translateX(-50%);
max-width: 90vw;
z-index: 10001;
}
/* Make sure tooltips show on touch */
.item-info-btn-container:active .item-info-tooltip,
.item-info-btn-container.show-tooltip .item-info-tooltip {
display: block;
}
/* Hide header on mobile, show toggle button */
.game-container {
position: relative;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
/* Prevent header from going outside viewport */
}
.game-header {
position: fixed;
top: 0;
left: -100%;
width: 80%;
max-width: 300px;
height: 100%;
z-index: 999;
background: rgba(20, 20, 20, 0.98) !important;
border-right: 2px solid rgba(255, 107, 107, 0.5);
border-bottom: none;
transform: none;
transition: left 0.3s ease;
overflow-y: auto;
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.8);
padding: 1.5rem !important;
padding-top: 4rem !important;
/* Space for X button */
padding-bottom: calc(65px + 1.5rem) !important;
/* Space for tab bar + padding */
flex-direction: column;
align-items: flex-start;
gap: 1.5rem;
}
.game-header.open {
left: 0;
}
.game-header h1 {
font-size: 1.3rem !important;
width: 100%;
text-align: center;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(255, 107, 107, 0.3);
}
.game-header .nav-links {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
}
.game-header .user-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
padding-top: 1rem;
border-top: 1px solid rgba(255, 107, 107, 0.3);
}
.nav-link,
.username-link {
padding: 0.75rem 1rem !important;
font-size: 0.95rem !important;
width: 100%;
text-align: left;
justify-content: flex-start;
}
.button-secondary {
width: 100%;
}
.mobile-header-toggle {
display: block;
position: fixed;
top: 10px;
left: 10px;
width: 45px;
height: 45px;
border-radius: 8px;
background: linear-gradient(135deg, rgba(40, 40, 40, 0.95), rgba(60, 60, 60, 0.95));
border: 2px solid rgba(255, 107, 107, 0.5);
color: #fff;
font-size: 1.3rem;
cursor: pointer;
z-index: 1001;
/* Above sidebar */
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
}
.mobile-header-toggle:active {
transform: scale(0.95);
}
/* Make game-main fill space and account for tab bar */
.game-main {
flex: 1;
overflow-y: auto;
margin-bottom: 65px;
/* Space for tab bar */
padding-bottom: 0 !important;
}
/* Compact location titles on mobile */
.location-info h2 {
font-size: 1.2rem !important;
line-height: 1.3 !important;
margin-bottom: 0.3rem !important;
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.location-badge {
font-size: 0.7rem !important;
padding: 0.2rem 0.4rem !important;
white-space: nowrap;
}
/* Toast notification for messages */
.message-box {
position: fixed !important;
top: 60px;
left: 50%;
transform: translateX(-50%);
width: 90%;
max-width: 400px;
z-index: 9999 !important;
margin: 0 !important;
animation: slideDown 0.3s ease;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.6);
cursor: pointer;
background: rgba(40, 40, 40, 0.98) !important;
/* Opaque background */
backdrop-filter: blur(10px);
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateX(-50%) translateY(-20px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
.message-box.fade-out {
animation: fadeOut 0.3s ease forwards;
}
@keyframes fadeOut {
from {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
to {
opacity: 0;
transform: translateX(-50%) translateY(-20px);
}
}
}
/* Utility classes */
.text-danger {
color: #ff4444 !important;
@@ -4573,6 +4046,120 @@ body.no-scroll {
border-color: #ff4444 !important;
box-shadow: 0 0 10px rgba(255, 68, 68, 0.5) !important;
display: flex !important;
/* Ensure it stays flex */
transform-origin: center;
}
/* GLOBAL GAME ITEM CARD */
.game-item-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: var(--game-bg-card);
border: 1px solid var(--game-border-color);
clip-path: var(--game-clip-path);
padding: 0.5rem;
aspect-ratio: 1;
position: relative;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: var(--game-shadow-sm);
width: 90px;
height: 90px;
box-sizing: border-box;
flex-shrink: 0;
}
.game-item-card:hover,
.game-item-card.active {
border-color: #63b3ed;
background: rgba(255, 255, 255, 0.1);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
z-index: 10;
}
.game-item-card.equipped {
border-color: #63b3ed;
background: rgba(66, 153, 225, 0.1);
}
.game-item-image-wrapper {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.game-item-img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.5));
}
.game-item-emoji.hidden {
display: none;
}
/* Tier Border Colors matching LocationView/TradeModal */
.game-item-card.text-tier-0 {
border-color: #a0aec0;
}
.game-item-card.text-tier-1 {
border-color: #ffffff;
}
.game-item-card.text-tier-2 {
border-color: #68d391;
}
.game-item-card.text-tier-3 {
border-color: #63b3ed;
}
.game-item-card.text-tier-4 {
border-color: #9f7aea;
}
.game-item-card.text-tier-5 {
border-color: #ed8936;
}
/* Global Item Quantity / Stack Badge */
.item-quantity-badge {
position: absolute;
bottom: 4px;
right: 4px;
background: rgba(0, 0, 0, 0.85);
color: #fff;
font-size: 0.75rem;
padding: 2px 5px;
clip-path: var(--game-clip-path-sm);
font-weight: bold;
border: 1px solid rgba(255, 255, 255, 0.2);
z-index: 20;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
line-height: 1;
}
.game-item-value-badge {
position: absolute;
bottom: 4px;
left: 4px;
background: rgba(0, 0, 0, 0.85);
color: #ffd700;
font-size: 0.75rem;
padding: 2px 5px;
clip-path: var(--game-clip-path-sm);
font-weight: bold;
border: 1px solid rgba(255, 255, 255, 0.2);
z-index: 20;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
line-height: 1;
}

View File

@@ -287,6 +287,26 @@ function Game() {
// Handled by GameHeader, ignore here
break
case 'global_quest_completed':
console.log('🌍 Global Quest Completed', message.data)
actions.addLocationMessage(`🎉 GLOBAL QUEST COMPLETED: ${message.data.title}`)
// Show unlocks if any
if (message.data.outcome?.unlocks && message.data.outcome.unlocks.length > 0) {
const unlocks = message.data.outcome.unlocks;
// @ts-ignore
const locationUnlocks = unlocks.filter((u: any) => u.type === 'location').map((u: any) => u.name).join(', ');
// @ts-ignore
const interactableUnlocks = unlocks.filter((u: any) => u.type === 'interactable').map((u: any) => u.name).join(', ');
if (locationUnlocks) actions.addLocationMessage(`🔓 New Locations Unlocked: ${locationUnlocks}`);
if (interactableUnlocks) actions.addLocationMessage(`🔓 New Content Unlocked: ${interactableUnlocks}`);
}
// Refresh everything to reflect unlocks and rewards
actions.fetchGameData()
break
default:
console.log('Unknown WebSocket message type:', message.type)
}

View File

@@ -324,63 +324,4 @@
height: 24px;
background: linear-gradient(to bottom, transparent, rgba(255, 255, 255, 0.2), transparent);
margin: 0 2px;
}
/* Responsive */
@media (max-width: 768px) {
.game-header {
padding: 0 0.5rem;
height: 56px;
}
.header-left h1 {
display: none;
/* Hide full text on mobile */
}
.header-title-container::after {
content: 'EotA';
/* Short title */
color: #fff;
font-weight: 800;
}
.nav-links {
margin-left: 0.5rem;
gap: 4px;
flex: 1;
}
.nav-link {
padding: 0 0.6rem;
font-size: 0.7rem;
clip-path: none;
/* Simplify for mobile touch */
border-radius: 4px;
}
.user-info {
gap: 0.4rem;
}
.player-count-badge {
padding: 0 6px;
}
.count-text {
display: none;
}
/* Dot only */
.username-link {
padding: 0 0.5rem;
max-width: 80px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
display: block;
/* block for ellipsis */
line-height: 34px;
}
}

View File

@@ -1,52 +1,47 @@
/* LandingPage.css */
.landing-page {
min-height: 100vh;
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 50%, #0f0f0f 100%);
color: #fff;
background-color: #050508;
color: #e2e8f0;
font-family: 'Inter', system-ui, sans-serif;
overflow-x: hidden;
}
/* Hero Section */
/* --- Hero Section --- */
.hero-section {
position: relative;
min-height: 100vh;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
overflow: hidden;
text-align: center;
padding: 0 1rem;
background-image: url('/landing-bg.webp');
background-size: cover;
background-position: center;
background-attachment: fixed;
}
/* Gradient Overlay for better text readability */
.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;
}
background: radial-gradient(circle at center, rgba(5, 5, 8, 0.4) 0%, rgba(5, 5, 8, 0.95) 90%);
z-index: 1;
}
.hero-content {
position: relative;
z-index: 1;
z-index: 2;
max-width: 800px;
text-align: center;
animation: fadeInUp 1s ease-out;
animation: fadeUp 1s ease-out;
}
@keyframes fadeInUp {
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(30px);
@@ -59,40 +54,39 @@
}
.hero-title {
font-family: 'Orbitron', sans-serif;
font-size: 4rem;
font-weight: 700;
font-weight: 900;
margin-bottom: 1rem;
background: linear-gradient(135deg, #646cff 0%, #8b5cf6 100%);
color: #fff;
text-transform: uppercase;
letter-spacing: 2px;
text-shadow: 0 0 20px rgba(225, 29, 72, 0.6);
background: linear-gradient(to right, #fff 20%, #fda4af 50%, #fff 80%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: glow 3s ease-in-out infinite;
background-size: 200% auto;
animation: shine 5s linear 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));
@keyframes shine {
to {
background-position: 200% center;
}
}
.hero-subtitle {
font-size: 1.5rem;
color: #ccc;
margin-bottom: 1.5rem;
color: #cbd5e1;
margin-bottom: 2rem;
font-weight: 300;
}
.hero-description {
font-size: 1.1rem;
color: #999;
line-height: 1.8;
margin-bottom: 2.5rem;
color: #94a3b8;
margin-bottom: 3rem;
line-height: 1.6;
max-width: 600px;
margin-left: auto;
margin-right: auto;
@@ -100,146 +94,205 @@
.hero-buttons {
display: flex;
gap: 1rem;
gap: 1.5rem;
justify-content: center;
flex-wrap: wrap;
}
.hero-button {
padding: 1rem 2.5rem;
font-size: 1.1rem;
min-width: 180px;
transition: all 0.3s ease;
font-family: 'Saira Condensed', sans-serif;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
font-size: 1.2rem !important;
}
.hero-button:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(100, 108, 255, 0.4);
}
/* Features Section */
/* --- Features Section --- */
.features-section {
padding: 6rem 2rem;
background: linear-gradient(180deg, transparent 0%, rgba(100, 108, 255, 0.05) 100%);
background-color: #050508;
position: relative;
overflow: hidden;
}
/* Tech grid background for features */
.features-section::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
linear-gradient(rgba(225, 29, 72, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(225, 29, 72, 0.03) 1px, transparent 1px);
background-size: 40px 40px;
opacity: 0.5;
pointer-events: none;
}
.section-title {
text-align: center;
font-family: 'Orbitron', sans-serif;
font-size: 2.5rem;
margin-bottom: 3rem;
color: #646cff;
font-weight: 600;
margin-bottom: 4rem;
color: #fff;
text-transform: uppercase;
letter-spacing: 2px;
position: relative;
z-index: 2;
}
.section-title::after {
content: '';
display: block;
width: 60px;
height: 4px;
background: #e11d48;
margin: 1rem auto 0;
box-shadow: 0 0 10px #e11d48;
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 3rem;
max-width: 1200px;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 2rem;
position: relative;
z-index: 2;
}
.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;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 2.5rem;
transition: all 0.3s ease;
animation: fadeIn 0.6s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
clip-path: polygon(20px 0, 100% 0, 100% calc(100% - 20px), calc(100% - 20px) 100%, 0 100%, 0 20px);
position: relative;
overflow: hidden;
}
.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);
transform: translateY(-10px);
background: rgba(255, 255, 255, 0.06);
border-color: rgba(225, 29, 72, 0.4);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
}
.feature-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 4px;
background: linear-gradient(90deg, transparent, #e11d48, transparent);
opacity: 0;
transition: opacity 0.3s;
}
.feature-card:hover::before {
opacity: 1;
}
.feature-icon {
font-size: 3rem;
margin-bottom: 1rem;
filter: drop-shadow(0 0 10px rgba(100, 108, 255, 0.5));
margin-bottom: 1.5rem;
background: rgba(225, 29, 72, 0.1);
width: 80px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
color: #e11d48;
}
.feature-card h3 {
font-size: 1.5rem;
font-family: 'Orbitron', sans-serif;
font-size: 1.4rem;
margin-bottom: 1rem;
color: #fff;
letter-spacing: 1px;
}
.feature-card p {
color: #aaa;
color: #94a3b8;
line-height: 1.6;
margin-bottom: 1rem;
margin-bottom: 1.5rem;
}
.feature-screenshot {
width: 100%;
border-radius: 8px;
margin-top: 1rem;
border: 1px solid rgba(100, 108, 255, 0.3);
transition: all 0.3s ease;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.1);
transition: transform 0.3s;
}
.feature-screenshot:hover {
.feature-card:hover .feature-screenshot {
transform: scale(1.02);
box-shadow: 0 8px 20px rgba(100, 108, 255, 0.3);
border-color: rgba(255, 255, 255, 0.3);
}
/* About Section */
/* --- About Section --- */
.about-section {
padding: 6rem 2rem;
background: rgba(26, 26, 26, 0.8);
background: linear-gradient(to bottom, #050508, #0f0f13);
text-align: center;
}
.about-content {
max-width: 800px;
margin: 0 auto;
text-align: center;
}
.about-content p {
font-size: 1.1rem;
font-size: 1.2rem;
color: #cbd5e1;
line-height: 1.8;
color: #bbb;
margin-bottom: 1.5rem;
}
/* Footer */
/* --- Footer --- */
.landing-footer {
padding: 2rem;
text-align: center;
background: #0a0a0a;
border-top: 1px solid rgba(100, 108, 255, 0.2);
padding: 3rem 2rem;
background-color: #020203;
border-top: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
}
.landing-footer p {
color: #666;
color: #64748b;
font-size: 0.9rem;
}
/* Responsive Design */
.footer-links {
display: flex;
gap: 2rem;
}
.footer-links span {
color: #94a3b8;
cursor: pointer;
transition: color 0.2s;
font-size: 0.9rem;
}
.footer-links span:hover {
color: #e11d48;
}
/* --- Responsive Adjustments --- */
@media (max-width: 768px) {
.hero-title {
font-size: 2.5rem;
}
.hero-subtitle {
font-size: 1.2rem;
}
.hero-description {
font-size: 1rem;
font-size: 1.1rem;
}
.section-title {
@@ -250,19 +303,9 @@
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;
gap: 1rem;
}
.hero-button {

View File

@@ -1,11 +1,14 @@
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../hooks/useAuth'
import { useEffect } from 'react'
import { GameButton } from './common/GameButton'
import { useTranslation } from 'react-i18next'
import './LandingPage.css'
function LandingPage() {
const navigate = useNavigate()
const { isAuthenticated } = useAuth()
const { t } = useTranslation()
// Redirect authenticated users to characters page
useEffect(() => {
@@ -19,26 +22,28 @@ function LandingPage() {
{/* 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>
<h1 className="hero-title">{t('landing.heroTitle')}</h1>
<p className="hero-subtitle">{t('landing.heroSubtitle')}</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.
{t('landing.about.description')}
</p>
<div className="hero-buttons">
<button
className="button-primary hero-button"
<GameButton
variant="primary"
size="lg"
className="hero-button"
onClick={() => navigate('/register')}
>
Start Your Journey
</button>
<button
className="button-secondary hero-button"
{t('landing.playNow')}
</GameButton>
<GameButton
variant="secondary"
size="lg"
className="hero-button"
onClick={() => navigate('/login')}
>
Login
</button>
{t('landing.login')}
</GameButton>
</div>
</div>
<div className="hero-gradient"></div>
@@ -46,73 +51,46 @@ function LandingPage() {
{/* Features Section */}
<section className="features-section">
<h2 className="section-title">Game Features</h2>
<h2 className="section-title">{t('landing.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" />
<h3>{t('landing.featureCards.combat.title')}</h3>
<p>{t('landing.featureCards.combat.description')}</p>
</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" />
<h3>{t('landing.featureCards.survival.title')}</h3>
<p>{t('landing.featureCards.survival.description')}</p>
</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>
<h3>{t('landing.featureCards.crafting.title')}</h3>
<p>{t('landing.featureCards.crafting.description')}</p>
</div>
</div>
</section>
{/* About Section */}
<section className="about-section">
<h2 className="section-title">About the Game</h2>
<h2 className="section-title">{t('landing.about.title')}</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.
{t('landing.about.description')}
</p>
</div>
</section>
{/* Footer */}
<footer className="landing-footer">
<p>&copy; 2025 Echoes of the Ash. All rights reserved.</p>
<p>{t('landing.footer.copyright', { year: 2026 })}</p>
<div className="footer-links">
<span onClick={() => navigate('/privacy')}>{t('landing.footer.links.privacy')}</span>
<span onClick={() => navigate('/terms')}>{t('landing.footer.links.terms')}</span>
<span onClick={() => window.open('https://discord.gg/8QWK9QcNqm', '_blank')}>{t('landing.footer.links.discord')}</span>
</div>
</footer>
</div>
)

View File

@@ -10,7 +10,6 @@
padding: 0.5rem 0.75rem;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
color: #fff;
cursor: pointer;
font-size: 0.9rem;

View File

@@ -101,7 +101,9 @@
color: #fff;
}
.leaderboard-loading, .leaderboard-error, .leaderboard-empty {
.leaderboard-loading,
.leaderboard-error,
.leaderboard-empty {
text-align: center;
padding: 4rem 2rem;
}
@@ -117,7 +119,9 @@
}
@keyframes spin {
to { transform: rotate(360deg); }
to {
transform: rotate(360deg);
}
}
.leaderboard-error button {
@@ -305,293 +309,4 @@
color: rgba(255, 255, 255, 0.8);
font-size: 1rem;
font-weight: 600;
}
/* Mobile responsive */
@media (max-width: 1024px) {
.game-main .leaderboards-container {
grid-template-columns: 1fr;
}
.stat-selector {
position: static;
}
.stat-options {
display: grid;
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
/* Remove tab bar spacing for leaderboards page */
.game-main {
margin-bottom: 0 !important;
}
.game-main .leaderboards-container {
padding: 0.75rem;
padding-top: 4rem; /* Space for hamburger button */
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
max-width: 100vw;
overflow-x: hidden;
box-sizing: border-box;
}
/* Hide desktop stat selector on mobile */
.stat-selector {
display: none;
}
.stat-selector h3 {
display: none;
}
/* Dropdown-style selector on mobile */
.stat-options {
position: relative;
display: block;
cursor: pointer;
background: rgba(0, 0, 0, 0.6);
border: 2px solid rgba(107, 185, 240, 0.3);
border-radius: 8px;
width: 90%;
max-width: 350px;
margin: 0 auto;
}
.stat-option {
width: 100%;
border: none;
border-radius: 0;
margin: 0;
padding: 1rem;
background: transparent;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
cursor: pointer;
transition: background 0.2s;
}
.stat-option:hover {
background: rgba(255, 255, 255, 0.05);
}
.stat-option:first-child {
border-radius: 6px 6px 0 0;
}
.stat-option:last-child {
border-bottom: none;
border-radius: 0 0 6px 6px;
}
/* Show only active by default */
.stat-option:not(.active) {
display: none;
}
.stat-option.active {
background: rgba(107, 185, 240, 0.15);
border-radius: 6px;
position: relative;
}
/* Add dropdown arrow to active option */
.stat-option.active::after {
content: '▼';
position: absolute;
right: 1rem;
opacity: 0.7;
font-size: 0.8rem;
pointer-events: none;
}
/* Show all options when expanded */
.stat-options.expanded .stat-option:not(.active) {
display: flex;
}
.stat-options.expanded .stat-option.active {
border-radius: 6px 6px 0 0;
}
.stat-options.expanded .stat-option.active::after {
content: '▲';
}
.stat-options.expanded {
background: rgba(0, 0, 0, 0.98);
border-radius: 6px;
border-color: rgba(107, 185, 240, 0.6);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 100;
}
.leaderboard-content {
padding: 0.75rem;
width: 100%;
box-sizing: border-box;
overflow-x: hidden;
}
.leaderboard-title {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem;
margin-bottom: 1rem;
position: relative;
}
.leaderboard-title.dropdown-open {
z-index: 100;
}
.title-left {
width: 100%;
}
.clickable-title {
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem;
margin: -0.5rem;
border-radius: 8px;
transition: background 0.2s;
}
.clickable-title:active {
background: rgba(255, 255, 255, 0.05);
}
.dropdown-arrow {
margin-left: auto;
font-size: 0.9rem;
opacity: 0.7;
}
.title-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.98);
border: 2px solid rgba(107, 185, 240, 0.6);
border-top: none;
border-radius: 0 0 12px 12px;
margin-top: -0.75rem;
padding-top: 0.75rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
z-index: 101;
max-height: 400px;
overflow-y: auto;
}
.title-dropdown-option {
width: 100%;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: transparent;
border: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
color: #fff;
cursor: pointer;
transition: background 0.2s;
text-align: left;
}
.title-dropdown-option:last-child {
border-bottom: none;
border-radius: 0 0 10px 10px;
}
.title-dropdown-option:hover,
.title-dropdown-option:active {
background: rgba(255, 255, 255, 0.1);
}
.title-icon {
font-size: 1.5rem;
}
.leaderboard-title h2 {
font-size: 1.3rem;
}
.pagination-top,
.pagination-bottom {
width: 100%;
justify-content: center;
}
.pagination-bottom {
margin-top: 1rem;
}
.pagination-btn {
min-width: 44px !important;
width: 44px !important;
height: 44px !important;
padding: 0.5rem !important;
font-size: 1.2rem !important;
border-radius: 8px !important;
}
.pagination-info {
min-width: 100px;
text-align: center;
font-size: 0.95rem;
}
.table-header {
display: none; /* Hide header on mobile */
}
.table-row {
grid-template-columns: 50px 1fr 70px;
gap: 0.75rem;
padding: 0.75rem;
}
.col-level {
order: 3;
}
.col-value {
order: 2;
grid-column: 2 / 3;
text-align: right;
margin-top: 0.25rem;
}
.player-name {
font-size: 1rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.player-username {
font-size: 0.85rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.level-badge {
padding: 0.4rem 0.8rem;
font-size: 0.85rem;
}
.col-value .stat-value {
font-size: 1.1rem;
}
}
}

View File

@@ -3,85 +3,98 @@
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 1rem;
background: linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%);
padding: 2rem;
background: radial-gradient(circle at center, rgba(225, 29, 72, 0.1) 0%, var(--game-bg-app) 100%);
font-family: var(--game-font-main);
}
.login-card {
background-color: #2a2a2a;
border-radius: 12px;
padding: 2rem;
max-width: 400px;
max-width: 450px;
width: 100%;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4);
padding: 3rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.login-card h1 {
font-size: 2rem;
.auth-title {
font-size: 3rem;
margin-bottom: 0.5rem;
text-align: center;
color: #646cff;
color: var(--game-text-primary);
text-transform: uppercase;
letter-spacing: 2px;
}
.login-subtitle {
text-align: center;
color: #888;
color: var(--game-text-secondary);
margin-bottom: 2rem;
font-size: 0.9rem;
font-size: 1.1rem;
text-transform: uppercase;
letter-spacing: 1px;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-group {
margin-bottom: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #ccc;
font-size: 0.9rem;
color: var(--game-text-secondary);
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 1px;
}
.form-group input {
margin-bottom: 0;
.game-input {
background: rgba(0, 0, 0, 0.5);
border: 1px solid var(--game-border-color);
color: var(--game-text-primary);
padding: 0.8rem 1rem;
font-family: var(--game-font-main);
font-size: 1.1rem;
outline: none;
transition: all 0.2s ease;
clip-path: var(--game-clip-path-sm);
}
.game-input:focus {
border-color: var(--game-color-primary);
background: rgba(0, 0, 0, 0.7);
box-shadow: 0 0 10px rgba(225, 29, 72, 0.2);
}
.auth-submit {
margin-top: 1rem;
width: 100%;
}
.error-message {
color: var(--game-text-danger);
background: rgba(239, 68, 68, 0.1);
padding: 0.75rem;
border-left: 3px solid var(--game-text-danger);
font-size: 0.9rem;
margin-top: 0.5rem;
}
.login-toggle {
margin-top: 1.5rem;
text-align: center;
}
.button-link {
background: none;
border: none;
color: #646cff;
cursor: pointer;
font-size: 0.9rem;
padding: 0.5rem;
text-decoration: underline;
}
.button-link:hover {
color: #535bf2;
border: none;
}
.button-link:disabled {
opacity: 0.5;
cursor: not-allowed;
display: flex;
justify-content: center;
}
.password-strength {
margin-top: 0.5rem;
font-size: 0.85rem;
margin-top: 0.25rem;
font-size: 0.9rem;
font-weight: 600;
}
@media (max-width: 480px) {
.login-card {
padding: 1.5rem;
}
.login-card h1 {
font-size: 1.5rem;
}
text-transform: uppercase;
}

View File

@@ -1,6 +1,8 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../hooks/useAuth'
import { GameButton } from './common/GameButton'
import './Login.css'
function Login() {
@@ -10,6 +12,7 @@ function Login() {
const [loading, setLoading] = useState(false)
const { login } = useAuth()
const navigate = useNavigate()
const { t } = useTranslation()
const validateEmail = (email: string) => {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
@@ -22,12 +25,12 @@ function Login() {
// Validation
if (!validateEmail(email)) {
setError('Please enter a valid email address')
setError(t('auth.errors.invalidEmail'))
return
}
if (password.length < 6) {
setError('Password must be at least 6 characters')
setError(t('auth.errors.passwordLength'))
return
}
@@ -38,7 +41,7 @@ function Login() {
// Navigate to character selection after successful login
navigate('/characters')
} catch (err: any) {
setError(err.response?.data?.detail || 'Login failed')
setError(err.response?.data?.detail || t('auth.errors.loginFailed'))
} finally {
setLoading(false)
}
@@ -46,19 +49,20 @@ function Login() {
return (
<div className="login-container">
<div className="login-card">
<h1>Welcome Back</h1>
<p className="login-subtitle">Login to continue your journey</p>
<div className="login-card game-panel">
<h1 className="auth-title">{t('auth.loginTitle')}</h1>
<p className="login-subtitle">{t('auth.loginSubtitle')}</p>
<form onSubmit={handleSubmit}>
<form onSubmit={handleSubmit} className="auth-form">
<div className="form-group">
<label htmlFor="email">Email Address</label>
<label htmlFor="email">{t('auth.email')}</label>
<input
type="email"
id="email"
className="game-input"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your.email@example.com"
placeholder={t('auth.emailPlaceholder')}
required
disabled={loading}
autoComplete="email"
@@ -66,47 +70,45 @@ function Login() {
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<label htmlFor="password">{t('auth.password')}</label>
<input
type="password"
id="password"
className="game-input"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Your password"
placeholder={t('auth.passwordPlaceholderLogin')}
required
disabled={loading}
autoComplete="current-password"
/>
</div>
{error && <div className="error">{error}</div>}
{error && <div className="error-message">{error}</div>}
<button type="submit" className="button-primary" disabled={loading}>
{loading ? 'Logging in...' : 'Login'}
</button>
<GameButton
variant="primary"
size="lg"
className="auth-submit"
disabled={loading}
onClick={() => { }} // Form will handle it via submit
>
{loading ? t('auth.loggingIn') : t('auth.login')}
</GameButton>
</form>
<div className="login-toggle">
<button
type="button"
className="button-link"
<GameButton
variant="secondary"
size="sm"
onClick={() => navigate('/register')}
disabled={loading}
>
Don't have an account? Register
</button>
{t('auth.registerLink')}
</GameButton>
</div>
<div className="login-toggle">
<button
type="button"
className="button-link"
onClick={() => navigate('/')}
disabled={loading}
>
Back to Home
</button>
</div>
</div>
</div>
)

View File

@@ -181,30 +181,4 @@
.stat-value.highlight-stamina {
color: #ffd93d;
}
/* Mobile responsive */
@media (max-width: 768px) {
/* Remove tab bar spacing for profile page */
.game-main {
margin-bottom: 0 !important;
}
.game-main .profile-container {
grid-template-columns: 1fr;
padding: 1rem;
padding-top: 4rem;
/* Space for hamburger button */
max-width: 100vw;
overflow-x: hidden;
}
.profile-info-card {
position: static;
}
.profile-stats-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -1,6 +1,8 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../hooks/useAuth'
import { GameButton } from './common/GameButton'
import './Login.css'
function Register() {
@@ -11,6 +13,7 @@ function Register() {
const [loading, setLoading] = useState(false)
const { register } = useAuth()
const navigate = useNavigate()
const { t } = useTranslation()
const validateEmail = (email: string) => {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
@@ -19,9 +22,9 @@ function Register() {
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' }
if (password.length < 6) return { strength: t('auth.strength.weak'), color: '#ff6b6b' }
if (password.length < 10) return { strength: t('auth.strength.medium'), color: '#ffd93d' }
return { strength: t('auth.strength.strong'), color: '#51cf66' }
}
const passwordStrength = getPasswordStrength(password)
@@ -32,17 +35,17 @@ function Register() {
// Validation
if (!validateEmail(email)) {
setError('Please enter a valid email address')
setError(t('auth.errors.invalidEmail'))
return
}
if (password.length < 6) {
setError('Password must be at least 6 characters')
setError(t('auth.errors.passwordLength'))
return
}
if (password !== confirmPassword) {
setError('Passwords do not match')
setError(t('auth.errors.passwordMatch'))
return
}
@@ -53,7 +56,7 @@ function Register() {
// Navigate to character selection after successful registration
navigate('/characters')
} catch (err: any) {
setError(err.response?.data?.detail || 'Registration failed')
setError(err.response?.data?.detail || t('auth.errors.registrationFailed'))
} finally {
setLoading(false)
}
@@ -61,19 +64,20 @@ function Register() {
return (
<div className="login-container">
<div className="login-card">
<h1>Create Account</h1>
<p className="login-subtitle">Join the survivors in the wasteland</p>
<div className="login-card game-panel">
<h1 className="auth-title">{t('auth.registerTitle')}</h1>
<p className="login-subtitle">{t('auth.registerSubtitle')}</p>
<form onSubmit={handleSubmit}>
<form onSubmit={handleSubmit} className="auth-form">
<div className="form-group">
<label htmlFor="email">Email Address</label>
<label htmlFor="email">{t('auth.email')}</label>
<input
type="email"
id="email"
className="game-input"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your.email@example.com"
placeholder={t('auth.emailPlaceholder')}
required
disabled={loading}
autoComplete="email"
@@ -81,13 +85,14 @@ function Register() {
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<label htmlFor="password">{t('auth.password')}</label>
<input
type="password"
id="password"
className="game-input"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="At least 6 characters"
placeholder={t('auth.passwordPlaceholder')}
required
disabled={loading}
autoComplete="new-password"
@@ -102,50 +107,49 @@ function Register() {
</div>
<div className="form-group">
<label htmlFor="confirmPassword">Confirm Password</label>
<label htmlFor="confirmPassword">{t('auth.confirmPassword')}</label>
<input
type="password"
id="confirmPassword"
className="game-input"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Re-enter your password"
placeholder={t('auth.confirmPasswordPlaceholder')}
required
disabled={loading}
autoComplete="new-password"
/>
</div>
{error && <div className="error">{error}</div>}
{error && <div className="error-message">{error}</div>}
<button type="submit" className="button-primary" disabled={loading}>
{loading ? 'Creating Account...' : 'Create Account'}
</button>
<GameButton
variant="primary"
size="lg"
className="auth-submit"
disabled={loading}
onClick={() => { }} // Form will handle it
>
{loading ? t('auth.submitting') : t('auth.submit')}
</GameButton>
</form>
<div className="login-toggle">
<button
type="button"
className="button-link"
<GameButton
variant="secondary"
size="sm"
onClick={() => navigate('/login')}
disabled={loading}
>
Already have an account? Login
</button>
{t('auth.loginLink')}
</GameButton>
</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

@@ -9,6 +9,7 @@ interface GameButtonProps {
disabled?: boolean;
className?: string;
style?: React.CSSProperties;
title?: string;
}
export const GameButton: React.FC<GameButtonProps> = ({
@@ -18,7 +19,8 @@ export const GameButton: React.FC<GameButtonProps> = ({
size = 'md',
disabled = false,
className = '',
style
style,
title
}) => {
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
if (disabled) return;
@@ -31,6 +33,7 @@ export const GameButton: React.FC<GameButtonProps> = ({
onClick={handleClick}
disabled={disabled}
style={style}
title={title}
>
{children}
</button>

View File

@@ -71,15 +71,25 @@ export const GameDropdown: React.FC<GameDropdownProps> = ({
// Use passed position (if updated dynamically) or fall back to the captured sticky position
const target = position || capturedPos;
const targetX = target.x;
const targetY = target.y;
let x = targetX - 10;
// Get zoom factor to adjust coordinates
const zoom = typeof window !== 'undefined'
? (parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--zoom-factor')) || 1)
: 1;
const targetX = target.x / zoom;
const targetY = target.y / zoom;
// Offset from cursor
const offsetX = 5;
const offsetY = 5;
let x = targetX - offsetX;
// Determine flip direction first using raw position
let flipUp = false;
if (typeof window !== 'undefined') {
const viewportHeight = window.innerHeight;
const viewportHeight = window.innerHeight / zoom;
const estimatedHeight = 200; // Guess for now
if (targetY + estimatedHeight > viewportHeight) {
@@ -87,7 +97,7 @@ export const GameDropdown: React.FC<GameDropdownProps> = ({
}
// Adjust width constrained by viewport
const viewportWidth = window.innerWidth;
const viewportWidth = window.innerWidth / zoom;
const estimatedWidth = parseInt(width) || 200;
if (x + estimatedWidth > viewportWidth) {
x = viewportWidth - estimatedWidth - 10;
@@ -97,7 +107,7 @@ export const GameDropdown: React.FC<GameDropdownProps> = ({
// Apply offset based on direction
// If flipping up, we want the bottom to be slightly below the mouse (y + 10)
// If flipping down, we want the top to be slightly above the mouse (y - 10)
const y = flipUp ? targetY + 10 : targetY - 10;
const y = flipUp ? targetY + offsetY : targetY - offsetY;
return createPortal(
<div
@@ -105,7 +115,7 @@ export const GameDropdown: React.FC<GameDropdownProps> = ({
className={`game-dropdown-menu ${className}`}
style={{
top: flipUp ? 'auto' : y,
bottom: flipUp ? (window.innerHeight - y) : 'auto',
bottom: flipUp ? ((window.innerHeight / zoom) - y) : 'auto',
left: x,
width: width
}}

View File

@@ -21,21 +21,26 @@ export const GameTooltip: React.FC<GameTooltipProps> = ({ content, children, cla
const updatePosition = (e: React.MouseEvent) => {
// Offset from cursor
const offsetX = 15;
const offsetY = 15;
const offsetX = 5;
const offsetY = 5;
// Check viewport boundaries to prevent overflow
let x = e.clientX + offsetX;
let y = e.clientY + offsetY;
// Get zoom factor (CSS zoom on <html> shifts coordinate space)
const zoom = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--zoom-factor')) || 1;
// clientX/Y are in physical viewport coords, but position:fixed uses CSS coords (divided by zoom)
let x = e.clientX / zoom - offsetX;
let y = e.clientY / zoom - offsetY;
// Simple boundary check (can be expanded if needed)
if (tooltipRef.current) {
const rect = tooltipRef.current.getBoundingClientRect();
if (x + rect.width > window.innerWidth) {
x = e.clientX - rect.width - 5;
const viewW = window.innerWidth / zoom;
const viewH = window.innerHeight / zoom;
if (x + rect.width / zoom > viewW) {
x = e.clientX / zoom - rect.width / zoom + offsetX;
}
if (y + rect.height > window.innerHeight) {
y = e.clientY - rect.height - 5;
if (y + rect.height / zoom > viewH) {
y = e.clientY / zoom - rect.height / zoom + offsetY;
}
}
@@ -55,6 +60,11 @@ export const GameTooltip: React.FC<GameTooltipProps> = ({ content, children, cla
setIsVisible(false);
};
const handleClick = () => {
// Hide tooltip on click so it doesn't interfere with dropdowns/menus
setIsVisible(false);
};
// Render the tooltip portal
const tooltip = isVisible && content ? (
createPortal(
@@ -94,6 +104,7 @@ export const GameTooltip: React.FC<GameTooltipProps> = ({ content, children, cla
onMouseEnter={handleMouseEnter}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
style={{ display: 'contents' }} // Use contents so the wrapper doesn't affect layout
>
{children}

View File

@@ -1,11 +1,15 @@
import { useTranslation } from 'react-i18next';
import { getTranslatedText } from '../../utils/i18nUtils';
import { EffectBadge } from '../game/EffectBadge';
import { ItemStatBadges } from './ItemStatBadges';
import { GameProgressBar } from './GameProgressBar';
interface ItemTooltipContentProps {
item: any;
showValue?: boolean; // Show item value (for trading)
valueDisplayType?: 'unit' | 'total';
tradeMarkup?: number; // Multiplier for displayed value
showDurability?: boolean; // Show durability bar (default: true if available)
actionHint?: string;
}
/**
@@ -15,13 +19,13 @@ interface ItemTooltipContentProps {
export const ItemTooltipContent = ({
item,
showValue = false,
showDurability = true
valueDisplayType = 'total',
tradeMarkup,
showDurability = true,
actionHint
}: ItemTooltipContentProps) => {
const { t } = useTranslation();
const stats = item.unique_stats || item.stats || {};
const effects = item.effects || {};
const maxDurability = item.max_durability;
const currentDurability = item.durability;
const hasDurability = showDurability && maxDurability && maxDurability > 0;
@@ -45,136 +49,40 @@ export const ItemTooltipContent = ({
</div>
{/* Value (for trading) */}
{showValue && item.value !== undefined && (
<div className="tooltip-value">
💰 {t('game.value')}: {item.value * (item.quantity || 1)} coins
</div>
)}
{showValue && item.value !== undefined && (() => {
const qty = item.is_infinite ? 1 : (item._displayQuantity !== undefined ? item._displayQuantity : item.quantity) || 1;
const multiplier = valueDisplayType === 'total' ? qty : 1;
return (
<div className="tooltip-value">
💰 {t('game.value')}: {Math.round(item.value * (tradeMarkup || 1) * multiplier)}
</div>
);
})()}
{/* Stat Badges */}
<div className="stat-badges-container">
{/* Capacity */}
{(stats.weight_capacity) && (
<span className="stat-badge capacity">
+{stats.weight_capacity}kg
</span>
)}
{(stats.volume_capacity) && (
<span className="stat-badge capacity">
📦 +{stats.volume_capacity}L
</span>
)}
{/* Combat */}
{(stats.damage_min) && (
<span className="stat-badge damage">
{stats.damage_min}-{stats.damage_max}
</span>
)}
{(stats.armor) && (
<span className="stat-badge armor">
🛡 +{stats.armor}
</span>
)}
{(stats.armor_penetration) && (
<span className="stat-badge penetration">
💔 +{stats.armor_penetration} {t('stats.pen')}
</span>
)}
{(stats.crit_chance) && (
<span className="stat-badge crit">
🎯 +{Math.round(stats.crit_chance * 100)}% {t('stats.crit')}
</span>
)}
{(stats.accuracy) && (
<span className="stat-badge accuracy">
👁 +{Math.round(stats.accuracy * 100)}% {t('stats.acc')}
</span>
)}
{(stats.dodge_chance) && (
<span className="stat-badge dodge">
💨 +{Math.round(stats.dodge_chance * 100)}% Dodge
</span>
)}
{(stats.lifesteal) && (
<span className="stat-badge lifesteal">
🧛 +{Math.round(stats.lifesteal * 100)}% {t('stats.life')}
</span>
)}
{/* Attributes */}
{(stats.strength_bonus) && (
<span className="stat-badge strength">
💪 +{stats.strength_bonus} {t('stats.str')}
</span>
)}
{(stats.agility_bonus) && (
<span className="stat-badge agility">
🏃 +{stats.agility_bonus} {t('stats.agi')}
</span>
)}
{(stats.endurance_bonus) && (
<span className="stat-badge endurance">
🏋 +{stats.endurance_bonus} {t('stats.end')}
</span>
)}
{(stats.hp_bonus) && (
<span className="stat-badge health">
+{stats.hp_bonus} {t('stats.hpMax')}
</span>
)}
{(stats.stamina_bonus) && (
<span className="stat-badge stamina">
+{stats.stamina_bonus} {t('stats.stmMax')}
</span>
)}
{/* Consumables */}
{(item.hp_restore || effects.hp_restore) && (
<span className="stat-badge health">
+{item.hp_restore || effects.hp_restore} HP
</span>
)}
{(item.stamina_restore || effects.stamina_restore) && (
<span className="stat-badge stamina">
+{item.stamina_restore || effects.stamina_restore} Stm
</span>
)}
{/* Status Effects */}
{effects.status_effect && (
<EffectBadge effect={effects.status_effect} />
)}
{effects.cures && effects.cures.length > 0 && (
<span className="stat-badge cure">
💊 {t('game.cures')}: {effects.cures.map((c: string) => t(`game.effects.${c}`, c)).join(', ')}
</span>
)}
</div>
<ItemStatBadges item={item} />
{/* Durability Bar */}
{hasDurability && (
<div className="durability-container">
<div className="durability-header">
<div style={{ marginTop: '0.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2px', fontSize: '0.8rem' }}>
<span>{t('game.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))}%`
}}
/>
<span>{currentDurability} / {maxDurability}</span>
</div>
<GameProgressBar
value={currentDurability}
max={maxDurability}
type="durability"
height="6px"
showText={false}
/>
</div>
)}
{/* Action Hint */}
{actionHint && (
<div style={{ marginTop: '0.5rem', paddingTop: '0.5rem', borderTop: '1px solid #444', color: '#aaa', fontSize: '0.8rem', fontStyle: 'italic', textAlign: 'center' }}>
{actionHint}
</div>
)}
</div>

View File

@@ -26,6 +26,8 @@ interface CombatProps {
onClose?: () => void;
}
import { useNotification } from '../../contexts/NotificationContext';
export const Combat: React.FC<CombatProps> = ({
combatState: initialCombatData,
combatLog: _combatLog,
@@ -44,6 +46,7 @@ export const Combat: React.FC<CombatProps> = ({
}) => {
const { currentCharacter: _currentCharacter, refreshCharacters } = useAuth();
const { t, i18n } = useTranslation();
const { addNotification } = useNotification();
const isPvP = initialCombatData?.is_pvp || false;
@@ -488,6 +491,10 @@ export const Combat: React.FC<CombatProps> = ({
setCombatResult('fled');
}, 500);
break;
case 'quest_update':
addNotification(data.message || 'Quest Progress', 'quest');
break;
}
}, [t]);

View File

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { getAssetPath } from '../../utils/assetPath';
import { getTranslatedText } from '../../utils/i18nUtils';
import './CombatInventoryModal.css';
import { EffectBadge } from './EffectBadge';
import { ItemStatBadges } from '../common/ItemStatBadges';
import { GameButton } from '../common/GameButton';
interface CombatInventoryModalProps {
@@ -107,33 +107,10 @@ export const CombatInventoryModal: React.FC<CombatInventoryModalProps> = ({
)}
<div className="item-effects">
{/* Logic adapted from InventoryModal to show all relevant stats */}
{/* Shared stat badges */}
<ItemStatBadges item={item} />
{/* Consumables (Priority for combat) */}
{(item.effects?.hp_restore || item.hp_restore) && (
<span className="stat-badge healing">
+{item.effects?.hp_restore || item.hp_restore} HP
</span>
)}
{(item.effects?.stamina_restore || item.stamina_restore) && (
<span className="stat-badge stamina">
+{item.effects?.stamina_restore || item.stamina_restore} Stm
</span>
)}
{/* Status Effects & Cures */}
{item.effects?.status_effect && (
<EffectBadge effect={item.effects.status_effect} />
)}
{item.effects?.cures && item.effects.cures.length > 0 && (
<span className="stat-badge cure">
💊 {t('game.cures')}: {item.effects.cures.map((c: string) => getTranslatedText(c)).join(', ')}
</span>
)}
{/* Combat Effects (Throwables, etc) */}
{/* Combat-specific Effects (Throwables, etc) */}
{item.combat_effects?.damage_min && (
<span className="stat-badge damage">
💥 {item.combat_effects.damage_min}-{item.combat_effects.damage_max} Dmg
@@ -144,60 +121,6 @@ export const CombatInventoryModal: React.FC<CombatInventoryModalProps> = ({
{t(`effects.${item.combat_effects.status.name}`, item.combat_effects.status.name) as string}
</span>
)}
{/* Stats & Unique Stats (If applicable) */}
{(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} {t('stats.pen') as string}
</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)}% {t('stats.crit') as string}
</span>
)}
{(item.unique_stats?.accuracy || item.stats?.accuracy) && (
<span className="stat-badge accuracy">
👁 +{Math.round((item.unique_stats?.accuracy || item.stats?.accuracy) * 100)}% {t('stats.acc') as string}
</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)}% {t('stats.life') as string}
</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} {t('stats.str') as string}
</span>
)}
{(item.unique_stats?.agility_bonus || item.stats?.agility_bonus) && (
<span className="stat-badge agility">
🏃 +{item.unique_stats?.agility_bonus || item.stats?.agility_bonus} {t('stats.agi') as string}
</span>
)}
{(item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus) && (
<span className="stat-badge endurance">
🏋 +{item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus} {t('stats.end') as string}
</span>
)}
</div>
</div>

View File

@@ -67,23 +67,29 @@
/* Renamed from .options-container to match JSX */
.options-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: auto;
}
/* Make the last item span full width if it's the only one in the row (odd number of items) */
.options-grid>*:last-child:nth-child(odd) {
/*.options-grid>*:last-child:nth-child(odd) {
grid-column: span 2;
}
}*/
.option-btn {
/* Base styles handled by GameButton, but ensure consistent height */
width: 100%;
flex: 1 1 45%;
min-width: 120px;
margin: 0;
}
.full-width {
flex: 1 1 100%;
width: 100%;
}
.option-button {
/* Legacy style - keeping just in case */
background: rgba(255, 255, 255, 0.05);

View File

@@ -1,5 +1,7 @@
import React, { useState, useEffect } from 'react';
import { useGame } from '../../contexts/GameContext';
import { useNotification } from '../../contexts/NotificationContext';
import { useTranslation } from 'react-i18next';
import { GAME_API_URL } from '../../config';
import { GameModal } from './GameModal';
import { GameButton } from '../common/GameButton';
@@ -32,7 +34,9 @@ interface Quest {
}
export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClose, onTrade }) => {
const { token, locale, actions } = useGame();
const { t } = useTranslation();
const { token, locale, actions, inventory } = useGame();
const { addNotification } = useNotification();
const [dialogData, setDialogData] = useState<any>(null);
const [currentText, setCurrentText] = useState<string>("");
const [quests, setQuests] = useState<Quest[]>([]);
@@ -115,7 +119,7 @@ export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClos
const desc = getLocalized(quest.description);
if (quest.status === 'active') {
setCurrentText(desc + "\n\n(Quest in progress...)");
setCurrentText(desc + "\n\n" + t('game.dialog.questInProgress'));
} else {
setCurrentText(desc);
}
@@ -132,7 +136,8 @@ export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClos
if (res.ok) {
const data = await res.json();
// Refresh or update state
setCurrentText("Quest accepted! Good luck.");
setCurrentText(t('game.dialog.questAccepted'));
addNotification(t('messages.questAccepted'), "success");
if (data.quest) {
actions.handleQuestUpdate(data.quest);
@@ -147,13 +152,36 @@ export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClos
}, 1500);
} else {
const err = await res.json();
alert(err.detail);
addNotification(err.detail, "error");
}
} catch (e) {
console.error(e);
addNotification(t('messages.failedToAcceptQuest'), "error");
}
};
// Check if player has any relevant items for the quest
const hasRequiredItems = () => {
if (!selectedQuest || !selectedQuest.objectives) return false;
// If it has kill objectives, we can always "hand in" (check progress/complete)
// unless it's ONLY item delivery.
const hasKillObjective = selectedQuest.objectives.some((o: any) => o.type === 'kill_count');
if (hasKillObjective) return true;
// Check item delivery objectives
const itemObjectives = selectedQuest.objectives.filter((o: any) => o.type === 'item_delivery');
if (itemObjectives.length === 0) return true; // No delivery needed? Should allow.
// Check if we have ANY of the required items in inventory
// @ts-ignore
return itemObjectives.some((o: any) => {
// @ts-ignore
const invItem = inventory.find((i: any) => i.item_id === o.target);
return invItem && invItem.quantity > 0;
});
};
const handInQuest = async () => {
if (!selectedQuest) return;
try {
@@ -166,26 +194,44 @@ export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClos
if (res.ok) {
if (result.quest_update) {
actions.handleQuestUpdate(result.quest_update);
// Update local state to reflect new progress
setQuests(prev => prev.map(q => q.quest_id === result.quest_update.quest_id ? result.quest_update : q));
// Also update selectedQuest so the UI reflects changes immediately if we stay on this screen
setSelectedQuest(result.quest_update);
}
// Refresh game data to update inventory/stats
actions.fetchGameData();
if (result.is_completed) {
addNotification(t('messages.questCompleted'), "quest");
let msg = getLocalized(result.completion_text) || "Thank you!";
if (result.rewards && result.rewards.length > 0) {
msg += "\n\nRewards:\n" + result.rewards.join('\n');
msg += "\n\n" + t('game.dialog.rewards') + ":\n" + result.rewards.join('\n');
}
setCurrentText(msg);
// Remove from list
setQuests(prev => prev.filter(q => q.quest_id !== selectedQuest.quest_id));
// Remove from list or mark as completed in local list
setQuests(prev => prev.map(q => q.quest_id === selectedQuest.quest_id ? { ...q, status: 'completed' } : q));
} else {
setCurrentText(`Progress updated.\n${result.items_deducted?.join('\n')}`);
addNotification(t('messages.questProgressUpdated'), "info");
let feedback = t('game.dialog.progressUpdated');
if (result.items_deducted && result.items_deducted.length > 0) {
feedback += `\n\n${result.items_deducted.join('\n')}`;
}
// Append objective status
if (result.quest_update && result.quest_update.objectives) {
const objText = result.quest_update.objectives.map((o: any) => {
const targetName = o.target_name || o.target;
return `- ${targetName}: ${o.current}/${o.count}`;
}).join('\n');
feedback += `\n\n${objText}`;
}
setCurrentText(feedback);
}
setTimeout(() => {
resetToGreeting();
}, 2000);
// Removed setTimeout to keep user in the dialog
} else {
alert(result.detail);
addNotification(result.detail, "error");
}
} catch (e) {
console.error(e);
@@ -225,13 +271,6 @@ export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClos
</div>
<div className="options-grid">
{/* BACK BUTTON */}
{(viewState === 'topic' || viewState === 'quest_preview') && (
<GameButton className="option-btn" size="sm" onClick={resetToGreeting}>
&larr; Back
</GameButton>
)}
{/* NPC TOPICS */}
{viewState === 'greeting' && dialogData.topics?.map((topic: Topic) => (
<GameButton key={topic.id} className="option-btn" size="sm" onClick={() => handleTopicClick(topic)}>
@@ -246,34 +285,35 @@ export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClos
className="option-btn quest-btn"
size="sm"
onClick={() => handleQuestClick(q)}
variant={q.status === 'active' ? 'warning' : 'info'}
variant={q.status === 'active' ? 'warning' : q.status === 'completed' ? 'success' : 'info'}
>
{q.status === 'available' ? '❗' : '❓'} {getLocalized(q.title)}
{q.status === 'available' ? '❗' : q.status === 'completed' ? '✅' : '❓'} {getLocalized(q.title)}
</GameButton>
))}
{/* CONFIRM QUEST ACTION */}
{viewState === 'quest_preview' && selectedQuest?.status === 'available' && (
<div style={{ gridColumn: 'span 2' }}>
<div className="full-width">
<GameButton className="option-btn action-btn" size="sm" variant="success" onClick={acceptQuest} style={{ width: '100%' }}>
Accept Quest
{t('game.dialog.acceptQuest')}
</GameButton>
</div>
)}
{viewState === 'quest_preview' && selectedQuest?.status === 'active' && (
<div style={{ gridColumn: 'span 2' }}>
<div className="full-width">
<GameButton
className="option-btn action-btn"
size="sm"
variant="warning"
onClick={handInQuest}
style={{ width: '100%' }}
disabled={!hasRequiredItems()}
>
{/* If it's pure kill quest, 'Complete' makes more sense than 'Hand In' */}
{selectedQuest.objectives?.some((o: any) => o.type === 'kill_count') && !selectedQuest.objectives?.some((o: any) => o.type === 'item_delivery')
? "Complete Quest"
: "Hand In Items"}
? t('game.dialog.completeQuest')
: t('game.dialog.handInItems')}
</GameButton>
</div>
)}
@@ -281,14 +321,21 @@ export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClos
{/* TRADE - Only show in greeting */}
{viewState === 'greeting' && npcData.trade?.enabled && (
<GameButton className="option-btn trade-btn" size="sm" variant="success" onClick={onTrade}>
💰 Trade
💰 {t('game.dialog.trade')}
</GameButton>
)}
{/* EXIT - Span full width */}
{viewState === 'greeting' && (
<GameButton className="option-btn exit-btn" size="sm" variant="secondary" onClick={onClose} style={{ gridColumn: 'span 2' }}>
Goodbye
<GameButton className="option-btn exit-btn full-width" size="sm" variant="secondary" onClick={onClose}>
{t('game.dialog.goodbye')}
</GameButton>
)}
{/* BACK BUTTON - Moved to bottom */}
{(viewState === 'topic' || viewState === 'quest_preview') && (
<GameButton className="option-btn full-width" size="sm" onClick={resetToGreeting}>
&larr; {t('game.dialog.back')}
</GameButton>
)}
</div>

View File

@@ -38,10 +38,11 @@
/* --- Redesigned Inventory Modal --- */
/* --- Redesigned Inventory Modal --- */
.inventory-modal-redesign {
.game-modal-container.inventory-modal-redesign {
display: flex;
flex-direction: column;
height: 85vh;
height: 90%;
max-height: 90%;
width: 95vw;
max-width: 1400px;
background: var(--game-bg-modal);
@@ -53,6 +54,15 @@
clip-path: var(--game-clip-path);
}
.game-modal-container.inventory-modal-redesign .game-modal-content {
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
height: 100%;
/* Ensure it fills the container */
}
/* Top Bar */
.inventory-top-bar {
display: flex;
@@ -233,31 +243,8 @@
}
.game-search-container {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--game-bg-input, rgba(0, 0, 0, 0.3));
border: 1px solid var(--game-border-color);
margin-bottom: 1.5rem;
color: var(--game-text-primary);
width: 100%;
box-sizing: border-box;
clip-path: var(--game-clip-path-sm);
}
.game-search-icon {
font-size: 1rem;
opacity: 0.7;
}
.game-search-input {
background: transparent;
border: none;
color: #fff;
font-size: 1rem;
flex: 1;
outline: none;
}
/* View Toggle Button */
@@ -308,9 +295,10 @@
/* Grid View Layout */
.items-container.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
grid-template-columns: repeat(auto-fill, 90px);
justify-content: center;
grid-auto-rows: max-content;
gap: 1rem;
gap: 0.5rem;
align-content: start;
}
@@ -365,6 +353,9 @@
cursor: pointer;
transition: all 0.2s ease;
box-shadow: var(--game-shadow-sm);
width: 90px;
height: 90px;
box-sizing: border-box;
}
.inventory-item-card.grid:hover,
@@ -424,38 +415,23 @@
display: none;
}
.item-quantity-badge {
position: absolute;
bottom: -5px;
right: -5px;
background: var(--game-bg-panel);
border: 1px solid var(--game-border-color);
color: var(--game-text-primary);
font-size: 0.75rem;
padding: 2px 6px;
clip-path: var(--game-clip-path-sm);
font-weight: bold;
box-shadow: var(--game-shadow-sm);
}
/* Position adjustment for grid view badge */
.inventory-item-card.grid .item-quantity-badge {
bottom: 2px;
right: 2px;
font-size: 0.7rem;
padding: 1px 4px;
}
.item-equipped-indicator {
position: absolute;
top: 2px;
right: 2px;
left: 2px;
/* moved to left to free up space for clip path */
background: #4299e1;
color: #fff;
font-size: 0.65rem;
font-size: 0.7rem;
font-weight: bold;
padding: 1px 4px;
border-radius: 2px;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
clip-path: var(--game-clip-path-sm);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}

View File

@@ -5,10 +5,13 @@ import { PlayerState, Profile, Equipment } from './types'
import { getAssetPath } from '../../utils/assetPath'
import { getTranslatedText } from '../../utils/i18nUtils'
import './InventoryModal.css'
import { EffectBadge } from './EffectBadge'
import { GameTooltip } from '../common/GameTooltip'
import { GameDropdown } from '../common/GameDropdown'
import { GameButton } from '../common/GameButton'
import { GameModal } from './GameModal'
import { ItemStatBadges } from '../common/ItemStatBadges'
import { GameProgressBar } from '../common/GameProgressBar'
import { GameItemCard } from '../common/GameItemCard'
import '../common/GameDropdown.css'
interface InventoryModalProps {
@@ -173,112 +176,12 @@ function InventoryModal({
<div className="stats-durability-column">
{item.description && <p className="item-description-compact">{getTranslatedText(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} {t('stats.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)}% {t('stats.crit')}
</span>
)}
{(item.unique_stats?.accuracy || item.stats?.accuracy) && (
<span className="stat-badge accuracy">
👁 +{Math.round((item.unique_stats?.accuracy || item.stats?.accuracy) * 100)}% {t('stats.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)}% {t('stats.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} {t('stats.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} {t('stats.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} {t('stats.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} {t('stats.hpMax')}
</span>
)}
{(item.unique_stats?.stamina_bonus || item.stats?.stamina_bonus) && (
<span className="stat-badge stamina">
+{item.unique_stats?.stamina_bonus || item.stats?.stamina_bonus} {t('stats.stmMax')}
</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>
)}
{/* Status Effects */}
{item.effects?.status_effect && (
<EffectBadge effect={item.effects.status_effect} />
)}
{item.effects?.cures && item.effects.cures.length > 0 && (
<span className="stat-badge cure">
💊 {t('game.cures')}: {item.effects.cures.map((c: string) => t(`game.effects.${c}`, c)).join(', ')}
</span>
)}
</div>
{/* Stats Row - Reusable Badges */}
<ItemStatBadges item={item} />
{/* Durability Bar */}
{hasDurability && (
<div className="durability-container">
<div style={{ marginTop: '0.5rem' }}>
<div className="durability-header">
<span>{t('game.durability')}</span>
<span className={
@@ -289,19 +192,13 @@ function InventoryModal({
{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>
<GameProgressBar
value={currentDurability}
max={maxDurability}
type="durability"
height="6px"
showText={false}
/>
</div>
)}
</div>
@@ -390,189 +287,15 @@ function InventoryModal({
return effectName === itemName;
});
const maxDurability = item.max_durability;
const currentDurability = item.durability;
const hasDurability = maxDurability && maxDurability > 0;
const tooltipContent = (
<div className="item-tooltip-content">
<div className={`tooltip-header text-tier-${item.tier || 0}`}>
{item.emoji} {getTranslatedText(item.name)}
</div>
{item.description && <div className="tooltip-desc">{getTranslatedText(item.description)}</div>}
<div className="tooltip-stats">
<div> {item.weight}kg {item.quantity > 1 && `(x${item.quantity})`}</div>
<div>📦 {item.volume}L {item.quantity > 1 && `(x${item.quantity})`}</div>
</div>
{/* 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} {t('stats.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)}% {t('stats.crit')}
</span>
)}
{(item.unique_stats?.accuracy || item.stats?.accuracy) && (
<span className="stat-badge accuracy">
👁 +{Math.round((item.unique_stats?.accuracy || item.stats?.accuracy) * 100)}% {t('stats.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)}% {t('stats.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} {t('stats.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} {t('stats.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} {t('stats.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} {t('stats.hpMax')}
</span>
)}
{(item.unique_stats?.stamina_bonus || item.stats?.stamina_bonus) && (
<span className="stat-badge stamina">
+{item.unique_stats?.stamina_bonus || item.stats?.stamina_bonus} {t('stats.stmMax')}
</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>
)}
{/* Status Effects */}
{item.effects?.status_effect && (
<EffectBadge effect={item.effects.status_effect} />
)}
{item.effects?.cures && item.effects.cures.length > 0 && (
<span className="stat-badge cure">
💊 {t('game.cures')}: {item.effects.cures.map((c: string) => t(`game.effects.${c}`, c)).join(', ')}
</span>
)}
</div>
{/* Durability Bar */}
{hasDurability && (
<div className="durability-container">
<div className="durability-header">
<span>{t('game.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>
);
return (
<div key={i} className="inventory-grid-wrapper">
<GameTooltip content={tooltipContent}>
<div
className={`inventory-item-card grid ${item.is_equipped ? 'equipped' : ''} ${activeDropdown === item.id ? 'active' : ''} text-tier-${item.tier || 0}`}
onClick={(e) => handleItemClick(e, item)}
>
{/* Image/Icon */}
<div className="item-grid-image">
{item.image_path ? (
<img
src={getAssetPath(item.image_path)}
alt={getTranslatedText(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>
</div>
{/* Quantity Badge */}
{item.quantity > 1 && <div className="item-quantity-badge">x{item.quantity}</div>}
{/* Equipped Indicator */}
{item.is_equipped && <div className="item-equipped-indicator">E</div>}
</div>
</GameTooltip>
<GameItemCard
item={item}
onClick={(e) => handleItemClick(e, item)}
isActive={activeDropdown === item.id}
showEquipped={true}
showQuantity={true}
/>
{/* Dropdown Menu */}
{activeDropdown === item.id && (
@@ -707,172 +430,171 @@ function InventoryModal({
};
return (
<div className="workbench-overlay" onClick={(e: MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) handleClose()
}}>
<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">
<span className="metric-text" style={{ marginBottom: '4px', display: 'block' }}>
{t('game.weight')}: {(profile.current_weight || 0).toFixed(1)} / {profile.max_weight || 0} kg
</span>
<GameProgressBar
value={profile.current_weight || 0}
max={profile.max_weight || 100}
type="weight"
height="8px"
showText={false}
/>
</div>
</div>
<div className="capacity-metric">
<span className="metric-icon">📦</span>
<div className="metric-bar-container">
<span className="metric-text" style={{ marginBottom: '4px', display: 'block' }}>
{t('game.volume')}: {(profile.current_volume || 0).toFixed(1)} / {profile.max_volume || 0} L
</span>
<GameProgressBar
value={profile.current_volume || 0}
max={profile.max_volume || 100}
type="volume"
height="8px"
showText={false}
/>
</div>
<GameModal
title={t('game.inventory')}
onClose={handleClose}
className="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">
<span className="metric-text" style={{ marginBottom: '4px', display: 'block' }}>
{t('game.weight')}: {(profile.current_weight || 0).toFixed(1)} / {profile.max_weight || 0} kg
</span>
<GameProgressBar
value={profile.current_weight || 0}
max={profile.max_weight || 100}
type="weight"
height="8px"
showText={false}
/>
</div>
</div>
<div className="inventory-backpack-info">
{equipment?.backpack ? (
<div className="backpack-status active">
<span className="backpack-icon">🎒</span>
<span className="backpack-name">{getTranslatedText(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 className="capacity-metric">
<span className="metric-icon">📦</span>
<div className="metric-bar-container">
<span className="metric-text" style={{ marginBottom: '4px', display: 'block' }}>
{t('game.volume')}: {(profile.current_volume || 0).toFixed(1)} / {profile.max_volume || 0} L
</span>
<GameProgressBar
value={profile.current_volume || 0}
max={profile.max_volume || 100}
type="volume"
height="8px"
showText={false}
/>
</div>
</div>
</div>
<div className="inventory-backpack-info">
{equipment?.backpack ? (
<div className="backpack-status active">
<span className="backpack-icon">🎒</span>
<span className="backpack-name">{getTranslatedText(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>{t('game.noBackpack')}</span>
</div>
)}
<button className="close-btn" onClick={handleClose}></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="game-search-container" style={{ marginBottom: '1.5rem' }}>
<span className="game-search-icon">🔍</span>
<input
className="game-search-input"
type="text"
placeholder={t('game.searchItems')}
value={inventoryFilter}
onChange={(e: ChangeEvent<HTMLInputElement>) => onSetInventoryFilter(e.target.value)}
/>
{/* View Mode Toggle */}
<div className="inventory-view-toggle">
<button
className={`view-toggle-btn ${viewMode === 'list' ? 'active' : ''}`}
onClick={toggleViewMode}
title={viewMode === 'list' ? "Switch to Grid View" : "Switch to List View"}
>
{viewMode === 'list' ? '📋' : '🔲'}
</button>
</div>
</div>
<div className="inventory-items-grid">
{filteredItems.length === 0 ? (
<div className="empty-state">
<span className="empty-icon">📦</span>
<p>{t('game.noItemsFound')}</p>
</div>
) : (
<div className="backpack-status inactive">
<span className="backpack-icon">🚫</span>
<span>{t('game.noBackpack')}</span>
</div>
)}
<button className="close-btn" onClick={handleClose}></button>
</div>
</div>
inventoryCategoryFilter === 'all' ? (
<>
{/* Equipped */}
{filteredItems.some((item: any) => item.is_equipped) && (
<>
<div className="category-header"> {t('game.equipped')}</div>
<div className={`items-container ${viewMode}`}>
{filteredItems.filter((item: any) => item.is_equipped).map((item: any, i: number) =>
viewMode === 'list' ? renderItemCard(item, i) : renderGridItem(item, i)
)}
</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="game-search-container" style={{ marginBottom: '1.5rem' }}>
<span className="game-search-icon">🔍</span>
<input
className="game-search-input"
type="text"
placeholder={t('game.searchItems')}
value={inventoryFilter}
onChange={(e: ChangeEvent<HTMLInputElement>) => onSetInventoryFilter(e.target.value)}
/>
{/* View Mode Toggle */}
<div className="inventory-view-toggle">
<button
className={`view-toggle-btn ${viewMode === 'list' ? 'active' : ''}`}
onClick={toggleViewMode}
title={viewMode === 'list' ? "Switch to Grid View" : "Switch to List View"}
>
{viewMode === 'list' ? '📋' : '🔲'}
</button>
</div>
</div>
<div className="inventory-items-grid">
{filteredItems.length === 0 ? (
<div className="empty-state">
<span className="empty-icon">📦</span>
<p>{t('game.noItemsFound')}</p>
</div>
) : (
inventoryCategoryFilter === 'all' ? (
<>
{/* Equipped */}
{filteredItems.some((item: any) => item.is_equipped) && (
<>
<div className="category-header"> {t('game.equipped')}</div>
<div className={`items-container ${viewMode}`}>
{filteredItems.filter((item: any) => item.is_equipped).map((item: any, i: number) =>
viewMode === 'list' ? renderItemCard(item, i) : renderGridItem(item, i)
)}
</div>
</>
)}
{/* Backpack - grouped by categories */}
{filteredItems.some((item: any) => !item.is_equipped) && (
<>
{/* Group backpack items by category */}
{categories
.filter(cat => cat.id !== 'all') // Exclude 'all' from subcategories
.map(cat => {
const categoryItems = filteredItems.filter(
(item: any) => !item.is_equipped && item.type === cat.id
);
if (categoryItems.length === 0) return null;
return (
<div key={cat.id} className="backpack-category-section">
<div className="category-header">
<span className="subcat-icon">{cat.icon}</span>
<span className="subcat-label">{cat.label}</span>
<span className="subcat-count">({categoryItems.length})</span>
</div>
<div className={`items-container ${viewMode}`}>
{categoryItems.map((item: any, i: number) =>
viewMode === 'list' ? renderItemCard(item, i) : renderGridItem(item, i)
)}
</div>
{/* Backpack - grouped by categories */}
{filteredItems.some((item: any) => !item.is_equipped) && (
<>
{/* Group backpack items by category */}
{categories
.filter(cat => cat.id !== 'all') // Exclude 'all' from subcategories
.map(cat => {
const categoryItems = filteredItems.filter(
(item: any) => !item.is_equipped && item.type === cat.id
);
if (categoryItems.length === 0) return null;
return (
<div key={cat.id} className="backpack-category-section">
<div className="category-header">
<span className="subcat-icon">{cat.icon}</span>
<span className="subcat-label">{cat.label}</span>
<span className="subcat-count">({categoryItems.length})</span>
</div>
);
})}
</>
)}
</>
) : (
/* Single category */
<div className={`items-container ${viewMode}`}>
{filteredItems.map((item: any, i: number) =>
viewMode === 'list' ? renderItemCard(item, i) : renderGridItem(item, i)
)}
</div>
)
)}
</div>
<div className={`items-container ${viewMode}`}>
{categoryItems.map((item: any, i: number) =>
viewMode === 'list' ? renderItemCard(item, i) : renderGridItem(item, i)
)}
</div>
</div>
);
})}
</>
)}
</>
) : (
/* Single category */
<div className={`items-container ${viewMode}`}>
{filteredItems.map((item: any, i: number) =>
viewMode === 'list' ? renderItemCard(item, i) : renderGridItem(item, i)
)}
</div>
)
)}
</div>
</div>
</div >
</div >
</div>
</GameModal >
)
}
export default InventoryModal
import { GameProgressBar } from '../common/GameProgressBar'

View File

@@ -176,7 +176,7 @@
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
transform: translateY(5px);
}
to {
@@ -186,6 +186,7 @@
}
.entity-card.grid-card {
animation: fadeIn 0.2s ease-out;
display: flex;
flex-direction: column;
align-items: center;
@@ -238,17 +239,7 @@
/* Overlay for text or stats on hover could be improved,
but for now we keep the tooltip */
.grid-card .grid-quantity {
position: absolute;
bottom: 2px;
right: 2px;
background: rgba(0, 0, 0, 0.8);
color: #fff;
font-size: 0.75rem;
padding: 2px 6px;
font-weight: bold;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.grid-overlay {
position: absolute;

View File

@@ -10,7 +10,7 @@ import { getAssetPath } from '../../utils/assetPath'
import { getTranslatedText } from '../../utils/i18nUtils'
import { DialogModal } from './DialogModal'
import { TradeModal } from './TradeModal'
import { ItemTooltipContent } from '../common/ItemTooltipContent'
import { GameItemCard } from '../common/GameItemCard'
import { GameModal } from './GameModal'
import './LocationView.css'
@@ -33,7 +33,7 @@ interface LocationViewProps {
playerState: PlayerState | null
combatState: CombatState | null
message: string
locationMessages: Array<{ time: string; message: string; location_name?: string }>
locationMessages: Array<{ time: string; message: string; location_name?: string | { [key: string]: string } }>
expandedCorpse: string | null
corpseDetails: any
mobileMenuOpen: string
@@ -223,6 +223,36 @@ function LocationView({
setActiveDialogNpc(npc.id);
};
const renderItemPickupOptions = (item: any, isModal: boolean = false) => {
const options = [];
options.push({ label: `${t('common.pickUp')}${item.quantity > 1 ? ' (x1)' : ''}`, qty: 1 });
if (item.quantity >= 5) options.push({ label: `${t('common.pickUp')} (x5)`, qty: 5 });
if (item.quantity >= 10) options.push({ label: `${t('common.pickUp')} (x10)`, qty: 10 });
if (item.quantity > 1) options.push({ label: `${t('common.pickUpAll')}`, qty: item.quantity });
return (
<div className="pickup-options-vertical" style={{ display: 'flex', flexDirection: 'column', gap: '4px', marginTop: '8px' }}>
{options.map((opt) => (
<GameButton
key={opt.label}
variant="success"
size="sm"
onClick={(e) => {
e.stopPropagation();
playSfx('/audio/sfx/pickup.wav');
onPickup(Number(item.id), opt.qty);
setActiveDropdown(null);
if (isModal) setEntityModal(null);
}}
style={{ width: '100%', justifyContent: 'center' }}
>
{opt.label}
</GameButton>
))}
</div>
);
};
const renderIndicator = (npcId: string) => {
const type = questIndicators[npcId];
if (!type) return null;
@@ -320,7 +350,7 @@ function LocationView({
<span className="message-time">{msg.time}</span>
<span className="message-text">{getTranslatedText(msg.message)}</span>
{msg.location_name && (
<span className="message-location">[{msg.location_name}]</span>
<span className="message-location">[{getTranslatedText(msg.location_name)}]</span>
)}
</div>
))}
@@ -555,65 +585,16 @@ function LocationView({
const isShaking = failedActionItemId == item.id;
const itemId = `item-${item.id}-${i}`;
const renderPickupOptions = () => {
const options = [];
options.push({ label: `${t('common.pickUp')} (x1)`, qty: 1 });
if (item.quantity >= 5) options.push({ label: `${t('common.pickUp')} (x5)`, qty: 5 });
if (item.quantity >= 10) options.push({ label: `${t('common.pickUp')} (x10)`, qty: 10 });
if (item.quantity > 1) options.push({ label: t('common.pickUpAll'), qty: item.quantity });
return (
<div className="pickup-options-vertical">
{options.map((opt) => (
<GameButton
key={opt.label}
variant="success"
size="sm"
onClick={(e) => {
e.stopPropagation();
playSfx('/audio/sfx/pickup.wav');
onPickup(Number(item.id), opt.qty);
setActiveDropdown(null);
}}
style={{ width: '100%', justifyContent: 'center' }}
>
{opt.label}
</GameButton>
))}
</div>
);
};
return (
<div key={itemId} className={`entity-card item-card grid-card ${isShaking ? 'shake-animation' : ''}`}
onClick={(e) => handleDropdownClick(e, itemId)}
>
<GameTooltip content={
<>
<ItemTooltipContent item={item} />
<div style={{ color: '#4caf50', fontSize: '0.8rem', marginTop: '0.5rem', textAlign: 'center' }}>Click to Interact</div>
</>
}>
<div className="grid-corpse-content">
{item.image_path ? (
<img
src={getAssetPath(item.image_path)}
alt={getTranslatedText(item.name)}
className="ground-item-image"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden');
}}
/>
) : null}
<div style={{ fontSize: '2rem', display: item.image_path ? 'none' : 'block' }} className={item.image_path ? 'hidden' : ''}>
{item.emoji}
</div>
{item.quantity > 1 && (
<div className="grid-quantity">x{item.quantity}</div>
)}
</div>
</GameTooltip>
<div key={itemId} className={`entity-wrapper ${isShaking ? 'shake-animation' : ''}`} style={{ position: 'relative' }}>
<GameItemCard
item={item}
onClick={(e) => handleDropdownClick(e, itemId)}
isActive={activeDropdown === itemId}
showQuantity={true}
showDurability={true}
className="entity-card item-card grid-card"
/>
{activeDropdown === itemId && (
<GameDropdown
@@ -622,27 +603,7 @@ function LocationView({
width="200px"
>
<div className="game-dropdown-header">{getTranslatedText(item.name)} {item.quantity > 1 ? `(x${item.quantity})` : ''}</div>
<GameButton
variant="success"
size="sm"
className="pickup-main-btn"
onClick={() => {
playSfx('/audio/sfx/pickup.wav');
onPickup(Number(item.id), 1);
setActiveDropdown(null);
}}
style={{ width: '100%', justifyContent: 'center', marginBottom: '8px' }}
>
{t('common.pickUp')}
</GameButton>
{item.quantity > 1 && (
<>
<div className="game-dropdown-divider" style={{ margin: '8px 0' }} />
{renderPickupOptions()}
</>
)}
{renderItemPickupOptions(item)}
</GameDropdown>
)}
</div>
@@ -697,7 +658,7 @@ function LocationView({
<div style={{ fontSize: '2.5rem' }}>
🧍
</div>
<div className="grid-quantity" style={{ top: '2px', right: '2px', bottom: 'auto', background: 'rgba(49, 130, 206, 0.8)', borderColor: 'rgba(99, 179, 237, 0.4)' }}>
<div className="item-quantity-badge" style={{ top: '2px', right: '2px', bottom: 'auto', background: 'rgba(49, 130, 206, 0.8)', borderColor: 'rgba(99, 179, 237, 0.4)' }}>
Lv.{player.level}
</div>
</div>
@@ -765,72 +726,63 @@ function LocationView({
{/* Corpse Loot Overlay Modal */}
{
expandedCorpse && corpseDetails && corpseDetails.loot_items && (
<div className="corpse-loot-overlay" onClick={() => onSetExpandedCorpse(null)}>
<div className="corpse-loot-modal" onClick={(e) => e.stopPropagation()}>
<div className="corpse-details-header">
<h4>{t('location.lootableItems')}</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' : ''}`}>
{/* Item Image */}
<div className="corpse-item-image">
{item.image_path ? (
<img
src={getAssetPath(item.image_path)}
alt={item.item_name}
className="item-img-thumb"
style={{ width: '40px', height: '40px', objectFit: 'contain', marginRight: '10px' }}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : <span style={{ fontSize: '2rem', marginRight: '10px' }}>{item.emoji || '📦'}</span>}
</div>
<div className="corpse-item-info" style={{ flex: 1 }}>
<div className="corpse-item-name">
{getTranslatedText(item.item_name)}
</div>
{item.description && <div className="corpse-item-desc" style={{ fontSize: '0.75rem', color: '#a0aec0' }}>{getTranslatedText(item.description)}</div>}
<div className="corpse-item-qty">
{t('common.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'}`}>
🔧 {getTranslatedText(item.required_tool_name)} {item.has_tool ? '✓' : '✗'}
</div>
)}
</div>
<GameTooltip content={!item.can_loot ? `Requires ${getTranslatedText(item.required_tool_name)}` : 'Loot this item'}>
<button
className="corpse-item-loot-btn"
onClick={() => onLootCorpseItem(expandedCorpse, item.index)}
disabled={!item.can_loot}
>
{item.can_loot ? `📦 ${t('common.loot')}` : '🔒'}
</button>
</GameTooltip>
<GameModal
title={t('location.lootableItems')}
onClose={() => onSetExpandedCorpse(null)}
className="corpse-loot-modal-wrapper"
>
<div className="corpse-items-list">
{corpseDetails.loot_items.map((item: any) => (
<div key={item.index} className={`corpse-item ${!item.can_loot ? 'locked' : ''}`}>
{/* Item Image */}
<div className="corpse-item-image">
{item.image_path ? (
<img
src={getAssetPath(item.image_path)}
alt={item.item_name}
className="item-img-thumb"
style={{ width: '40px', height: '40px', objectFit: 'contain', marginRight: '10px' }}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : <span style={{ fontSize: '2rem', marginRight: '10px' }}>{item.emoji || '📦'}</span>}
</div>
))}
</div>
<button
className="loot-all-btn"
onClick={() => onLootCorpseItem(expandedCorpse, null)}
>
📦 {t('common.lootAll')}
</button>
<div className="corpse-item-info" style={{ flex: 1 }}>
<div className="corpse-item-name">
{getTranslatedText(item.item_name)}
</div>
{item.description && <div className="corpse-item-desc" style={{ fontSize: '0.75rem', color: '#a0aec0' }}>{getTranslatedText(item.description)}</div>}
<div className="corpse-item-qty">
{t('common.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'}`}>
🔧 {getTranslatedText(item.required_tool_name)} {item.has_tool ? '✓' : '✗'}
</div>
)}
</div>
<GameTooltip content={!item.can_loot ? `Requires ${getTranslatedText(item.required_tool_name)}` : 'Loot this item'}>
<button
className="corpse-item-loot-btn"
onClick={() => onLootCorpseItem(expandedCorpse, item.index)}
disabled={!item.can_loot}
>
{item.can_loot ? `📦 ${t('common.loot')}` : '🔒'}
</button>
</GameTooltip>
</div>
))}
</div>
</div>
<button
className="loot-all-btn"
onClick={() => onLootCorpseItem(expandedCorpse, null)}
>
📦 {t('common.lootAll')}
</button>
</GameModal>
)
}
@@ -885,158 +837,152 @@ function LocationView({
)
}
{/* Entity "Show All" Modal */}
{entityModal && (
<GameModal
title={entityModal.title}
onClose={() => setEntityModal(null)}
className="entity-show-all-modal"
>
<div className="entity-modal-grid">
{entityModal.type === 'enemies' && location.npcs
.filter((npc: any) => npc.type === 'enemy')
.map((enemy: any) => {
const id = `modal-enemy-${enemy.id}`;
return (
<div key={enemy.id} className="entity-card enemy-card grid-card"
onClick={(e) => handleDropdownClick(e, id)}
>
{enemy.id && (
<div className="entity-image padded-image">
<img
src={getAssetPath(enemy.image_path || `images/npcs/${(typeof enemy.name === 'string' ? enemy.name : enemy.name?.en || '').toLowerCase().replace(/ /g, '_')}.webp`)}
alt={getTranslatedText(enemy.name)}
onError={(e: any) => { e.currentTarget.style.display = 'none' }}
/>
</div>
)}
<GameTooltip content={
<div>
<div className="tooltip-title">{getTranslatedText(enemy.name)}</div>
<div>{t('location.level')} {enemy.level}</div>
</div>
}>
<div className="grid-overlay"></div>
</GameTooltip>
{activeDropdown === id && (
<GameDropdown isOpen={true} onClose={() => setActiveDropdown(null)} width="160px">
<div className="game-dropdown-header">{getTranslatedText(enemy.name)}</div>
<GameButton variant="danger" size="sm"
onClick={() => { onInitiateCombat(enemy.id); setActiveDropdown(null); setEntityModal(null); }}
style={{ width: '100%', justifyContent: 'flex-start' }}
>
{t('common.fight')}
</GameButton>
</GameDropdown>
)}
</div>
);
})}
{entityModal.type === 'corpses' && (location.corpses || []).map((corpse: any) => (
<div key={corpse.id} className="entity-card corpse-card grid-card"
onClick={(e) => handleDropdownClick(e, `modal-corpse-${corpse.id}`)}
>
<div className="grid-corpse-content">
{corpse.image_path ? (
<img src={getAssetPath(corpse.image_path)} alt={getTranslatedText(corpse.name)} className="corpse-image"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
) : <div style={{ fontSize: '2rem' }}>{corpse.emoji}</div>}
<div className="corpse-loot-count">{corpse.loot_count} items</div>
</div>
{activeDropdown === `modal-corpse-${corpse.id}` && (
<GameDropdown isOpen={true} onClose={() => setActiveDropdown(null)} width="160px">
<div className="game-dropdown-header">{getTranslatedText(corpse.name)}</div>
<GameButton variant="secondary" size="sm"
onClick={() => { playSfx('/audio/sfx/interact.wav'); onLootCorpse(String(corpse.id)); setActiveDropdown(null); setEntityModal(null); }}
disabled={corpse.loot_count === 0}
style={{ width: '100%', justifyContent: 'flex-start' }}
{
entityModal && (
<GameModal
title={entityModal.title}
onClose={() => setEntityModal(null)}
className="entity-show-all-modal"
>
<div className="entity-modal-grid">
{entityModal.type === 'enemies' && location.npcs
.filter((npc: any) => npc.type === 'enemy')
.map((enemy: any) => {
const id = `modal-enemy-${enemy.id}`;
return (
<div key={enemy.id} className="entity-card enemy-card grid-card"
onClick={(e) => handleDropdownClick(e, id)}
>
🔍 {t('common.examine')}
</GameButton>
</GameDropdown>
)}
</div>
))}
{enemy.id && (
<div className="entity-image padded-image">
<img
src={getAssetPath(enemy.image_path || `images/npcs/${(typeof enemy.name === 'string' ? enemy.name : enemy.name?.en || '').toLowerCase().replace(/ /g, '_')}.webp`)}
alt={getTranslatedText(enemy.name)}
onError={(e: any) => { e.currentTarget.style.display = 'none' }}
/>
</div>
)}
<GameTooltip content={
<div>
<div className="tooltip-title">{getTranslatedText(enemy.name)}</div>
<div>{t('location.level')} {enemy.level}</div>
</div>
}>
<div className="grid-overlay"></div>
</GameTooltip>
{activeDropdown === id && (
<GameDropdown isOpen={true} onClose={() => setActiveDropdown(null)} width="160px">
<div className="game-dropdown-header">{getTranslatedText(enemy.name)}</div>
<GameButton variant="danger" size="sm"
onClick={() => { onInitiateCombat(enemy.id); setActiveDropdown(null); setEntityModal(null); }}
style={{ width: '100%', justifyContent: 'flex-start' }}
>
{t('common.fight')}
</GameButton>
</GameDropdown>
)}
</div>
);
})}
{entityModal.type === 'npcs' && location.npcs
.filter((npc: any) => npc.type !== 'enemy')
.map((npc: any, i: number) => (
<div key={i} className="entity-card npc-card grid-card"
onClick={() => { handleNpcClick(npc); setEntityModal(null); }}
style={{ cursor: 'pointer' }}
>
{npc.image_path ? (
<img src={getAssetPath(npc.image_path)} alt={getTranslatedText(npc.name)} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : <span className="entity-icon" style={{ fontSize: '2.5rem' }}>🧑</span>}
<div className="grid-overlay"></div>
</div>
))}
{entityModal.type === 'items' && [...location.items]
.sort((a: any, b: any) => (a.id || 0) - (b.id || 0))
.map((item: any, i: number) => {
const itemId = `modal-item-${item.id}-${i}`;
return (
<div key={itemId} className="entity-card item-card grid-card"
onClick={(e) => handleDropdownClick(e, itemId)}
>
<GameTooltip content={<ItemTooltipContent item={item} />}>
<div className="grid-corpse-content">
{item.image_path ? (
<img src={getAssetPath(item.image_path)} alt={getTranslatedText(item.name)} className="ground-item-image"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
) : <div style={{ fontSize: '2rem' }}>{item.emoji}</div>}
{item.quantity > 1 && <div className="grid-quantity">x{item.quantity}</div>}
</div>
</GameTooltip>
{activeDropdown === itemId && (
<GameDropdown isOpen={true} onClose={() => setActiveDropdown(null)} width="200px">
<div className="game-dropdown-header">{getTranslatedText(item.name)}</div>
<GameButton variant="success" size="sm"
onClick={() => { playSfx('/audio/sfx/pickup.wav'); onPickup(Number(item.id), 1); setActiveDropdown(null); setEntityModal(null); }}
style={{ width: '100%', justifyContent: 'center' }}
>
{t('common.pickUp')}
</GameButton>
</GameDropdown>
)}
</div>
);
})}
{entityModal.type === 'players' && (location.other_players || []).map((player: any, i: number) => {
const playerId = `modal-player-${player.id}-${i}`;
return (
<div key={i} className="entity-card player-card grid-card"
onClick={(e) => handleDropdownClick(e, playerId)}
{entityModal.type === 'corpses' && (location.corpses || []).map((corpse: any) => (
<div key={corpse.id} className="entity-card corpse-card grid-card"
onClick={(e) => handleDropdownClick(e, `modal-corpse-${corpse.id}`)}
>
<div className="grid-corpse-content">
<div style={{ fontSize: '2.5rem' }}>🧍</div>
<div className="grid-quantity" style={{ top: '2px', right: '2px', bottom: 'auto', background: 'rgba(49, 130, 206, 0.8)', borderColor: 'rgba(99, 179, 237, 0.4)' }}>
Lv.{player.level}
</div>
{corpse.image_path ? (
<img src={getAssetPath(corpse.image_path)} alt={getTranslatedText(corpse.name)} className="corpse-image"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
) : <div style={{ fontSize: '2rem' }}>{corpse.emoji}</div>}
<div className="corpse-loot-count">{corpse.loot_count} items</div>
</div>
{activeDropdown === playerId && (
<GameDropdown isOpen={true} onClose={() => setActiveDropdown(null)} width="180px">
<div className="game-dropdown-header">{player.name || player.username}</div>
{player.can_pvp ? (
<GameButton variant="danger" size="sm"
onClick={() => { onInitiatePvP(player.id); setActiveDropdown(null); setEntityModal(null); }}
style={{ width: '100%', justifyContent: 'flex-start' }}
>
{t('game.attack')}
</GameButton>
) : (
<div style={{ padding: '0.5rem', fontSize: '0.8rem', color: '#a0aec0' }}>PvP Unavailable</div>
)}
{activeDropdown === `modal-corpse-${corpse.id}` && (
<GameDropdown isOpen={true} onClose={() => setActiveDropdown(null)} width="160px">
<div className="game-dropdown-header">{getTranslatedText(corpse.name)}</div>
<GameButton variant="secondary" size="sm"
onClick={() => { playSfx('/audio/sfx/interact.wav'); onLootCorpse(String(corpse.id)); setActiveDropdown(null); setEntityModal(null); }}
disabled={corpse.loot_count === 0}
style={{ width: '100%', justifyContent: 'flex-start' }}
>
🔍 {t('common.examine')}
</GameButton>
</GameDropdown>
)}
</div>
);
})}
</div>
</GameModal>
)}
))}
{entityModal.type === 'npcs' && location.npcs
.filter((npc: any) => npc.type !== 'enemy')
.map((npc: any, i: number) => (
<div key={i} className="entity-card npc-card grid-card"
onClick={() => { handleNpcClick(npc); setEntityModal(null); }}
style={{ cursor: 'pointer' }}
>
{npc.image_path ? (
<img src={getAssetPath(npc.image_path)} alt={getTranslatedText(npc.name)} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : <span className="entity-icon" style={{ fontSize: '2.5rem' }}>🧑</span>}
<div className="grid-overlay"></div>
</div>
))}
{entityModal.type === 'items' && [...location.items]
.sort((a: any, b: any) => (a.id || 0) - (b.id || 0))
.map((item: any, i: number) => {
const itemId = `modal-item-${item.id}-${i}`;
return (
<div key={itemId} style={{ position: 'relative' }}>
<GameItemCard
item={item}
onClick={(e) => handleDropdownClick(e, itemId)}
isActive={activeDropdown === itemId}
showQuantity={true}
showDurability={true}
className="entity-card item-card"
/>
{activeDropdown === itemId && (
<GameDropdown isOpen={true} onClose={() => setActiveDropdown(null)} width="200px">
<div className="game-dropdown-header">{getTranslatedText(item.name)} {item.quantity > 1 ? `(x${item.quantity})` : ''}</div>
{renderItemPickupOptions(item, true)}
</GameDropdown>
)}
</div>
);
})}
{entityModal.type === 'players' && (location.other_players || []).map((player: any, i: number) => {
const playerId = `modal-player-${player.id}-${i}`;
return (
<div key={i} className="entity-card player-card grid-card"
onClick={(e) => handleDropdownClick(e, playerId)}
>
<div className="grid-corpse-content">
<div style={{ fontSize: '2.5rem' }}>🧍</div>
<div className="grid-quantity" style={{ top: '2px', right: '2px', bottom: 'auto', background: 'rgba(49, 130, 206, 0.8)', borderColor: 'rgba(99, 179, 237, 0.4)' }}>
Lv.{player.level}
</div>
</div>
{activeDropdown === playerId && (
<GameDropdown isOpen={true} onClose={() => setActiveDropdown(null)} width="180px">
<div className="game-dropdown-header">{player.name || player.username}</div>
{player.can_pvp ? (
<GameButton variant="danger" size="sm"
onClick={() => { onInitiatePvP(player.id); setActiveDropdown(null); setEntityModal(null); }}
style={{ width: '100%', justifyContent: 'flex-start' }}
>
{t('game.attack')}
</GameButton>
) : (
<div style={{ padding: '0.5rem', fontSize: '0.8rem', color: '#a0aec0' }}>PvP Unavailable</div>
)}
</GameDropdown>
)}
</div>
);
})}
</div>
</GameModal>
)
}
</div >
)
}

View File

@@ -112,9 +112,11 @@ function MovementControls({
const outsideDir = location.directions.includes('outside') ? 'outside' : null;
const enterDir = location.directions.includes('enter') ? 'enter' : null;
const exitDir = location.directions.includes('exit') ? 'exit' : null;
const upDir = location.directions.includes('up') ? 'up' : null;
const downDir = location.directions.includes('down') ? 'down' : null;
// Priority: Inside/Outside (usually mutually exclusive) > Enter/Exit
const centerDirection = insideDir || outsideDir || enterDir || exitDir;
const centerDirection = insideDir || outsideDir || enterDir || exitDir || upDir || downDir;
if (!centerDirection) {
// Default Compass Icon
@@ -136,6 +138,8 @@ function MovementControls({
let icon = '🚪';
if (centerDirection === 'inside') icon = '🏠';
if (centerDirection === 'outside') icon = '🌳';
if (centerDirection === 'up') icon = '⬆️';
if (centerDirection === 'down') icon = '⬇️';
const tooltipText = profile?.is_dead ? t('messages.youAreDead') :
movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) :
@@ -191,44 +195,6 @@ function MovementControls({
{renderCompassButton('southeast', '↘️', 'se')}
</div>
{(location.directions.includes('up') || location.directions.includes('down')) && (
<div className="special-moves" style={{ display: 'flex', justifyContent: 'center', gap: '0.5rem' }}>
{location.directions.includes('up') && (
<GameTooltip content={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : (
<div className="movement-tooltip">
<div className="tooltip-title">{t('directions.up')}</div>
<div className="tooltip-stat"> {t('game.stamina')}: {getStaminaCost('up')}</div>
</div>
)}>
<button
onClick={() => onMove('up')}
className="compass-center-btn"
disabled={!!combatState || movementCooldown > 0}
style={{ padding: '0.3rem 1rem' }}
>
{t('directions.up')} <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('up')}`}</span>
</button>
</GameTooltip>
)}
{location.directions.includes('down') && (
<GameTooltip content={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : (
<div className="movement-tooltip">
<div className="tooltip-title">{t('directions.down')}</div>
<div className="tooltip-stat"> {t('game.stamina')}: {getStaminaCost('down')}</div>
</div>
)}>
<button
onClick={() => onMove('down')}
className="compass-center-btn"
disabled={!!combatState || movementCooldown > 0}
style={{ padding: '0.3rem 1rem' }}
>
{t('directions.down')} <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('down')}`}</span>
</button>
</GameTooltip>
)}
</div>
)}
</div>
{/* Surroundings - outside movement controls */}

View File

@@ -1,4 +1,5 @@
import { useState } from 'react'
import { useGame } from '../../contexts/GameContext'
import { useTranslation } from 'react-i18next'
import type { PlayerState, Profile, Equipment } from './types'
import { getAssetPath } from '../../utils/assetPath'
@@ -7,6 +8,8 @@ import InventoryModal from './InventoryModal'
import { GameProgressBar } from '../common/GameProgressBar'
import { GameTooltip } from '../common/GameTooltip'
import { GameButton } from '../common/GameButton'
import { GameItemCard } from '../common/GameItemCard'
import { GameDropdown } from '../common/GameDropdown'
import { useAudio } from '../../contexts/AudioContext'
interface PlayerSidebarProps {
@@ -43,107 +46,63 @@ function PlayerSidebar({
onOpenQuestJournal
}: PlayerSidebarProps) {
const [showInventory, setShowInventory] = useState(false)
const [activeSlot, setActiveSlot] = useState<string | null>(null)
const { t } = useTranslation()
const { playSfx } = useAudio()
const { state } = useGame() // Use global state to check quests
// Check if any quest is ready to turn in
const hasReadyQuests = state.quests.active?.some((q: any) => {
// Check if all objectives met
if (!q.objectives) return false;
return q.objectives.every((obj: any) => {
const current = q.progress?.[obj.target] || 0;
return current >= obj.count;
});
});
const renderEquipmentSlot = (slot: string, item: any, label: string) => {
// Construct the tooltip content if item exists
const tooltipContent = item ? (
<div className="game-tooltip-stats">
<div className="item-tooltip-name" style={{ color: 'var(--game-text-highlight)', fontWeight: 'bold' }}>
{getTranslatedText(item.name)}
</div>
{item.tier !== undefined && item.tier !== null && item.tier > 0 && (
<div className="item-tooltip-stat" style={{ color: '#fbbf24' }}>
Tier: {item.tier}
</div>
)}
{item.description && <div className="item-tooltip-desc" style={{ color: 'var(--game-text-secondary)', fontStyle: 'italic', marginBottom: '0.5rem' }}>{getTranslatedText(item.description)}</div>}
{(item.unique_stats || item.stats) && Object.keys(item.unique_stats || item.stats).length > 0 && (
<div style={{ display: 'grid', gridTemplateColumns: 'auto auto', gap: '0.25rem 1rem' }}>
{(item.unique_stats?.armor || item.stats?.armor) && (
<div className="item-tooltip-stat">
{t('stats.armor')}: <span style={{ color: 'var(--game-color-success)' }}>+{item.unique_stats?.armor || item.stats?.armor}</span>
</div>
)}
{(item.unique_stats?.hp_max || item.stats?.hp_max) && (
<div className="item-tooltip-stat">
{t('stats.hp')}: <span style={{ color: 'var(--game-color-success)' }}>+{item.unique_stats?.hp_max || item.stats?.hp_max}</span>
</div>
)}
{(item.unique_stats?.stamina_max || item.stats?.stamina_max) && (
<div className="item-tooltip-stat">
{t('stats.stamina')}: <span style={{ color: 'var(--game-color-stamina)' }}>+{item.unique_stats?.stamina_max || item.stats?.stamina_max}</span>
</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">
{t('stats.damage')}: <span style={{ color: 'var(--game-color-primary)' }}>{item.unique_stats?.damage_min || item.stats?.damage_min}-{item.unique_stats?.damage_max || item.stats?.damage_max}</span>
</div>
)}
{(item.unique_stats?.weight_capacity || item.stats?.weight_capacity) && (
<div className="item-tooltip-stat">
{t('stats.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">
{t('stats.volume')}: +{item.unique_stats?.volume_capacity || item.stats?.volume_capacity}L
</div>
)}
</div>
)}
{item.durability !== undefined && item.durability !== null && (
<div className="item-tooltip-stat" style={{ marginTop: '0.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2px' }}>
<span>{t('stats.durability')}:</span>
<span>{item.durability}/{item.max_durability}</span>
</div>
<GameProgressBar
value={item.durability}
max={item.max_durability}
type="durability"
height="6px"
showText={false}
/>
</div>
)}
</div>
) : label; // Show label if no item
// Merge with full inventory data to ensure tooltips have weight/volume
const fullItemInfo = playerState.inventory?.find((i: any) => i.is_equipped && i.equipment_slot === slot) || item;
return (
<div className={`equipment-slot game-slot ${item ? 'filled' : 'empty'}`}>
{item ? (
<>
<GameTooltip content={t('game.unequip')}>
<button className="equipment-unequip-btn game-btn game-btn-icon" onClick={(e) => { e.stopPropagation(); onUnequipItem(slot); playSfx('/audio/sfx/unequip.wav'); }}></button>
</GameTooltip>
<GameTooltip content={tooltipContent}>
<div className="equipment-item-content">
{item.image_path ? (
<img
src={getAssetPath(item.image_path)}
alt={getTranslatedText(item.name)}
className="equipment-emoji"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
) : (
<span className="equipment-emoji" style={{ fontSize: '2rem' }}>{item.emoji}</span>
)}
{item.durability !== undefined && item.durability !== null && (
<div className="equipment-durability-bar-container" style={{ width: '90%', marginTop: 'auto', marginBottom: '4px' }}>
<GameProgressBar
value={item.durability}
max={item.max_durability}
type="durability"
height="4px"
showText={false}
/>
</div>
)}
</div>
</GameTooltip>
<GameItemCard
item={fullItemInfo}
showTooltip={activeSlot !== slot} // Hide tooltip when dropdown configures
showDurability={true}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
// Toggle active slot
setActiveSlot(activeSlot === slot ? null : slot);
}}
isActive={activeSlot === slot}
className="equipment-item-content"
style={{ width: '100%', height: '100%' }}
/>
{activeSlot === slot && (
<GameDropdown isOpen={true} onClose={() => setActiveSlot(null)} width="160px">
<div className="game-dropdown-header">
{getTranslatedText(item.name)}
</div>
<GameButton
variant="info"
size="sm"
onClick={(e) => {
e.stopPropagation();
setActiveSlot(null);
onUnequipItem(slot);
playSfx('/audio/sfx/unequip.wav');
}}
style={{ width: '100%', justifyContent: 'flex-start' }}
>
{t('game.unequip')}
</GameButton>
</GameDropdown>
)}
</>
) : (
<GameTooltip content={label}>
@@ -305,13 +264,13 @@ function PlayerSidebar({
</GameButton>
<GameButton
className="quest-journal-btn"
variant="secondary"
className={`quest-journal-btn ${hasReadyQuests ? 'pulse-gold' : ''}`}
variant={hasReadyQuests ? 'warning' : 'secondary'}
size="sm"
onClick={onOpenQuestJournal}
style={{ flex: 1, justifyContent: 'center' }}
>
📜 {t('common.quests')}
{hasReadyQuests ? '❗ ' : '📜 '}{t('common.quests')}
</GameButton>
</div>
</div>

View File

@@ -1,66 +1,37 @@
.quest-journal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
z-index: 1000;
.quest-journal-modal {
width: 90vw;
max-width: 1200px;
height: 95%;
}
.quest-journal-modal .game-modal-content {
display: flex;
align-items: center;
justify-content: center;
}
.journal-container {
background: rgba(20, 20, 20, 0.95);
border: 1px solid #444;
border-radius: 8px;
padding: 20px;
max-width: 800px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
color: #e0e0e0;
position: relative;
}
.journal-title {
color: #ff9800;
border-bottom: 2px solid #555;
padding-bottom: 10px;
margin-top: 0;
}
.journal-close-btn {
position: absolute;
top: 15px;
right: 15px;
background: none;
border: none;
color: #888;
font-size: 1.5rem;
cursor: pointer;
}
.journal-close-btn:hover {
color: #fff;
flex-direction: column;
height: 100%;
padding: 0;
overflow: hidden;
/* Manage scroll internally */
}
/* Tabs - matching Workbench style but full width split */
.tab-container {
display: flex;
margin-bottom: 20px;
border-bottom: 1px solid #444;
gap: 10px;
}
.journal-tab {
flex: 1;
background: transparent;
border: none;
border-bottom: 3px solid transparent;
color: #aaa;
padding: 10px 20px;
border: 1px solid transparent;
color: #a0aec0;
padding: 10px;
cursor: pointer;
font-size: 1rem;
font-weight: 600;
transition: all 0.2s;
clip-path: var(--game-clip-path);
text-align: center;
border-bottom: none;
/* Override old */
}
.journal-tab:hover {
@@ -69,78 +40,389 @@
}
.journal-tab.active {
background: rgba(255, 152, 0, 0.2);
border-bottom: 3px solid #ff9800;
color: #ff9800;
background: #3182ce;
color: #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
border-color: transparent;
/* Override old */
}
.quest-list {
/* Main Layout */
.journal-layout {
display: flex;
flex-direction: row;
height: 100%;
overflow: hidden;
}
/* Quest List Column */
.quest-list-column {
width: 40%;
min-width: 300px;
border-right: 1px solid var(--game-border-color);
display: flex;
flex-direction: column;
gap: 15px;
background: rgba(0, 0, 0, 0.2);
position: relative;
}
.quest-card {
background: rgba(0, 0, 0, 0.3);
border: 1px solid #555;
border-radius: 5px;
/* Search Bar (Game Style) - Removed (using Game.css global) */
.game-search-container {
margin: 10px;
}
/* Quest List Area */
.quest-list-scroll {
flex: 1;
overflow-y: auto;
padding: 10px;
padding-bottom: 60px;
}
/* Quest List Cards */
.quest-list-item {
padding: 15px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.05);
margin-bottom: 10px;
cursor: pointer;
transition: all 0.2s;
position: relative;
clip-path: var(--game-clip-path);
display: flex;
flex-direction: column;
gap: 5px;
}
.quest-card.completed {
border-color: #4caf50;
.quest-list-item:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
}
.quest-card h3 {
margin: 0 0 5px 0;
color: #ddd;
.quest-list-item.selected {
border-color: var(--game-color-primary);
box-shadow: 0 0 0 1px var(--game-color-primary);
}
.quest-list-item h4 {
margin: 0;
font-size: 1.1rem;
color: #eee;
font-weight: 600;
}
.quest-card-type {
align-self: flex-start;
font-size: 0.75rem;
padding: 2px 8px;
clip-path: var(--game-clip-path);
background: rgba(0, 0, 0, 0.3);
color: #aaa;
border: 1px solid rgba(255, 255, 255, 0.1);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.quest-list-item.selected h4 {
color: var(--game-color-primary);
}
.quest-status-indicator {
position: absolute;
top: 10px;
right: 10px;
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-active {
background-color: #2196f3;
box-shadow: 0 0 5px #2196f3;
}
.status-completed {
background-color: #4caf50;
box-shadow: 0 0 5px #4caf50;
}
/* Pagination - Sticky at Bottom */
.pagination-controls {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 15px;
border-top: 1px solid var(--game-border-color);
background: rgba(18, 18, 18, 0.95);
position: sticky;
bottom: 0;
z-index: 10;
margin-top: auto;
}
.quest-card.completed h3 {
color: #4caf50;
/* Right Column: Quest Details */
.quest-details-column {
flex: 1;
padding: 30px;
overflow-y: auto;
background: rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
}
.quest-desc {
.quest-details-header {
margin-bottom: 25px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding-bottom: 20px;
}
.quest-details-header h2 {
margin: 0;
font-size: 2rem;
color: var(--game-color-primary);
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
}
.quest-giver-info {
margin-top: 15px;
display: flex;
gap: 15px;
align-items: flex-start;
background: rgba(0, 0, 0, 0.2);
padding: 10px;
clip-path: var(--game-clip-path);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.quest-giver-image {
width: 100px;
height: 100px;
clip-path: var(--game-clip-path);
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4);
background: var(--game-bg-app)
}
.quest-giver-details {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.9rem;
color: #ccc;
margin-bottom: 10px;
font-style: italic;
}
.objective-list {
.label {
color: #888;
margin-right: 5px;
}
.value {
color: #ddd;
font-weight: 500;
}
.quest-description {
font-size: 1.05rem;
line-height: 1.7;
color: #e0e0e0;
margin-bottom: 30px;
white-space: pre-wrap;
background: rgba(0, 0, 0, 0.1);
padding: 15px;
clip-path: var(--game-clip-path);
border-left: 2px solid var(--game-color-primary);
}
.quest-section-title {
font-size: 0.9rem;
color: #aaa;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 15px;
margin-top: 25px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding-bottom: 5px;
display: inline-block;
}
.objective-list,
.rewards-list {
list-style: none;
padding: 0;
margin: 10px 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.objective-item {
color: #aaa;
margin-bottom: 4px;
padding: 12px 15px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
color: #ccc;
clip-path: var(--game-clip-path);
display: flex;
align-items: center;
}
.objective-item.met {
color: #8bc34a;
}
.objective-item:before {
content: '○';
margin-right: 8px;
.objective-item::before {
content: "○";
margin-right: 10px;
color: #aaa;
font-weight: bold;
color: #777;
}
.objective-item.met:before {
content: '✓';
color: #8bc34a;
.objective-item.met {
background: rgba(76, 175, 80, 0.1);
border-color: rgba(76, 175, 80, 0.3);
color: #a5d6a7;
text-decoration: none;
}
.empty-message {
text-align: center;
padding: 40px;
color: #777;
.objective-item.met::before {
content: "✓";
color: #4caf50;
}
.rewards-list li {
padding: 10px;
color: #ffd700;
background: rgba(255, 215, 0, 0.05);
border: 1px solid rgba(255, 215, 0, 0.2);
clip-path: var(--game-clip-path);
display: flex;
align-items: center;
}
.rewards-list li::before {
content: "🎁";
margin-right: 10px;
}
.history-dates {
margin-top: auto;
padding-top: 30px;
font-size: 0.85rem;
color: #666;
border-top: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
flex-direction: column;
gap: 5px;
}
.completion-text {
font-style: italic;
color: #b0bec5;
padding: 15px;
background: rgba(255, 255, 255, 0.05);
border-left: 2px solid #b0bec5;
clip-path: var(--game-clip-path);
margin-bottom: 20px;
}
.empty-selection {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #666;
font-size: 1.2rem;
font-style: italic;
background: repeating-linear-gradient(45deg,
rgba(0, 0, 0, 0.1),
rgba(0, 0, 0, 0.1) 10px,
rgba(0, 0, 0, 0.15) 10px,
rgba(0, 0, 0, 0.15) 20px);
}
/* Scrollbar Styling */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.2);
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
/* Global Quest Styles */
.objective-item.global-objective {
flex-direction: column;
align-items: stretch;
gap: 8px;
background: rgba(33, 150, 243, 0.1);
border-color: rgba(33, 150, 243, 0.3);
padding-bottom: 12px;
}
.objective-item.global-objective::before {
content: none;
}
.objective-label {
display: flex;
justify-content: space-between;
align-items: center;
color: #fff;
margin-bottom: 4px;
}
.completed-badge {
font-size: 0.8rem;
color: #4caf50;
background: rgba(76, 175, 80, 0.1);
padding: 2px 6px;
border-radius: 4px;
border: 1px solid rgba(76, 175, 80, 0.3);
}
.global-progress-container {
background: rgba(0, 0, 0, 0.3);
padding: 8px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.progress-label {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: #aaa;
margin-bottom: 6px;
}
.progress-bar-bg {
height: 8px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
}
.progress-bar-fill.global {
height: 100%;
background: linear-gradient(90deg, #2196f3, #64b5f6);
box-shadow: 0 0 10px rgba(33, 150, 243, 0.5);
transition: width 0.5s ease-out;
}
.personal-contribution {
font-size: 0.85rem;
color: #ccc;
text-align: right;
margin-top: 4px;
}
.contribution-value {
color: #ffd700;
font-weight: bold;
}

View File

@@ -1,7 +1,10 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { useGame } from '../../contexts/GameContext';
import { GameModal } from './GameModal';
import { GameButton } from '../common/GameButton';
import { useNotification } from '../../contexts/NotificationContext';
import './QuestJournal.css';
import axios from 'axios';
interface Quest {
quest_id: string;
@@ -14,18 +17,76 @@ interface Quest {
type: string;
completion_text?: { [key: string]: string } | string;
completed_at?: number;
started_at?: number;
giver_name?: { [key: string]: string } | string;
giver_location_id?: string;
giver_location_name?: { [key: string]: string } | string;
giver_image?: string;
}
interface QuestJournalProps {
onClose: () => void;
}
import { GAME_API_URL } from '../../config';
import { useTranslation } from 'react-i18next';
export const QuestJournal: React.FC<QuestJournalProps> = ({ onClose }) => {
const { locale, state } = useGame(); // Use global state
const { t } = useTranslation();
const { locale, state } = useGame();
const { addNotification } = useNotification();
// Tabs
const [activeTab, setActiveTab] = useState<'active' | 'completed'>('active');
// Derived from global state
const quests = (state.quests.active || []) as Quest[];
// Selection - now using a composite key
const [selectedQuestKey, setSelectedQuestKey] = useState<string | null>(null);
// Search
const [searchQuery, setSearchQuery] = useState('');
// Data
const [activeQuests, setActiveQuests] = useState<Quest[]>([]);
const [historyQuests, setHistoryQuests] = useState<Quest[]>([]);
// Pagination for History
const [historyPage, setHistoryPage] = useState(1);
const [historyTotalPages, setHistoryTotalPages] = useState(1);
const [loadingHistory, setLoadingHistory] = useState(false);
// Initial Load of Active Quests from Game State
useEffect(() => {
if (state.quests && state.quests.active) {
setActiveQuests(state.quests.active as Quest[]);
}
}, [state.quests]);
// Fetch History when tab changes to completed or page changes
useEffect(() => {
if (activeTab === 'completed') {
fetchHistory(historyPage);
}
}, [activeTab, historyPage]);
const fetchHistory = async (page: number) => {
setLoadingHistory(true);
try {
const token = localStorage.getItem('token');
const response = await axios.get(`${GAME_API_URL}/quests/history`, {
params: { page, limit: 10 }, // 10 per page for better UI fit
headers: { Authorization: `Bearer ${token}` }
});
setHistoryQuests(response.data.data);
setHistoryTotalPages(response.data.pages);
setHistoryPage(response.data.page);
} catch (error) {
console.error("Failed to fetch quest history", error);
addNotification(t('common.error'), "error");
} finally {
setLoadingHistory(false);
}
};
const getLocalizedText = (textObj: any) => {
if (typeof textObj === 'string') return textObj;
@@ -33,25 +94,111 @@ export const QuestJournal: React.FC<QuestJournalProps> = ({ onClose }) => {
return textObj[locale] || textObj['en'] || Object.values(textObj)[0] || '';
};
const filteredQuests = quests.filter((q: Quest) => {
if (activeTab === 'active') {
return q.status === 'active';
// Filter Logic
const getFilteredQuests = () => {
const source = activeTab === 'active' ? activeQuests : historyQuests;
if (!searchQuery) return source;
return source.filter(q => {
const title = getLocalizedText(q.title).toLowerCase();
return title.includes(searchQuery.toLowerCase());
});
};
const getQuestKey = (quest: Quest) => {
return quest.quest_id + (quest.completed_at ? `_${quest.completed_at}` : `_${quest.started_at || ''}`);
};
const filteredQuests = getFilteredQuests();
const selectedQuest = filteredQuests.find(q => getQuestKey(q) === selectedQuestKey) || (filteredQuests.length > 0 ? filteredQuests[0] : null);
// Automatically select first if selection is invalid/null or tab changes
useEffect(() => {
if (filteredQuests.length > 0) {
const currentKeyValid = selectedQuestKey && filteredQuests.some(q => getQuestKey(q) === selectedQuestKey);
if (!currentKeyValid) {
setSelectedQuestKey(getQuestKey(filteredQuests[0]));
}
} else {
return q.status === 'completed';
setSelectedQuestKey(null);
}
});
}, [filteredQuests, activeTab]); // Re-run when list changes or tab changes
// Renderers
const renderObjectives = (quest: Quest) => {
return quest.objectives.map((obj, idx) => {
const current = quest.progress[obj.target] || 0;
const required = obj.count;
const met = current >= required;
let label = obj.target;
if (!quest.objectives) return null;
if (obj.type === 'kill_count') {
label = `Kill ${obj.target}`;
} else if (obj.type === 'item_delivery') {
label = `Deliver ${obj.target}`;
// GLOBAL QUEST RENDERING
if (quest.type === 'global') {
return quest.objectives.map((obj, idx) => {
const required = obj.count;
// Personal Progress
const personalCurrent = quest.progress?.[obj.target] || 0;
// Global Progress
// @ts-ignore - dynamic field
const globalProgressMap = quest.global_progress || {};
const globalCurrent = globalProgressMap[obj.target] || 0;
const isGlobalComplete = (quest as any).global_is_completed || globalCurrent >= required;
let label = obj.target;
if (obj.target_name) {
const targetName = getLocalizedText(obj.target_name);
label = targetName; // usually simplified for global counters? e.g. "Wood"
}
return (
<li key={idx} className="objective-item global-objective">
<div className="objective-label">
<strong>{label}</strong>
{isGlobalComplete ? <span className="completed-badge"> {t('common.completed')}</span> : null}
</div>
{/* Global Progress Bar */}
<div className="global-progress-container">
<div className="progress-label">
<span>{t('journal.communityProgress')}</span>
<span>{globalCurrent} / {required}</span>
</div>
<div className="progress-bar-bg">
<div
className="progress-bar-fill global"
style={{ width: `${Math.min(100, (globalCurrent / required) * 100)}%` }}
></div>
</div>
</div>
{/* Personal Contribution */}
<div className="personal-contribution">
<span>{t('journal.yourContribution')}: </span>
<span className="contribution-value">{personalCurrent}</span>
</div>
</li>
);
});
}
// STANDARD QUEST RENDERING
return quest.objectives.map((obj, idx) => {
const required = obj.count;
// Force completed count for history items to avoid confusing 0/X display
const isCompleted = quest.status === 'completed' || activeTab === 'completed';
const current = isCompleted ? required : (quest.progress?.[obj.target] || 0);
const met = current >= required || isCompleted;
let label = obj.target;
// Improved translation logic
if (obj.target_name) {
// If we have an enriched name, use it.
// But we still want the prefix "Fight" or "Pick Up" if applicable
const targetName = getLocalizedText(obj.target_name);
if (obj.type === 'kill_count') label = `${t('game.kill')} ${targetName}`;
else if (obj.type === 'item_delivery') label = `${t('game.pickUp')} ${targetName}`;
else label = targetName;
} else {
// Fallback to basic translation if no enriched name
if (obj.type === 'kill_count') label = `${t('game.kill')} ${obj.target}`;
else if (obj.type === 'item_delivery') label = `${t('game.pickUp')} ${obj.target}`;
}
return (
@@ -62,59 +209,196 @@ export const QuestJournal: React.FC<QuestJournalProps> = ({ onClose }) => {
});
};
const renderDate = (timestamp?: number) => {
if (!timestamp) return 'N/A';
return new Date(timestamp * 1000).toLocaleString(locale === 'en' ? 'en-US' : locale);
};
return (
<GameModal
title="Quest Journal"
title={t('journal.title')}
onClose={onClose}
className="quest-journal-modal"
footer={
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
>
<div className="game-modal-content" style={{ display: 'flex', flexDirection: 'column', height: '100%', padding: 0 }}>
{/* Header / Tabs */}
<div style={{ padding: '10px 20px', background: 'rgba(0,0,0,0.5)', borderBottom: '1px solid #333' }}>
<div className="tab-container">
<button
className={`journal-tab ${activeTab === 'active' ? 'active' : ''}`}
onClick={() => setActiveTab('active')}
>
Active
{t('journal.activeQuests')}
</button>
<button
className={`journal-tab ${activeTab === 'completed' ? 'active' : ''}`}
onClick={() => setActiveTab('completed')}
>
Completed
{t('journal.history')}
</button>
</div>
</div>
}
>
<div className="journal-content">
<div className="quest-list">
{filteredQuests.length === 0 ? (
<div className="empty-message">No quests found in this category.</div>
) : (
filteredQuests.map((quest: Quest) => (
<div key={quest.quest_id} className={`quest-card ${quest.status === 'completed' ? 'completed' : ''}`}>
<h3>
{getLocalizedText(quest.title)}
{quest.type === 'global' && <span style={{ fontSize: '0.8rem', color: '#64b5f6', marginLeft: '10px' }}>GLOBAL</span>}
</h3>
<div className="quest-desc">{getLocalizedText(quest.description)}</div>
{quest.status === 'active' && (
<ul className="objective-list">
{renderObjectives(quest)}
</ul>
)}
{/* Main Content Area */}
<div className="journal-layout">
{/* LEFT COLUMN: LIST */}
<div className="quest-list-column">
<div className="game-search-container">
<span className="game-search-icon">🔍</span>
<input
type="text"
className="game-search-input"
placeholder={t('journal.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{quest.status === 'completed' && quest.completion_text && (
<div className="completion-text">
"{getLocalizedText(quest.completion_text)}"
</div>
)}
<div className="quest-list-scroll">
{filteredQuests.length === 0 ? (
<div style={{ padding: '20px', color: '#777', textAlign: 'center' }}>
{loadingHistory ? t('common.loading') : t('journal.noQuests')}
</div>
) : (
filteredQuests.map(quest => {
const key = getQuestKey(quest);
return (
<div
key={key}
className={`quest-list-item ${selectedQuestKey === key ? 'selected' : ''}`}
onClick={() => setSelectedQuestKey(key)}
>
<h4>{getLocalizedText(quest.title)}</h4>
<span className="quest-card-type">
{quest.type === 'global' ? t('journal.global') : (quest.type === 'story' ? t('journal.story') : t('journal.side'))}
</span>
<div className={`quest-status-indicator status-${quest.status === 'active' ? 'active' : 'completed'}`}></div>
</div>
);
})
)}
</div>
{/* Pagination Controls for History */}
{activeTab === 'completed' && (
<div className="pagination-controls">
<GameButton
className="pagination-btn"
size="sm"
disabled={historyPage <= 1 || loadingHistory}
onClick={() => setHistoryPage(p => p - 1)}
>
&laquo; {t('common.prev')}
</GameButton>
<span style={{ color: '#aaa', fontSize: '0.8rem' }}>
{loadingHistory ? '...' : `${historyPage} / ${historyTotalPages}`}
</span>
<GameButton
className="pagination-btn"
size="sm"
disabled={historyPage >= historyTotalPages || loadingHistory}
onClick={() => setHistoryPage(p => p + 1)}
>
{t('common.next')} &raquo;
</GameButton>
</div>
))
)}
)}
</div>
{/* RIGHT COLUMN: DETAILS */}
<div className="quest-details-column">
{selectedQuest ? (
<>
<div className="quest-details-header">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<h2>{getLocalizedText(selectedQuest.title)}</h2>
{activeTab === 'active' && selectedQuest.status === 'completed' && (
<span style={{ color: '#ffd700', fontWeight: 'bold' }}>{t('journal.ready')}</span>
)}
</div>
{/* Subtitle removed to avoid redundancy as requested */}
{/* Giver Info */}
{selectedQuest.giver_name && (
<div className="quest-giver-info">
{selectedQuest.giver_image && (
<img
src={`/${selectedQuest.giver_image}`}
className="quest-giver-image"
alt="Giver"
/>
)}
<div className="quest-giver-details">
<div>
<span className="label">{t('journal.giver')}:</span>
<span className="value">{getLocalizedText(selectedQuest.giver_name)}</span>
</div>
{(selectedQuest.giver_location_name || selectedQuest.giver_location_id) && (
<div>
<span className="label">{t('journal.location')}:</span>
<span className="value">
{selectedQuest.giver_location_name
? getLocalizedText(selectedQuest.giver_location_name)
: selectedQuest.giver_location_id
}
</span>
</div>
)}
</div>
</div>
)}
</div>
<div className="quest-description">
{getLocalizedText(selectedQuest.description)}
</div>
{/* Objectives - Show for both active and completed */}
<div className="quest-section-title">{t('journal.objectives')}</div>
<ul className="objective-list">
{renderObjectives(selectedQuest)}
</ul>
{selectedQuest.status === 'completed' && selectedQuest.completion_text && (
<>
<div className="quest-section-title">{t('journal.completionMessage')}</div>
<div className="completion-text" style={{ fontStyle: 'italic', color: '#aaa', padding: '10px', background: 'rgba(0,0,0,0.2)' }}>
"{getLocalizedText(selectedQuest.completion_text)}"
</div>
</>
)}
{selectedQuest.rewards && (
<>
<div className="quest-section-title">{t('journal.rewards')}</div>
<ul className="rewards-list">
{selectedQuest.rewards.xp && <li>{selectedQuest.rewards.xp} {t('stats.xp')}</li>}
{(selectedQuest as any).reward_items_details ?
Object.values((selectedQuest as any).reward_items_details).map((item: any, idx) => (
<li key={idx}>{getLocalizedText(item.name)} x{item.qty}</li>
))
:
selectedQuest.rewards.items && Object.entries(selectedQuest.rewards.items).map(([id, qty]) => (
<li key={id}>{id} x{qty as any}</li>
))
}
</ul>
</>
)}
<div className="history-dates">
{/* Show accepted date for both active and completed quests if available */}
{selectedQuest.started_at && <div>{t('journal.accepted')}: {renderDate(selectedQuest.started_at)}</div>}
{activeTab === 'completed' && selectedQuest.completed_at && <div>{t('journal.completed')}: {renderDate(selectedQuest.completed_at)}</div>}
</div>
</>
) : (
<div className="empty-selection">{t('journal.selectQuest')}</div>
)}
</div>
</div>
</div>
</GameModal>
</GameModal >
);
};

View File

@@ -3,7 +3,8 @@
.game-modal-container.trade-modal {
max-width: 1400px;
width: 95vw;
height: 90vh;
max-height: 90%;
height: 90%;
}
.trade-modal .game-modal-content {
@@ -50,30 +51,33 @@
}
.search-bar {
margin-bottom: 0.5rem;
padding: 0.75rem 1rem;
background: rgba(0, 0, 0, 0.5);
border: 1px solid #555;
color: white;
width: 100%;
margin-bottom: 0.5rem;
padding: 0.8rem 1rem 0.8rem 2.5rem;
background: rgba(0, 0, 0, 0.4);
border: 1px solid var(--game-border-color);
color: var(--game-text-primary);
font-family: var(--game-font-main);
font-size: 0.95rem;
clip-path: var(--game-clip-path-sm);
transition: all 0.2s ease;
box-sizing: border-box;
/* Fixes cut-off issue */
clip-path: var(--game-clip-path-sm, polygon(0 0,
100% 0,
100% calc(100% - 5px),
calc(100% - 5px) 100%,
0 100%));
}
.inventory-grid {
.search-bar:focus {
outline: none;
border-color: #6bb9f0;
background: rgba(0, 0, 0, 0.6);
box-shadow: 0 0 10px rgba(107, 185, 240, 0.2);
}
.trade-inventory-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
grid-auto-rows: max-content;
/* Ensure rows don't stretch */
grid-template-columns: repeat(auto-fill, 90px);
justify-content: center;
gap: 0.5rem;
overflow-y: auto;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 0.75rem;
padding: 0.5rem;
overflow-y: auto;
}
@@ -91,6 +95,10 @@
justify-content: center;
padding: 0.5rem;
box-shadow: var(--game-shadow-sm);
width: 90px;
height: 90px;
box-sizing: border-box;
flex-shrink: 0;
}
.trade-item-card:hover {
@@ -139,30 +147,10 @@
max-height: 100%;
object-fit: contain;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.5));
z-index: 1;
}
/* Exact match for quantity badge from InventoryModal.css */
.trade-item-qty {
position: absolute;
bottom: 2px;
right: 2px;
background: var(--game-bg-panel);
/* Match source */
border: 1px solid var(--game-border-color);
/* Match source */
color: var(--game-text-primary);
/* Match source */
font-size: 0.7rem;
/* Match source grid adjustment */
padding: 1px 4px;
/* Match source grid adjustment */
clip-path: var(--game-clip-path-sm);
/* Match source */
font-weight: bold;
box-shadow: var(--game-shadow-sm);
/* Match source */
pointer-events: none;
}
.trade-item-value {
position: absolute;
@@ -210,9 +198,10 @@
.cart-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
grid-template-columns: repeat(auto-fill, 90px);
justify-content: center;
grid-auto-rows: max-content;
gap: 8px;
gap: 0.5rem;
overflow-y: auto;
padding-right: 5px;
margin-top: 10px;
@@ -313,4 +302,5 @@
background: #1a202c;
border: 1px solid #4a5568;
color: white;
border-radius: 0;
}

View File

@@ -3,9 +3,9 @@ import { useGame } from '../../contexts/GameContext';
import { GAME_API_URL } from '../../config';
import { GameModal } from './GameModal';
import { GameButton } from '../common/GameButton';
import { GameTooltip } from '../common/GameTooltip';
import { getAssetPath } from '../../utils/assetPath';
import { GameItemCard } from '../common/GameItemCard';
import { getTranslatedText } from '../../utils/i18nUtils';
import { useTranslation } from 'react-i18next';
import './TradeModal.css';
interface TradeItem {
@@ -51,6 +51,7 @@ interface TradeModalProps {
export const TradeModal: React.FC<TradeModalProps> = ({ npcId, onClose }) => {
const { token, inventory: playerInv } = useGame();
const { t } = useTranslation();
const [npcStock, setNpcStock] = useState<TradeItem[]>([]);
const [playerItems, setPlayerItems] = useState<TradeItem[]>([]);
@@ -64,6 +65,19 @@ export const TradeModal: React.FC<TradeModalProps> = ({ npcId, onClose }) => {
const [npcSearch, setNpcSearch] = useState('');
const [playerSearch, setPlayerSearch] = useState('');
const categories = [
{ id: 'all', label: t('categories.all'), icon: '🎒' },
{ id: 'weapon', label: t('categories.weapon'), icon: '⚔️' },
{ id: 'armor', label: t('categories.armor'), icon: '🛡️' },
{ id: 'clothing', label: t('categories.clothing'), icon: '👕' },
{ id: 'backpack', label: t('categories.backpack'), icon: '🎒' },
{ id: 'tool', label: t('categories.tool'), icon: '🛠️' },
{ id: 'consumable', label: t('categories.consumable'), icon: '🍖' },
{ id: 'resource', label: t('categories.resource'), icon: '📦' },
{ id: 'quest', label: t('categories.quest'), icon: '📜' },
{ id: 'misc', label: t('categories.misc'), icon: '📦' }
];
// Selection logic
const [selectedItem, setSelectedItem] = useState<TradeItem | null>(null);
const [showQtyModal, setShowQtyModal] = useState(false);
@@ -138,6 +152,10 @@ export const TradeModal: React.FC<TradeModalProps> = ({ npcId, onClose }) => {
// Filter by search
const n = getTranslatedText(item.name).toLowerCase();
return n.includes(npcSearch.toLowerCase());
}).sort((a: any, b: any) => {
// High tier first, then name
if ((a.tier || 0) !== (b.tier || 0)) return (b.tier || 0) - (a.tier || 0);
return (getTranslatedText(a.name) || '').localeCompare(getTranslatedText(b.name) || '');
});
}, [npcStock, npcSearch, buying]);
@@ -156,6 +174,10 @@ export const TradeModal: React.FC<TradeModalProps> = ({ npcId, onClose }) => {
if (item._displayQuantity <= 0) return false;
if (item.is_equipped) return false; // Usually can't sell equipped items directly
return true;
}).sort((a: any, b: any) => {
// High tier first, then name
if ((a.tier || 0) !== (b.tier || 0)) return (b.tier || 0) - (a.tier || 0);
return (getTranslatedText(a.name) || '').localeCompare(getTranslatedText(b.name) || '');
});
}, [playerItems, playerSearch, selling]);
@@ -253,14 +275,46 @@ export const TradeModal: React.FC<TradeModalProps> = ({ npcId, onClose }) => {
}
};
// Tooltip Renderer (Reusable) - REMOVED as we use inline now to match InventoryModal structure better
// Drag and Drop Logic
const [dragOverZone, setDragOverZone] = useState<'buy' | 'sell' | null>(null);
const handleDragStart = (e: React.DragEvent, item: TradeItem, source: 'npc' | 'player') => {
e.dataTransfer.setData('application/json', JSON.stringify({ item, source }));
e.dataTransfer.effectAllowed = 'copy';
};
const handleDragOver = (e: React.DragEvent, zone: 'buy' | 'sell') => {
e.preventDefault();
if (dragOverZone !== zone) setDragOverZone(zone);
};
const handleDragLeave = () => {
setDragOverZone(null);
};
const handleDrop = (e: React.DragEvent, zone: 'buy' | 'sell') => {
e.preventDefault();
setDragOverZone(null);
try {
const data = JSON.parse(e.dataTransfer.getData('application/json'));
const { item, source } = data;
if (zone === 'buy' && source === 'npc') {
handleItemClick(item, 'npc');
} else if (zone === 'sell' && source === 'player') {
handleItemClick(item, 'player');
}
} catch (err) {
console.error('Failed to parse drag data', err);
}
};
if (!npcStock || !tradeConfig) return <div className="loading-text">Loading trade data...</div>;
return (
<GameModal
title="Trading"
title={t('trade.title')}
onClose={onClose}
className="trade-modal"
>
@@ -268,85 +322,40 @@ export const TradeModal: React.FC<TradeModalProps> = ({ npcId, onClose }) => {
<div className="trade-content">
{/* LEFT: NPC STOCK */}
<div className="trade-column">
<h3 className="column-header">Merchant Stock {tradeConfig.buy_markup && <small>(x{tradeConfig.buy_markup})</small>}</h3>
<input
type="text"
className="search-bar"
placeholder="Filter..."
value={npcSearch}
onChange={(e) => setNpcSearch(e.target.value)}
/>
<div className="inventory-grid">
{availableNpcStock.map((item, idx) => {
// Prepare tooltip content matching InventoryModal
const tooltipContent = (
<div className="item-tooltip-content">
<div className={`tooltip-header text-tier-${item.tier || 0}`}>
{item.emoji} {getTranslatedText(item.name)}
</div>
{item.description && <div className="tooltip-desc">{getTranslatedText(item.description)}</div>}
<div className="tooltip-stats">
<div style={{ color: '#ffd700' }}>💰 {Math.round(item.value * (tradeConfig.buy_markup || 1))}</div>
{item.weight !== undefined && <div> {item.weight}kg</div>}
{item.volume !== undefined && <div>📦 {item.volume}L</div>}
</div>
<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 Stats */}
{(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>
)}
{/* 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>
</div>
);
<h3 className="column-header">{t('trade.merchantStock')} {tradeConfig.buy_markup && <small>(x{tradeConfig.buy_markup})</small>}</h3>
<div className="game-search-container" style={{ marginBottom: '0.5rem' }}>
<span className="game-search-icon">🔍</span>
<input
type="text"
className="search-bar"
placeholder="Filter..."
value={npcSearch}
onChange={(e) => setNpcSearch(e.target.value)}
/>
</div>
<div className="trade-inventory-grid">
{categories.filter(cat => cat.id !== 'all').map(cat => {
const categoryItems = availableNpcStock.filter((item: any) => item.item_type === cat.id);
if (categoryItems.length === 0) return null;
return (
<GameTooltip key={idx} content={tooltipContent}>
<div className={`trade-item-card text-tier-${item.tier || 0}`} onClick={() => handleItemClick(item, 'npc')}>
<div className="trade-item-image">
{item.image_path ? (
<img src={getAssetPath(item.image_path)} alt={getTranslatedText(item.name)} className="trade-item-img" />
) : (
<div className={`item-icon-large tier-${item.tier || 0}`} style={{ fontSize: '2.5rem' }}>{item.emoji || '📦'}</div>
)}
</div>
{(item.is_infinite || (item as any)._displayQuantity > 1) && (
<div className="trade-item-qty">{item.is_infinite ? '∞' : `x${(item as any)._displayQuantity}`}</div>
)}
<div className="trade-item-value">{Math.round(item.value * (tradeConfig.buy_markup || 1))}</div>
<React.Fragment key={cat.id}>
<div className="category-header" style={{ gridColumn: '1 / -1', marginTop: '10px' }}>
<span className="subcat-icon">{cat.icon}</span>
<span className="subcat-label">{cat.label}</span>
</div>
</GameTooltip>
{categoryItems.map((item, idx) => (
<GameItemCard
key={idx}
item={item}
onClick={() => handleItemClick(item, 'npc')}
draggable={true}
onDragStart={(e) => handleDragStart(e, item, 'npc')}
showValue={true}
valueDisplayType="unit"
tradeMarkup={tradeConfig.buy_markup || 1}
/>
))}
</React.Fragment>
);
})}
</div>
@@ -354,52 +363,68 @@ export const TradeModal: React.FC<TradeModalProps> = ({ npcId, onClose }) => {
{/* CENTER: CART */}
<div className="trade-center-column">
<div className="trade-cart-section">
<div
className={`trade-cart-section ${dragOverZone === 'buy' ? 'drag-over' : ''}`}
onDragOver={(e) => handleDragOver(e, 'buy')}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, 'buy')}
>
<div className="trade-list-header">
<span>Buying</span>
<span>{t('trade.buying')}</span>
<span style={{ color: '#ff9800' }}>{Math.round(buyTotal)}</span>
</div>
<div className="cart-grid">
{buying.length === 0 && <div style={{ color: '#666', gridColumn: '1 / -1', textAlign: 'center', padding: '10px' }}>Empty</div>}
{buying.length === 0 && <div style={{ color: '#666', gridColumn: '1 / -1', textAlign: 'center', padding: '10px' }}>{t('trade.empty')}</div>}
{buying.map((b, i) => (
<GameTooltip key={i} content={<div>{getTranslatedText(b.name)}<br />x{b.quantity} - Total: {Math.round(b.value * (tradeConfig.buy_markup || 1) * b.quantity)}</div>}>
<div className={`trade-item-card text-tier-${b.tier || 0}`} onClick={() => {
<GameItemCard
key={i}
item={b}
onClick={() => {
const n = [...buying]; n.splice(i, 1); setBuying(n);
}}>
{b.image_path ? (
<img src={getAssetPath(b.image_path)} alt={getTranslatedText(b.name)} className="trade-item-img" />
) : (
<div style={{ fontSize: '24px' }}>{b.emoji || '📦'}</div>
)}
<div className="trade-item-qty">x{b.quantity}</div>
<div className="trade-item-value">{Math.round(b.value * (tradeConfig.buy_markup || 1) * b.quantity)}</div>
</div>
</GameTooltip>
}}
showValue={true}
valueDisplayType="total"
tradeMarkup={tradeConfig.buy_markup || 1}
actionHint={t('trade.clickToRemove')}
/>
))}
</div>
</div>
<div className="trade-cart-section">
{/* BALANCE INDICATOR MOVED TO CENTER DIVIDER */}
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '10px 0', borderTop: '1px solid #444', borderBottom: '1px solid #444', background: 'rgba(0,0,0,0.3)' }}>
<div className="trade-summary" style={{ flexDirection: 'row', gap: '15px' }}>
<span>{t('trade.balance')}:</span>
<span className={`trade-total ${sellTotal >= buyTotal ? 'text-green' : 'text-red'}`}>
{Math.round(sellTotal - buyTotal)} {sellTotal >= buyTotal ? '▲' : '▼'}
</span>
</div>
</div>
<div
className={`trade-cart-section ${dragOverZone === 'sell' ? 'drag-over' : ''}`}
onDragOver={(e) => handleDragOver(e, 'sell')}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, 'sell')}
>
<div className="trade-list-header">
<span>Selling</span>
<span>{t('trade.selling')}</span>
<span style={{ color: '#4caf50' }}>{Math.round(sellTotal)}</span>
</div>
<div className="cart-grid">
{selling.length === 0 && <div style={{ color: '#666', gridColumn: '1 / -1', textAlign: 'center', padding: '10px' }}>Empty</div>}
{selling.length === 0 && <div style={{ color: '#666', gridColumn: '1 / -1', textAlign: 'center', padding: '10px' }}>{t('trade.empty')}</div>}
{selling.map((b, i) => (
<GameTooltip key={i} content={<div>{getTranslatedText(b.name)}<br />x{b.quantity} - Total: {Math.round(b.value * (tradeConfig.sell_markdown || 1) * b.quantity)}</div>}>
<div className={`trade-item-card text-tier-${b.tier || 0}`} onClick={() => {
<GameItemCard
key={i}
item={b}
onClick={() => {
const n = [...selling]; n.splice(i, 1); setSelling(n);
}}>
{b.image_path ? (
<img src={getAssetPath(b.image_path)} alt={getTranslatedText(b.name)} className="trade-item-img" />
) : (
<div style={{ fontSize: '24px' }}>{b.emoji || '📦'}</div>
)}
<div className="trade-item-qty">x{b.quantity}</div>
<div className="trade-item-value">{Math.round(b.value * (tradeConfig.sell_markdown || 1) * b.quantity)}</div>
</div>
</GameTooltip>
}}
showValue={true}
valueDisplayType="total"
tradeMarkup={tradeConfig.sell_markdown || 1}
actionHint={t('trade.clickToRemove')}
/>
))}
</div>
</div>
@@ -407,51 +432,40 @@ export const TradeModal: React.FC<TradeModalProps> = ({ npcId, onClose }) => {
{/* RIGHT: PLAYER INVENTORY */}
<div className="trade-column">
<h3 className="column-header">Inventory {tradeConfig.sell_markdown && <small>(x{tradeConfig.sell_markdown})</small>}</h3>
<input
type="text"
className="search-bar"
placeholder="Filter..."
value={playerSearch}
onChange={(e) => setPlayerSearch(e.target.value)}
/>
<div className="inventory-grid">
{availablePlayerInv.map((item, idx) => {
const tooltipContent = (
<div className="item-tooltip-content">
<div className={`tooltip-header text-tier-${item.tier || 0}`}>
{item.emoji} {getTranslatedText(item.name)}
</div>
{item.description && <div className="tooltip-desc">{getTranslatedText(item.description)}</div>}
<div className="tooltip-stats">
<div style={{ color: '#4caf50' }}>💰 {Math.round(item.value * (tradeConfig.sell_markdown || 1))}</div>
</div>
<div className="stat-badges-container">
{/* Same badges logic could be extracted but duplicating for speed/safety */}
{(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.hp_restore && <span className="stat-badge health"> +{item.hp_restore} HP</span>}
</div>
</div>
);
<h3 className="column-header">{t('trade.inventory')} {tradeConfig.sell_markdown && <small>(x{tradeConfig.sell_markdown})</small>}</h3>
<div className="game-search-container" style={{ marginBottom: '0.5rem' }}>
<span className="game-search-icon">🔍</span>
<input
type="text"
className="search-bar"
placeholder="Filter..."
value={playerSearch}
onChange={(e) => setPlayerSearch(e.target.value)}
/>
</div>
<div className="trade-inventory-grid">
{categories.filter(cat => cat.id !== 'all').map(cat => {
const categoryItems = availablePlayerInv.filter((item: any) => item.item_type === cat.id);
if (categoryItems.length === 0) return null;
return (
<GameTooltip key={idx} content={tooltipContent}>
<div className={`trade-item-card text-tier-${item.tier || 0}`} onClick={() => handleItemClick(item, 'player')}>
<div className="trade-item-image">
{item.image_path ? (
<img src={getAssetPath(item.image_path)} alt={getTranslatedText(item.name)} className="trade-item-img" />
) : (
<div className={`item-icon-large tier-${item.tier || 0}`} style={{ fontSize: '2.5rem' }}>{item.emoji || '📦'}</div>
)}
</div>
{(item as any)._displayQuantity > 1 && <div className="trade-item-qty">x{(item as any)._displayQuantity}</div>}
<div className="trade-item-value">{Math.round(item.value * (tradeConfig.sell_markdown || 1))}</div>
<React.Fragment key={cat.id}>
<div className="category-header" style={{ gridColumn: '1 / -1', marginTop: '10px' }}>
<span className="subcat-icon">{cat.icon}</span>
<span className="subcat-label">{cat.label}</span>
</div>
</GameTooltip>
{categoryItems.map((item, idx) => (
<GameItemCard
key={idx}
item={item}
onClick={() => handleItemClick(item, 'player')}
draggable={true}
onDragStart={(e) => handleDragStart(e, item, 'player')}
showValue={true}
valueDisplayType="unit"
tradeMarkup={tradeConfig.sell_markdown || 1}
/>
))}
</React.Fragment>
);
})}
</div>
@@ -459,46 +473,49 @@ export const TradeModal: React.FC<TradeModalProps> = ({ npcId, onClose }) => {
</div>
<div className="trade-footer">
<div className="trade-summary">
<span>Balance</span>
<span className={`trade-total ${sellTotal >= buyTotal ? 'text-green' : 'text-red'}`}>
{Math.round(sellTotal - buyTotal)}
</span>
</div>
<button className="trade-action-btn" onClick={executeTrade} disabled={!isValid}>
{isValid ? "CONFIRM TRADE" : "INVALID OFFER"}
{isValid ? t('trade.confirmTrade') : t('trade.invalidOffer')}
</button>
<div style={{ width: '60px' }}></div> {/* Spacer */}
</div>
{showQtyModal && selectedItem && (
<div className="dialog-modal-overlay" style={{ zIndex: 1101 }}>
<div className="quantity-modal">
<h4>How many {getTranslatedText(selectedItem.name)}?</h4>
<div className="qty-controls">
<GameButton size="sm" onClick={() => setQtyInput(Math.max(1, qtyInput - 1))}>-</GameButton>
<input
className="qty-input"
type="number"
value={qtyInput}
onChange={e => setQtyInput(parseInt(e.target.value) || 1)}
min="1"
/>
<GameButton size="sm" onClick={() => setQtyInput(qtyInput + 1)}>+</GameButton>
<GameButton size="sm" onClick={() => {
const max = (selectedItem as any)._displayQuantity || 1;
setQtyInput(max);
}}>Max</GameButton>
</div>
<div style={{ display: 'flex', gap: '10px' }}>
<GameButton variant="primary" onClick={confirmSelection}>Confirm</GameButton>
<GameButton variant="secondary" onClick={() => setShowQtyModal(false)}>Cancel</GameButton>
{showQtyModal && selectedItem && (() => {
const maxAvailable = (selectedItem as any)._displayQuantity || 1;
return (
<div className="dialog-modal-overlay" style={{ zIndex: 1101 }}>
<div className="quantity-modal">
<h4>{t('trade.howMany', { item: getTranslatedText(selectedItem.name) })}</h4>
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: '10px' }}>
<GameItemCard item={selectedItem} showTooltip={false} showQuantity={false} />
</div>
<div className="qty-controls">
<GameButton size="sm" onClick={() => setQtyInput(Math.max(1, qtyInput - 1))} disabled={qtyInput <= 1}>-</GameButton>
<input
className="qty-input"
type="number"
value={qtyInput}
onChange={e => {
const val = parseInt(e.target.value);
if (isNaN(val)) {
setQtyInput(1);
} else {
setQtyInput(Math.min(Math.max(1, val), maxAvailable));
}
}}
min="1"
max={maxAvailable}
/>
<GameButton size="sm" onClick={() => setQtyInput(Math.min(maxAvailable, qtyInput + 1))} disabled={qtyInput >= maxAvailable}>+</GameButton>
<GameButton size="sm" onClick={() => setQtyInput(maxAvailable)}>Max</GameButton>
</div>
<div style={{ display: 'flex', gap: '10px' }}>
<GameButton variant="primary" onClick={confirmSelection}>Confirm</GameButton>
<GameButton variant="secondary" onClick={() => setShowQtyModal(false)}>Cancel</GameButton>
</div>
</div>
</div>
</div>
)}
);
})()}
</div>
</GameModal>
);

View File

@@ -13,19 +13,39 @@
backdrop-filter: blur(4px);
}
.workbench-menu {
/* Specific Override for GameModal container when used as Workbench */
.game-modal-container.workbench-modal {
width: 95vw;
max-width: 1400px;
height: 85vh;
height: 90%;
max-height: 90%;
background: var(--game-bg-modal);
border: 1px solid var(--game-border-color);
display: flex;
flex-direction: column;
box-shadow: var(--game-shadow-modal);
overflow: hidden;
color: var(--game-text-primary);
font-family: var(--game-font-main);
clip-path: var(--game-clip-path);
}
.game-modal-container.workbench-modal .game-modal-content {
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.workbench-menu {
/* Legacy class support or internal structure if needed, but GameModal is the container */
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.workbench-menu-content {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.workbench-header {
@@ -46,6 +66,15 @@
gap: 0.5rem;
}
.workbench-header-tabs {
display: flex;
gap: 0.5rem;
padding: 1rem;
background: var(--game-bg-panel);
border-bottom: 1px solid var(--game-border-color);
flex-shrink: 0;
}
.workbench-tabs {
display: flex;
gap: 0.5rem;

View File

@@ -1,8 +1,9 @@
import { useState, useEffect, type MouseEvent, type ChangeEvent } from 'react'
import { useState, useEffect, type ChangeEvent } from 'react'
import { useTranslation } from 'react-i18next'
import type { Profile, WorkbenchTab } from './types'
import { getAssetPath } from '../../utils/assetPath'
import { getTranslatedText } from '../../utils/i18nUtils'
import { GameModal } from './GameModal'
import { GameButton } from '../common/GameButton'
import './Workbench.css'
@@ -476,33 +477,31 @@ function Workbench({
]
return (
<div className="workbench-overlay" onClick={(e: MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) onCloseCrafting()
}}>
<div className="workbench-menu">
<div className="workbench-header">
<h3>{t('game.workbench')}</h3>
<div className="workbench-tabs">
<button
className={`tab-btn ${workbenchTab === 'craft' ? 'active' : ''}`}
onClick={() => onSwitchTab('craft')}
>
{t('game.craft')}
</button>
<button
className={`tab-btn ${workbenchTab === 'repair' ? 'active' : ''}`}
onClick={() => onSwitchTab('repair')}
>
{t('game.repair')}
</button>
<button
className={`tab-btn ${workbenchTab === 'uncraft' ? 'active' : ''}`}
onClick={() => onSwitchTab('uncraft')}
>
{t('game.salvage')}
</button>
</div>
<button className="close-btn" onClick={onCloseCrafting}></button>
<GameModal
title={t('game.workbench')}
onClose={onCloseCrafting}
className={`workbench-modal ${workbenchTab}`} // Add tab class for styling if needed
>
<div className="workbench-menu-content">
<div className="workbench-header-tabs">
<button
className={`tab-btn ${workbenchTab === 'craft' ? 'active' : ''}`}
onClick={() => onSwitchTab('craft')}
>
{t('game.craft')}
</button>
<button
className={`tab-btn ${workbenchTab === 'repair' ? 'active' : ''}`}
onClick={() => onSwitchTab('repair')}
>
{t('game.repair')}
</button>
<button
className={`tab-btn ${workbenchTab === 'uncraft' ? 'active' : ''}`}
onClick={() => onSwitchTab('uncraft')}
>
{t('game.salvage')}
</button>
</div>
<div className="workbench-content-grid">
@@ -678,7 +677,7 @@ function Workbench({
</div>
</div>
</div>
</div>
</GameModal>
)
}

View File

@@ -234,7 +234,7 @@ export function useGameEngine(
const addLocationMessage = useCallback((msg: string) => {
const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
const locationName = location?.name ? (typeof location.name === 'string' ? location.name : location.name.en || Object.values(location.name)[0]) : ''
const locationName = location?.name || ''
setLocationMessages((prev: LocationMessage[]) => {
const newMessages = [...prev, { time: timeStr, message: msg, location_name: locationName }]

View File

@@ -77,7 +77,7 @@ export interface CombatLogEntry {
export interface LocationMessage {
time: string
message: string
location_name?: string
location_name?: string | { [key: string]: string }
}
export interface Equipment {