Update
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>© 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>
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}>
|
||||
← 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}>
|
||||
← {t('game.dialog.back')}
|
||||
</GameButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 >
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)}
|
||||
>
|
||||
« {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')} »
|
||||
</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 >
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user