feat(frontend): UI polishing, Character Sheet redesign, and translation updates
This commit is contained in:
BIN
pwa/public/landing-bg.jpeg
Normal file
BIN
pwa/public/landing-bg.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 874 KiB |
BIN
pwa/public/landing-bg.webp
Normal file
BIN
pwa/public/landing-bg.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 132 KiB |
13
pwa/src/components/AuthenticatedLayout.tsx
Normal file
13
pwa/src/components/AuthenticatedLayout.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import LandingHeader from './LandingHeader'
|
||||
|
||||
export default function AuthenticatedLayout() {
|
||||
return (
|
||||
<div className="authenticated-layout" style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
<LandingHeader />
|
||||
<div className="authenticated-content" style={{ flex: 1, paddingTop: '80px' }}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import PlayerSidebar from './game/PlayerSidebar'
|
||||
|
||||
import { GameProvider } from '../contexts/GameContext'
|
||||
import { QuestJournal } from './game/QuestJournal'
|
||||
import { CharacterSheet } from './game/CharacterSheet'
|
||||
import GameHeader from './GameHeader'
|
||||
import './Game.css'
|
||||
|
||||
@@ -18,6 +19,7 @@ function Game() {
|
||||
|
||||
const [token] = useState(() => localStorage.getItem('token'))
|
||||
const [showQuestJournal, setShowQuestJournal] = useState(false)
|
||||
const [showCharacterSheet, setShowCharacterSheet] = useState(false)
|
||||
|
||||
// Handle WebSocket messages
|
||||
const handleWebSocketMessage = async (message: any) => {
|
||||
@@ -527,6 +529,7 @@ function Game() {
|
||||
}}
|
||||
onSpendPoint={actions.handleSpendPoint}
|
||||
onOpenQuestJournal={() => setShowQuestJournal(true)}
|
||||
onOpenCharacterSheet={() => setShowCharacterSheet(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -595,6 +598,13 @@ function Game() {
|
||||
{showQuestJournal && (
|
||||
<QuestJournal onClose={() => setShowQuestJournal(false)} />
|
||||
)}
|
||||
|
||||
{showCharacterSheet && (
|
||||
<CharacterSheet
|
||||
onClose={() => setShowCharacterSheet(false)}
|
||||
onSpendPoint={actions.handleSpendPoint}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</GameProvider>
|
||||
)
|
||||
|
||||
189
pwa/src/components/LandingHeader.css
Normal file
189
pwa/src/components/LandingHeader.css
Normal file
@@ -0,0 +1,189 @@
|
||||
/* LandingHeader.css */
|
||||
|
||||
.landing-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 2rem;
|
||||
height: 70px;
|
||||
/* Slightly taller for landing */
|
||||
background-color: rgba(5, 5, 8, 0.85);
|
||||
border-bottom: 2px solid rgba(225, 29, 72, 0.3);
|
||||
backdrop-filter: blur(12px);
|
||||
position: fixed;
|
||||
/* Always fixed on landing */
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6);
|
||||
/* Tech grid background pattern */
|
||||
background-image: linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px);
|
||||
background-size: 30px 30px;
|
||||
}
|
||||
|
||||
.landing-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.landing-header-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
padding: 0.5rem 1.5rem;
|
||||
background: linear-gradient(135deg, rgba(225, 29, 72, 0.1) 0%, transparent 100%);
|
||||
border: 1px solid rgba(225, 29, 72, 0.3);
|
||||
/* Angled Cut */
|
||||
clip-path: polygon(0 0, 100% 0, 95% 100%, 0% 100%);
|
||||
border-left: 3px solid #e11d48;
|
||||
}
|
||||
|
||||
.landing-header-title h1 {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
letter-spacing: 1.5px;
|
||||
text-transform: uppercase;
|
||||
font-family: 'Orbitron', sans-serif;
|
||||
text-shadow: 0 0 10px rgba(225, 29, 72, 0.4);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.landing-header-version {
|
||||
font-size: 0.7rem;
|
||||
color: #e11d48;
|
||||
margin-left: 0.6rem;
|
||||
font-family: monospace;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.landing-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
/* Auth Buttons */
|
||||
.landing-nav-btn {
|
||||
height: 40px;
|
||||
padding: 0 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: 'Saira Condensed', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
clip-path: polygon(10px 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%, 0 10px);
|
||||
}
|
||||
|
||||
/* Login Button - Secondary Style */
|
||||
.landing-nav-btn.login {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.landing-nav-btn.login:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: #fff;
|
||||
color: #fff;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 10px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Register Button - Primary Style */
|
||||
.landing-nav-btn.register {
|
||||
background: linear-gradient(135deg, rgba(225, 29, 72, 0.8) 0%, rgba(190, 18, 60, 0.9) 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 15px rgba(225, 29, 72, 0.4);
|
||||
}
|
||||
|
||||
.landing-nav-btn.register:hover {
|
||||
background: linear-gradient(135deg, rgba(244, 63, 94, 0.9) 0%, rgba(225, 29, 72, 1) 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px rgba(225, 29, 72, 0.6);
|
||||
}
|
||||
|
||||
/* Play Button (Authenticated) - Green/Success Style */
|
||||
.landing-nav-btn.play {
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.8) 0%, rgba(5, 150, 105, 0.9) 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 15px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
.landing-nav-btn.play:hover {
|
||||
background: linear-gradient(135deg, rgba(34, 197, 94, 0.9) 0%, rgba(16, 185, 129, 1) 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px rgba(16, 185, 129, 0.6);
|
||||
}
|
||||
|
||||
/* Logout Button */
|
||||
.landing-nav-btn.logout {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.landing-nav-btn.logout:hover {
|
||||
background: rgba(225, 29, 72, 0.1);
|
||||
border-color: rgba(225, 29, 72, 0.4);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Language Selector Overrides in Landing Header */
|
||||
/* We need to ensure specific overrides to match Game Header exactly */
|
||||
.landing-header .language-selector {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.landing-header .language-btn {
|
||||
height: 40px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
/* Exact clip-path from GameHeader (var(--game-clip-path-sm)) which is: */
|
||||
clip-path: polygon(4px 0, 100% 0, 100% calc(100% - 4px), calc(100% - 4px) 100%, 0 100%, 0 4px);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.landing-header .language-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.landing-header .language-dropdown {
|
||||
border-radius: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(10, 10, 15, 0.95);
|
||||
/* Tech clip path */
|
||||
clip-path: polygon(10px 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%, 0 10px);
|
||||
}
|
||||
|
||||
/* Mobile Responsiveness */
|
||||
@media (max-width: 768px) {
|
||||
.landing-header {
|
||||
padding: 0 1rem;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.landing-header-title h1 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.landing-header-version {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.landing-nav-btn {
|
||||
padding: 0 1rem;
|
||||
height: 36px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
68
pwa/src/components/LandingHeader.tsx
Normal file
68
pwa/src/components/LandingHeader.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import LanguageSelector from './LanguageSelector'
|
||||
import './LandingHeader.css'
|
||||
|
||||
export default function LandingHeader() {
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
const { isAuthenticated, logout } = useAuth()
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
// Force a full reload to clear any in-memory state/cache and ensure clean redirection
|
||||
window.location.href = '/'
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="landing-header">
|
||||
<div className="landing-header-left">
|
||||
<div
|
||||
className="landing-header-title"
|
||||
onClick={() => navigate('/')}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<h1>Echoes of the Ash</h1>
|
||||
<span className="landing-header-version">Official</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="landing-header-right">
|
||||
<LanguageSelector />
|
||||
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<button
|
||||
className="landing-nav-btn account"
|
||||
onClick={() => navigate('/account')}
|
||||
>
|
||||
{t('common.account', 'Account')}
|
||||
</button>
|
||||
<button
|
||||
className="landing-nav-btn logout"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
{t('auth.logout', 'Logout')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="landing-nav-btn login"
|
||||
onClick={() => navigate('/login')}
|
||||
>
|
||||
{t('landing.login', 'Login')}
|
||||
</button>
|
||||
<button
|
||||
className="landing-nav-btn register"
|
||||
onClick={() => navigate('/register')}
|
||||
>
|
||||
{t('landing.playNow', 'Register')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
45
pwa/src/components/PrivacyPolicy.tsx
Normal file
45
pwa/src/components/PrivacyPolicy.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { GameButton } from './common/GameButton'
|
||||
import './LandingPage.css'
|
||||
|
||||
export default function PrivacyPolicy() {
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="landing-page" style={{ paddingTop: '80px', paddingBottom: '40px' }}>
|
||||
<div className="about-content" style={{ textAlign: 'left', maxWidth: '800px', margin: '0 auto' }}>
|
||||
<h1 className="section-title">{t('legal.privacy.title')}</h1>
|
||||
|
||||
<div style={{ color: '#cbd5e1', lineHeight: '1.6' }}>
|
||||
<p>{t('legal.privacy.lastUpdated')}</p>
|
||||
|
||||
<h3>{t('legal.privacy.sections.1.title')}</h3>
|
||||
<p>{t('legal.privacy.sections.1.content')}</p>
|
||||
|
||||
<h3>{t('legal.privacy.sections.2.title')}</h3>
|
||||
<p>{t('legal.privacy.sections.2.content')}</p>
|
||||
|
||||
<h3>{t('legal.privacy.sections.3.title')}</h3>
|
||||
<p>{t('legal.privacy.sections.3.content')}</p>
|
||||
|
||||
<h3>{t('legal.privacy.sections.4.title')}</h3>
|
||||
<p>{t('legal.privacy.sections.4.content')}</p>
|
||||
|
||||
<h3>{t('legal.privacy.sections.5.title')}</h3>
|
||||
<p>{t('legal.privacy.sections.5.content')}</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '2rem', textAlign: 'center' }}>
|
||||
<GameButton
|
||||
variant="primary"
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
{t('legal.privacy.back')}
|
||||
</GameButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
14
pwa/src/components/PublicLayout.tsx
Normal file
14
pwa/src/components/PublicLayout.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import LandingHeader from './LandingHeader'
|
||||
import './LandingPage.css' // Reuse landing styles for the wrapper if needed
|
||||
|
||||
export default function PublicLayout() {
|
||||
return (
|
||||
<div className="public-layout">
|
||||
<LandingHeader />
|
||||
<div className="public-content">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
pwa/src/components/TermsOfService.tsx
Normal file
45
pwa/src/components/TermsOfService.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { GameButton } from './common/GameButton'
|
||||
import './LandingPage.css'
|
||||
|
||||
export default function TermsOfService() {
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="landing-page" style={{ paddingTop: '80px', paddingBottom: '40px' }}>
|
||||
<div className="about-content" style={{ textAlign: 'left', maxWidth: '800px', margin: '0 auto' }}>
|
||||
<h1 className="section-title">{t('legal.terms.title')}</h1>
|
||||
|
||||
<div style={{ color: '#cbd5e1', lineHeight: '1.6' }}>
|
||||
<p>{t('legal.terms.lastUpdated')}</p>
|
||||
|
||||
<h3>{t('legal.terms.sections.1.title')}</h3>
|
||||
<p>{t('legal.terms.sections.1.content')}</p>
|
||||
|
||||
<h3>{t('legal.terms.sections.2.title')}</h3>
|
||||
<p>{t('legal.terms.sections.2.content')}</p>
|
||||
|
||||
<h3>{t('legal.terms.sections.3.title')}</h3>
|
||||
<p>{t('legal.terms.sections.3.content')}</p>
|
||||
|
||||
<h3>{t('legal.terms.sections.4.title')}</h3>
|
||||
<p>{t('legal.terms.sections.4.content')}</p>
|
||||
|
||||
<h3>{t('legal.terms.sections.5.title')}</h3>
|
||||
<p>{t('legal.terms.sections.5.content')}</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '2rem', textAlign: 'center' }}>
|
||||
<GameButton
|
||||
variant="primary"
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
{t('legal.terms.back')}
|
||||
</GameButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
154
pwa/src/components/common/GameItemCard.tsx
Normal file
154
pwa/src/components/common/GameItemCard.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import React from 'react';
|
||||
import { GameTooltip } from './GameTooltip';
|
||||
import { ItemTooltipContent } from './ItemTooltipContent';
|
||||
import { getAssetPath } from '../../utils/assetPath';
|
||||
import { getTranslatedText } from '../../utils/i18nUtils';
|
||||
import { GameProgressBar } from './GameProgressBar';
|
||||
|
||||
export interface GameItemCardProps {
|
||||
item: any;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
|
||||
// Display Flags
|
||||
showTooltip?: boolean;
|
||||
showQuantity?: boolean;
|
||||
showEquipped?: boolean;
|
||||
showValue?: boolean;
|
||||
valueDisplayType?: 'unit' | 'total';
|
||||
showDurability?: boolean;
|
||||
actionHint?: string;
|
||||
|
||||
// Trade Data
|
||||
tradeMarkup?: number;
|
||||
|
||||
// Drag & Drop
|
||||
draggable?: boolean;
|
||||
onDragStart?: (e: React.DragEvent) => void;
|
||||
|
||||
// Styling Overrides
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export const GameItemCard: React.FC<GameItemCardProps> = ({
|
||||
item,
|
||||
onClick,
|
||||
showTooltip = true,
|
||||
showQuantity = true,
|
||||
showEquipped = false,
|
||||
showValue = false,
|
||||
valueDisplayType = 'total',
|
||||
showDurability = true,
|
||||
tradeMarkup = 1,
|
||||
draggable = false,
|
||||
onDragStart,
|
||||
className = '',
|
||||
style = {},
|
||||
isActive = false,
|
||||
actionHint
|
||||
}) => {
|
||||
if (!item) return null;
|
||||
|
||||
// Resolve tooltip content
|
||||
const tooltipContent = showTooltip ? (
|
||||
<ItemTooltipContent
|
||||
item={item}
|
||||
showValue={showValue}
|
||||
valueDisplayType={valueDisplayType}
|
||||
tradeMarkup={tradeMarkup}
|
||||
actionHint={actionHint}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
// Use a unified class name 'game-item-card'
|
||||
const cardClasses = [
|
||||
'game-item-card',
|
||||
`text-tier-${item.tier || 0}`,
|
||||
isActive ? 'active' : '',
|
||||
showEquipped && item.is_equipped ? 'equipped' : '',
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
const preventDragHandler = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const cardContent = (
|
||||
<div
|
||||
className={cardClasses}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
draggable={draggable}
|
||||
onDragStart={onDragStart}
|
||||
>
|
||||
<div className="game-item-image-wrapper">
|
||||
{item.image_path ? (
|
||||
<img
|
||||
src={getAssetPath(item.image_path)}
|
||||
alt={getTranslatedText(item.name)}
|
||||
className="game-item-img"
|
||||
onDragStart={preventDragHandler}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden');
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={`game-item-emoji tier-${item.tier || 0} ${item.image_path ? 'hidden' : ''}`}
|
||||
style={{ fontSize: '2.5rem' }}
|
||||
>
|
||||
{item.emoji || '📦'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Equipped Badge */}
|
||||
{showEquipped && item.is_equipped && (
|
||||
<div className="item-equipped-indicator">E</div>
|
||||
)}
|
||||
|
||||
{/* Quantity Badge */}
|
||||
{showQuantity && (item.is_infinite || (item._displayQuantity || item.quantity) > 1) && (
|
||||
<div className="item-quantity-badge">
|
||||
{item.is_infinite ? '∞' : `x${item._displayQuantity || item.quantity}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Value Overlay (usually for Trade) */}
|
||||
{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="game-item-value-badge">
|
||||
{Math.round(item.value * tradeMarkup * multiplier)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Durability Bar */}
|
||||
{showDurability && item.max_durability && item.max_durability > 0 && (
|
||||
<div className="game-item-durability-wrapper" style={{ width: '85%', position: 'absolute', bottom: '4px', left: '50%', transform: 'translateX(-50%)' }}>
|
||||
<GameProgressBar
|
||||
value={item.durability}
|
||||
max={item.max_durability}
|
||||
type="durability"
|
||||
height="4px"
|
||||
showText={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// If tooltip is enabled, wrap it. Otherwise return bare card.
|
||||
if (showTooltip && tooltipContent) {
|
||||
return (
|
||||
<GameTooltip content={tooltipContent}>
|
||||
{cardContent}
|
||||
</GameTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return cardContent;
|
||||
};
|
||||
@@ -11,6 +11,7 @@ interface GameProgressBarProps {
|
||||
height?: string;
|
||||
align?: 'left' | 'right';
|
||||
labelAlignment?: 'left' | 'right';
|
||||
customColor?: string;
|
||||
}
|
||||
|
||||
export const GameProgressBar: React.FC<GameProgressBarProps> = ({
|
||||
@@ -22,7 +23,8 @@ export const GameProgressBar: React.FC<GameProgressBarProps> = ({
|
||||
unit = '',
|
||||
height = '8px',
|
||||
align = 'left',
|
||||
labelAlignment
|
||||
labelAlignment,
|
||||
customColor
|
||||
}) => {
|
||||
const percentage = Math.min(100, Math.max(0, (value / (max || 1)) * 100));
|
||||
|
||||
@@ -42,6 +44,7 @@ export const GameProgressBar: React.FC<GameProgressBarProps> = ({
|
||||
|
||||
// Custom coloring for health/stamina if not using classes matching InventoryModal exactly
|
||||
const getGradient = () => {
|
||||
if (customColor) return customColor;
|
||||
switch (type) {
|
||||
// InventoryModal.css defines .weight and .volume gradients
|
||||
// We can rely on classes if we import the CSS in parent or here
|
||||
|
||||
120
pwa/src/components/common/ItemStatBadges.tsx
Normal file
120
pwa/src/components/common/ItemStatBadges.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { EffectBadge } from '../game/EffectBadge';
|
||||
|
||||
interface ItemStatBadgesProps {
|
||||
item: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable component that renders all stat badges for an item.
|
||||
* Used in tooltips, inventory cards, combat inventory, etc.
|
||||
*/
|
||||
export const ItemStatBadges = ({ item }: ItemStatBadgesProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const stats = item.unique_stats || item.stats || {};
|
||||
const effects = item.effects || {};
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
21
pwa/src/components/common/LanguageSelector.css
Normal file
21
pwa/src/components/common/LanguageSelector.css
Normal file
@@ -0,0 +1,21 @@
|
||||
.language-selector {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.lang-btn {
|
||||
min-width: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.lang-icon {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.lang-text {
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
27
pwa/src/components/common/LanguageSelector.tsx
Normal file
27
pwa/src/components/common/LanguageSelector.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { GameButton } from './GameButton'
|
||||
import './LanguageSelector.css'
|
||||
|
||||
export function LanguageSelector() {
|
||||
const { i18n } = useTranslation()
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const newLang = i18n.language.startsWith('es') ? 'en' : 'es'
|
||||
i18n.changeLanguage(newLang)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="language-selector">
|
||||
<GameButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={toggleLanguage}
|
||||
className="lang-btn"
|
||||
title={i18n.language.startsWith('es') ? "Switch to English" : "Cambiar a Español"}
|
||||
>
|
||||
<span className="lang-icon">🌐</span>
|
||||
<span className="lang-text">{i18n.language.startsWith('es') ? 'ES' : 'EN'}</span>
|
||||
</GameButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
89
pwa/src/components/common/Notification.css
Normal file
89
pwa/src/components/common/Notification.css
Normal file
@@ -0,0 +1,89 @@
|
||||
.notification-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
pointer-events: none;
|
||||
/* Allow clicking through container */
|
||||
}
|
||||
|
||||
.notification-toast {
|
||||
background-color: rgba(0, 0, 0, 0.85);
|
||||
color: #fff;
|
||||
padding: 12px 20px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
min-width: 250px;
|
||||
max-width: 400px;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
border-left: 4px solid #fff;
|
||||
pointer-events: auto;
|
||||
/* Allow clicking toast to dismiss */
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Types */
|
||||
/* Types */
|
||||
.notification-toast.success {
|
||||
border-left-color: #4caf50;
|
||||
background: rgba(20, 30, 20, 0.95);
|
||||
}
|
||||
|
||||
.notification-toast.error {
|
||||
border-left-color: #f44336;
|
||||
background: rgba(40, 20, 20, 0.95);
|
||||
}
|
||||
|
||||
.notification-toast.warning {
|
||||
border-left-color: #ff9800;
|
||||
background: rgba(40, 30, 20, 0.95);
|
||||
}
|
||||
|
||||
.notification-toast.info {
|
||||
border-left-color: #2196f3;
|
||||
background: rgba(20, 30, 40, 0.95);
|
||||
}
|
||||
|
||||
.notification-toast.quest {
|
||||
border-left-color: #ffd700;
|
||||
/* Gold */
|
||||
background: rgba(40, 35, 10, 0.95);
|
||||
box-shadow: 0 0 10px rgba(255, 215, 0, 0.3);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-toast.exiting {
|
||||
animation: slideOut 0.4s ease-in forwards;
|
||||
}
|
||||
|
||||
@keyframes slideOut {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
23
pwa/src/components/common/NotificationContainer.tsx
Normal file
23
pwa/src/components/common/NotificationContainer.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
import './Notification.css';
|
||||
|
||||
export const NotificationContainer: React.FC = () => {
|
||||
const { notifications, removeNotification } = useNotification();
|
||||
|
||||
if (notifications.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="notification-container">
|
||||
{notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={`notification-toast ${notification.type} ${notification.isExiting ? 'exiting' : ''}`}
|
||||
onClick={() => removeNotification(notification.id)}
|
||||
>
|
||||
{notification.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
554
pwa/src/components/game/CharacterSheet.css
Normal file
554
pwa/src/components/game/CharacterSheet.css
Normal file
@@ -0,0 +1,554 @@
|
||||
/* ═══════════════════════════════════════════════
|
||||
CHARACTER SHEET MODAL
|
||||
Follows VISUALS_GUIDE: dark post-apocalyptic,
|
||||
chamfered corners, glassmorphism, condensed font
|
||||
═══════════════════════════════════════════════ */
|
||||
|
||||
.game-modal-container.character-sheet-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 95vw;
|
||||
max-width: 1400px;
|
||||
height: 90%;
|
||||
max-height: 90%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.character-sheet-modal .game-modal-content {
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ─── Loading ─── */
|
||||
.cs-loading {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #a0a0a0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* ─── Header Vitals ─── */
|
||||
.cs-header-content {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cs-header-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cs-vitals.cs-header-vitals {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 1rem;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.cs-vital-bar {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
/* ─── Tabs ─── */
|
||||
.cs-tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 10px 20px;
|
||||
background: var(--game-bg-panel);
|
||||
border-bottom: 1px solid var(--game-border-color);
|
||||
}
|
||||
|
||||
.cs-tab {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: #a0aec0;
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
clip-path: var(--game-clip-path);
|
||||
text-align: center;
|
||||
font-family: 'Inter', 'Segoe UI', sans-serif;
|
||||
font-size: 0.95rem;
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.cs-tab:hover {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.cs-tab.active {
|
||||
background: #3182ce;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.cs-tab-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 10px;
|
||||
background: #c0392b;
|
||||
color: white;
|
||||
font-size: 0.65rem;
|
||||
padding: 1px 5px;
|
||||
clip-path: var(--game-clip-path-sm);
|
||||
font-weight: 700;
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ─── Tab Content ─── */
|
||||
.cs-tab-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* ─── Sections ─── */
|
||||
.cs-stats-tab {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.cs-stats-base-col {
|
||||
flex: 0 0 35%;
|
||||
}
|
||||
|
||||
.cs-stats-derived-col {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cs-section {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.cs-section-title {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #8a8a9a;
|
||||
margin: 0 0 0.6rem 0;
|
||||
padding-bottom: 0.3rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
/* ─── Base Stats Grid ─── */
|
||||
.cs-unspent-badge {
|
||||
background: linear-gradient(135deg, rgba(241, 196, 15, 0.15), rgba(241, 196, 15, 0.05));
|
||||
border: 1px solid rgba(241, 196, 15, 0.3);
|
||||
color: #f1c40f;
|
||||
padding: 0.35rem 0.75rem;
|
||||
clip-path: var(--game-clip-path);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.6rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cs-base-stats-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.cs-stat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(30, 35, 45, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
clip-path: var(--game-clip-path);
|
||||
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.2);
|
||||
transition: background 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.cs-stat-row:hover {
|
||||
background: rgba(40, 45, 55, 0.8);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.cs-stat-icon {
|
||||
font-size: 1.25rem;
|
||||
width: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cs-stat-name {
|
||||
min-width: 80px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: #c0c0d0;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.cs-stat-bar-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.cs-stat-bar {
|
||||
flex: 1;
|
||||
height: 10px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
clip-path: var(--game-clip-path);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cs-stat-bar-fill {
|
||||
height: 100%;
|
||||
clip-path: var(--game-clip-path);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.cs-stat-val {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
min-width: 3.5rem;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.cs-plus-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 1px solid rgba(241, 196, 15, 0.5);
|
||||
background: rgba(241, 196, 15, 0.1);
|
||||
color: #f1c40f;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
clip-path: var(--game-clip-path);
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
transition: all 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cs-plus-btn:hover {
|
||||
background: rgba(241, 196, 15, 0.25);
|
||||
border-color: #f1c40f;
|
||||
}
|
||||
|
||||
/* ─── Derived Stats Grid ─── */
|
||||
.cs-derived-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.cs-derived-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 0.85rem;
|
||||
background: rgba(20, 25, 35, 0.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.03);
|
||||
clip-path: var(--game-clip-path);
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.cs-derived-row:hover {
|
||||
background: rgba(35, 40, 50, 0.6);
|
||||
}
|
||||
|
||||
.cs-derived-icon {
|
||||
font-size: 1rem;
|
||||
width: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cs-derived-label {
|
||||
flex: 1;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.cs-derived-value {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: #e2e8f0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ─── Skills Tab ─── */
|
||||
.cs-skills-tab {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.cs-skill-group-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.4rem 0;
|
||||
}
|
||||
|
||||
.cs-skill-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.cs-skill-card {
|
||||
padding: 0.5rem 0.65rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
clip-path: var(--game-clip-path);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.cs-skill-card.locked {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.cs-skill-card.unlocked {
|
||||
border-color: rgba(46, 204, 113, 0.2);
|
||||
background: rgba(46, 204, 113, 0.03);
|
||||
}
|
||||
|
||||
.cs-skill-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.cs-skill-icon {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.cs-skill-name {
|
||||
flex: 1;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: #d0d0e0;
|
||||
}
|
||||
|
||||
.cs-skill-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 1px 6px;
|
||||
clip-path: var(--game-clip-path-sm);
|
||||
}
|
||||
|
||||
.cs-skill-badge.unlocked {
|
||||
background: rgba(46, 204, 113, 0.15);
|
||||
color: #2ecc71;
|
||||
}
|
||||
|
||||
.cs-skill-badge.locked {
|
||||
color: #8a8a9a;
|
||||
}
|
||||
|
||||
.cs-skill-desc {
|
||||
font-size: 0.72rem;
|
||||
color: #8a8a9a;
|
||||
margin: 0.2rem 0;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.cs-skill-meta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.cs-skill-tag {
|
||||
font-size: 0.65rem;
|
||||
padding: 1px 5px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #a0a0b0;
|
||||
clip-path: var(--game-clip-path-sm);
|
||||
}
|
||||
|
||||
.cs-skill-tag.req {
|
||||
color: #e07a5f;
|
||||
}
|
||||
|
||||
/* ─── Perks Tab ─── */
|
||||
.cs-perks-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.cs-perk-points {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(241, 196, 15, 0.05);
|
||||
border: 1px solid rgba(241, 196, 15, 0.15);
|
||||
clip-path: var(--game-clip-path);
|
||||
}
|
||||
|
||||
.cs-perk-points-label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: #c0c0d0;
|
||||
}
|
||||
|
||||
.cs-perk-points-value {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: #f1c40f;
|
||||
}
|
||||
|
||||
.cs-perk-points-hint {
|
||||
font-size: 0.7rem;
|
||||
color: #8a8a9a;
|
||||
}
|
||||
|
||||
.cs-perk-list {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.cs-perk-card {
|
||||
padding: 0.5rem 0.65rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
clip-path: var(--game-clip-path);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.cs-perk-card.owned {
|
||||
border-color: rgba(46, 204, 113, 0.25);
|
||||
background: rgba(46, 204, 113, 0.04);
|
||||
}
|
||||
|
||||
.cs-perk-card.locked {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.cs-perk-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.cs-perk-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.cs-perk-title-block {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cs-perk-name {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: #d0d0e0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cs-perk-desc {
|
||||
font-size: 0.7rem;
|
||||
color: #8a8a9a;
|
||||
margin: 0.1rem 0 0 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.cs-perk-status {
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 8px;
|
||||
clip-path: var(--game-clip-path-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cs-perk-status.owned {
|
||||
background: rgba(46, 204, 113, 0.15);
|
||||
color: #2ecc71;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cs-perk-status.locked {
|
||||
color: #8a8a9a;
|
||||
}
|
||||
|
||||
.cs-perk-reqs {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.3rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cs-perk-req {
|
||||
font-size: 0.65rem;
|
||||
padding: 1px 5px;
|
||||
clip-path: var(--game-clip-path-sm);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.cs-perk-req.met {
|
||||
background: rgba(46, 204, 113, 0.1);
|
||||
color: #2ecc71;
|
||||
}
|
||||
|
||||
.cs-perk-req.unmet {
|
||||
background: rgba(231, 76, 60, 0.1);
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
/* ─── Mobile Responsive ─── */
|
||||
@media (max-width: 900px) {
|
||||
.cs-stats-tab {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cs-skills-tab {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cs-perk-list {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.cs-derived-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.cs-derived-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cs-perk-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cs-vitals {
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.game-modal-container.character-sheet-modal {
|
||||
max-width: 100vw;
|
||||
max-height: 90vh;
|
||||
height: 90vh;
|
||||
margin: 0.5rem;
|
||||
}
|
||||
}
|
||||
412
pwa/src/components/game/CharacterSheet.tsx
Normal file
412
pwa/src/components/game/CharacterSheet.tsx
Normal file
@@ -0,0 +1,412 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getTranslatedText } from '../../utils/i18nUtils';
|
||||
import api from '../../services/api';
|
||||
import { GameModal } from './GameModal';
|
||||
import { GameProgressBar } from '../common/GameProgressBar';
|
||||
import { GameButton } from '../common/GameButton';
|
||||
import './CharacterSheet.css';
|
||||
|
||||
interface CharacterSheetProps {
|
||||
onClose: () => void;
|
||||
onSpendPoint: (stat: string) => void;
|
||||
}
|
||||
|
||||
interface DerivedStats {
|
||||
attack_power: number;
|
||||
crit_chance: number;
|
||||
crit_damage: number;
|
||||
dodge_chance: number;
|
||||
flee_chance_base: number;
|
||||
max_hp: number;
|
||||
max_stamina: number;
|
||||
total_armor: number;
|
||||
armor_reduction: number;
|
||||
block_chance: number;
|
||||
status_resistance: number;
|
||||
item_effectiveness: number;
|
||||
xp_bonus: number;
|
||||
loot_quality: number;
|
||||
crafting_bonus: number;
|
||||
carry_weight: number;
|
||||
weapon_damage_min: number;
|
||||
weapon_damage_max: number;
|
||||
has_shield: boolean;
|
||||
}
|
||||
|
||||
interface SkillData {
|
||||
id: string;
|
||||
name: any;
|
||||
description: any;
|
||||
icon: string;
|
||||
stat_requirement: string;
|
||||
stat_threshold: number;
|
||||
level_requirement: number;
|
||||
cooldown: number;
|
||||
stamina_cost: number;
|
||||
unlocked: boolean;
|
||||
}
|
||||
|
||||
interface PerkData {
|
||||
id: string;
|
||||
name: any;
|
||||
description: any;
|
||||
icon: string;
|
||||
requirements: Record<string, number>;
|
||||
effects: Record<string, any>;
|
||||
meets_requirements: boolean;
|
||||
owned: boolean;
|
||||
}
|
||||
|
||||
interface CharacterSheetData {
|
||||
base_stats: {
|
||||
strength: number;
|
||||
agility: number;
|
||||
endurance: number;
|
||||
intellect: number;
|
||||
unspent_points: number;
|
||||
stat_cap: number;
|
||||
};
|
||||
derived_stats: DerivedStats;
|
||||
skills: SkillData[];
|
||||
perks: {
|
||||
available_points: number;
|
||||
total_points: number;
|
||||
used_points: number;
|
||||
all_perks: PerkData[];
|
||||
};
|
||||
character: {
|
||||
name: string;
|
||||
level: number;
|
||||
xp: number;
|
||||
hp: number;
|
||||
max_hp: number;
|
||||
stamina: number;
|
||||
max_stamina: number;
|
||||
avatar_data?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const STAT_ICONS: Record<string, string> = {
|
||||
strength: '💪',
|
||||
agility: '🏃',
|
||||
endurance: '🫀',
|
||||
intellect: '🧠',
|
||||
};
|
||||
|
||||
const STAT_COLORS: Record<string, string> = {
|
||||
strength: '#e74c3c',
|
||||
agility: '#2ecc71',
|
||||
endurance: '#f39c12',
|
||||
intellect: '#3498db',
|
||||
};
|
||||
|
||||
export function CharacterSheet({ onClose, onSpendPoint }: CharacterSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const [data, setData] = useState<CharacterSheetData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<'stats' | 'skills' | 'perks'>('stats');
|
||||
const [selectingPerk, setSelectingPerk] = useState(false);
|
||||
|
||||
const fetchSheet = async () => {
|
||||
try {
|
||||
const res = await api.get('/api/game/character-sheet');
|
||||
setData(res.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch character sheet:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchSheet();
|
||||
}, []);
|
||||
|
||||
const handleSpendPoint = async (stat: string) => {
|
||||
onSpendPoint(stat);
|
||||
// Refetch after a short delay to get updated derived stats
|
||||
setTimeout(fetchSheet, 500);
|
||||
};
|
||||
|
||||
const handleSelectPerk = async (perkId: string) => {
|
||||
setSelectingPerk(true);
|
||||
try {
|
||||
await api.post(`/api/game/select_perk?perk_id=${perkId}`);
|
||||
await fetchSheet();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to select perk:', err.response?.data?.detail || err.message);
|
||||
} finally {
|
||||
setSelectingPerk(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<GameModal title={t('characterSheet.title', 'Character Sheet')} onClose={onClose} className="character-sheet-modal">
|
||||
<div className="cs-loading"><span>⌛</span> {t('common.loading', 'Loading...')}</div>
|
||||
</GameModal>
|
||||
);
|
||||
}
|
||||
|
||||
const { base_stats, derived_stats, skills, perks, character } = data;
|
||||
|
||||
const renderStatsTab = () => (
|
||||
<div className="cs-stats-tab">
|
||||
{/* Base Stats Section */}
|
||||
<div className="cs-stats-base-col">
|
||||
<div className="cs-section">
|
||||
<h4 className="cs-section-title">{t('characterSheet.baseStats', 'Base Stats')}</h4>
|
||||
|
||||
{/* Vitals Moved Here */}
|
||||
<div className="cs-vitals" style={{ marginBottom: '1.5rem', display: 'flex', flexDirection: 'column', gap: '0.8rem' }}>
|
||||
<GameProgressBar
|
||||
value={character.hp}
|
||||
max={character.max_hp}
|
||||
type="health"
|
||||
showText={true}
|
||||
height="12px"
|
||||
label={t('stats.hpMax')}
|
||||
/>
|
||||
<GameProgressBar
|
||||
value={character.stamina}
|
||||
max={character.max_stamina}
|
||||
type="stamina"
|
||||
showText={true}
|
||||
height="12px"
|
||||
label={t('stats.stmMax')}
|
||||
/>
|
||||
<GameProgressBar
|
||||
value={character.xp}
|
||||
max={character.level * 100}
|
||||
type="xp"
|
||||
showText={true}
|
||||
height="12px"
|
||||
label={t('stats.xp')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{base_stats.unspent_points > 0 && (
|
||||
<div className="cs-unspent-badge">
|
||||
<span>✨</span> {base_stats.unspent_points} {t('characterSheet.pointsAvailable', 'points available')}
|
||||
</div>
|
||||
)}
|
||||
<div className="cs-base-stats-grid">
|
||||
{(['strength', 'agility', 'endurance', 'intellect'] as const).map(stat => (
|
||||
<div key={stat} className="cs-stat-row">
|
||||
<span className="cs-stat-icon"><span>{STAT_ICONS[stat]}</span></span>
|
||||
<span className="cs-stat-name">{t(`stats.${stat}Full`)}</span>
|
||||
<div className="cs-stat-bar-wrap">
|
||||
<GameProgressBar
|
||||
value={base_stats[stat]}
|
||||
max={base_stats.stat_cap}
|
||||
type="durability"
|
||||
customColor={STAT_COLORS[stat]}
|
||||
showText={true}
|
||||
height="10px"
|
||||
/>
|
||||
</div>
|
||||
{base_stats.unspent_points > 0 && base_stats[stat] < base_stats.stat_cap && (
|
||||
<button className="cs-plus-btn" onClick={() => handleSpendPoint(stat)}>+</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Derived Stats Section */}
|
||||
<div className="cs-stats-derived-col">
|
||||
<div className="cs-section">
|
||||
<h4 className="cs-section-title">{t('characterSheet.derivedStats', 'Derived Stats')}</h4>
|
||||
<div className="cs-derived-grid">
|
||||
<DerivedStatRow icon="⚔️" label={t('characterSheet.attackPower', 'Attack Power')} value={derived_stats.attack_power} />
|
||||
<DerivedStatRow icon="🎯" label={t('characterSheet.critChance', 'Crit Chance')} value={`${(derived_stats.crit_chance * 100).toFixed(1)}%`} />
|
||||
<DerivedStatRow icon="💥" label={t('characterSheet.critDamage', 'Crit Damage')} value={`${derived_stats.crit_damage}x`} />
|
||||
<DerivedStatRow icon="🏃" label={t('characterSheet.dodgeChance', 'Dodge Chance')} value={`${(derived_stats.dodge_chance * 100).toFixed(1)}%`} />
|
||||
<DerivedStatRow icon="💨" label={t('characterSheet.fleeChance', 'Flee Chance')} value={`${(derived_stats.flee_chance_base * 100).toFixed(0)}%`} />
|
||||
<DerivedStatRow icon="❤️" label={t('characterSheet.maxHp', 'Max HP')} value={derived_stats.max_hp} />
|
||||
<DerivedStatRow icon="⚡" label={t('characterSheet.maxStamina', 'Max Stamina')} value={derived_stats.max_stamina} />
|
||||
<DerivedStatRow icon="🛡️" label={t('characterSheet.armor', 'Armor')} value={`${derived_stats.total_armor} (${(derived_stats.armor_reduction * 100).toFixed(1)}%)`} />
|
||||
<DerivedStatRow icon="🧱" label={t('characterSheet.blockChance', 'Block Chance')} value={`${(derived_stats.block_chance * 100).toFixed(1)}%`} />
|
||||
<DerivedStatRow icon="🧬" label={t('characterSheet.statusResist', 'Status Resist')} value={`${(derived_stats.status_resistance * 100).toFixed(0)}%`} />
|
||||
<DerivedStatRow icon="💊" label={t('characterSheet.itemEffect', 'Item Effectiveness')} value={`${(derived_stats.item_effectiveness * 100).toFixed(0)}%`} />
|
||||
<DerivedStatRow icon="📈" label={t('characterSheet.xpBonus', 'XP Bonus')} value={`${(derived_stats.xp_bonus * 100).toFixed(0)}%`} />
|
||||
<DerivedStatRow icon="🎲" label={t('characterSheet.lootQuality', 'Loot Quality')} value={`${(derived_stats.loot_quality * 100).toFixed(1)}%`} />
|
||||
<DerivedStatRow icon="🔨" label={t('characterSheet.craftBonus', 'Craft Bonus')} value={`${(derived_stats.crafting_bonus * 100).toFixed(0)}%`} />
|
||||
<DerivedStatRow icon="🎒" label={t('characterSheet.carryWeight', 'Carry Weight')} value={`${derived_stats.carry_weight} kg`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSkillsTab = () => {
|
||||
const grouped: Record<string, SkillData[]> = {
|
||||
strength: [],
|
||||
agility: [],
|
||||
endurance: [],
|
||||
intellect: [],
|
||||
};
|
||||
skills.forEach(s => {
|
||||
if (grouped[s.stat_requirement]) {
|
||||
grouped[s.stat_requirement].push(s);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="cs-skills-tab">
|
||||
{Object.entries(grouped).map(([stat, statSkills]) => (
|
||||
<div key={stat} className="cs-skill-group">
|
||||
<h4 className="cs-skill-group-title" style={{ color: STAT_COLORS[stat] }}>
|
||||
<span>{STAT_ICONS[stat]}</span> {t(`stats.${stat}Full`)}
|
||||
</h4>
|
||||
<div className="cs-skill-list">
|
||||
{statSkills.map(skill => (
|
||||
<div key={skill.id} className={`cs-skill-card ${skill.unlocked ? 'unlocked' : 'locked'}`}>
|
||||
<div className="cs-skill-header">
|
||||
<span className="cs-skill-icon">{skill.icon}</span>
|
||||
<span className="cs-skill-name">{getTranslatedText(skill.name)}</span>
|
||||
{skill.unlocked ? (
|
||||
<span className="cs-skill-badge unlocked"><span>✓</span></span>
|
||||
) : (
|
||||
<span className="cs-skill-badge locked"><span>🔒</span></span>
|
||||
)}
|
||||
</div>
|
||||
<p className="cs-skill-desc">{getTranslatedText(skill.description)}</p>
|
||||
<div className="cs-skill-meta">
|
||||
<span className="cs-skill-tag"><span>⚡</span> {skill.stamina_cost}</span>
|
||||
<span className="cs-skill-tag"><span>🔄</span> {skill.cooldown}t</span>
|
||||
<span className="cs-skill-tag req">
|
||||
{t(`stats.${skill.stat_requirement}`)}: {skill.stat_threshold}
|
||||
</span>
|
||||
<span className="cs-skill-tag req">
|
||||
{t('stats.level')}: {skill.level_requirement}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPerksTab = () => (
|
||||
<div className="cs-perks-tab">
|
||||
<div className="cs-perk-points">
|
||||
<span className="cs-perk-points-label"><span>⭐</span> {t('characterSheet.perkPoints', 'Perk Points')}:</span>
|
||||
<span className="cs-perk-points-value">
|
||||
{perks.available_points} / {perks.total_points}
|
||||
</span>
|
||||
<span className="cs-perk-points-hint">
|
||||
({t('characterSheet.nextPerkAt', 'Next at Lv')} {((perks.used_points + perks.available_points + 1) * 5)})
|
||||
</span>
|
||||
</div>
|
||||
<div className="cs-perk-list">
|
||||
{perks.all_perks.map(perk => {
|
||||
const canSelect = perk.meets_requirements && !perk.owned && perks.available_points > 0;
|
||||
return (
|
||||
<div key={perk.id} className={`cs-perk-card ${perk.owned ? 'owned' : ''} ${!perk.meets_requirements ? 'locked' : ''}`}>
|
||||
<div className="cs-perk-header">
|
||||
<span className="cs-perk-icon">{perk.icon}</span>
|
||||
<div className="cs-perk-title-block">
|
||||
<span className="cs-perk-name">{getTranslatedText(perk.name)}</span>
|
||||
<p className="cs-perk-desc">{getTranslatedText(perk.description)}</p>
|
||||
</div>
|
||||
{perk.owned ? (
|
||||
<span className="cs-perk-status owned"><span>✓</span> {t('characterSheet.owned', 'Owned')}</span>
|
||||
) : canSelect ? (
|
||||
<GameButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => handleSelectPerk(perk.id)}
|
||||
disabled={selectingPerk}
|
||||
>
|
||||
{t('characterSheet.select', 'Select')}
|
||||
</GameButton>
|
||||
) : (
|
||||
<span className="cs-perk-status locked"><span>🔒</span></span>
|
||||
)}
|
||||
</div>
|
||||
<div className="cs-perk-reqs">
|
||||
{Object.entries(perk.requirements).map(([key, val]) => {
|
||||
const isMax = key.endsWith('_max');
|
||||
const baseKey = isMax ? key.replace('_max', '') : key;
|
||||
const displayKey = ['strength', 'agility', 'endurance', 'intellect', 'level'].includes(baseKey)
|
||||
? t(`stats.${baseKey}`)
|
||||
: baseKey;
|
||||
|
||||
const currentVal = baseKey === 'level' ? character.level : ((data.base_stats as any)[baseKey] || 0);
|
||||
const isMet = isMax ? currentVal <= val : currentVal >= val;
|
||||
|
||||
return (
|
||||
<span key={key} className={`cs-perk-req ${isMet ? 'met' : 'unmet'}`}>
|
||||
{displayKey} {isMax ? '≤' : '≥'} {val}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const modalTitle = (
|
||||
<div className="cs-header-content">
|
||||
<span className="cs-header-title">{character.name} — Lv. {character.level}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<GameModal
|
||||
title={modalTitle}
|
||||
onClose={onClose}
|
||||
className="character-sheet-modal"
|
||||
>
|
||||
{/* Tab Navigation */}
|
||||
<div className="cs-tabs">
|
||||
<button
|
||||
className={`cs-tab ${activeTab === 'stats' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('stats')}
|
||||
>
|
||||
<span>📊</span> {t('characterSheet.statsTab', 'Stats')}
|
||||
{base_stats.unspent_points > 0 && <span className="cs-tab-badge">{base_stats.unspent_points}</span>}
|
||||
</button>
|
||||
<button
|
||||
className={`cs-tab ${activeTab === 'skills' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('skills')}
|
||||
>
|
||||
<span>⚔️</span> {t('characterSheet.skillsTab', 'Skills')}
|
||||
</button>
|
||||
<button
|
||||
className={`cs-tab ${activeTab === 'perks' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('perks')}
|
||||
>
|
||||
<span>⭐</span> {t('characterSheet.perksTab', 'Perks')}
|
||||
{perks.available_points > 0 && <span className="cs-tab-badge">{perks.available_points}</span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="cs-tab-content">
|
||||
{activeTab === 'stats' && renderStatsTab()}
|
||||
{activeTab === 'skills' && renderSkillsTab()}
|
||||
{activeTab === 'perks' && renderPerksTab()}
|
||||
</div>
|
||||
</GameModal>
|
||||
);
|
||||
}
|
||||
|
||||
function DerivedStatRow({ icon, label, value }: { icon: string; label: string; value: any }) {
|
||||
return (
|
||||
<div className="cs-derived-row">
|
||||
<span className="cs-derived-icon"><span>{icon}</span></span>
|
||||
<span className="cs-derived-label">{label}</span>
|
||||
<span className="cs-derived-value">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -495,6 +495,46 @@ export const Combat: React.FC<CombatProps> = ({
|
||||
case 'quest_update':
|
||||
addNotification(data.message || 'Quest Progress', 'quest');
|
||||
break;
|
||||
|
||||
// ── Skill messages ──
|
||||
case 'skill_attack':
|
||||
triggerAnim('playerAttacking');
|
||||
triggerAnim('npcHit', 300);
|
||||
if (data.damage) {
|
||||
const label = data.hits > 1
|
||||
? `${data.skill_icon || '⚔️'} -${data.damage} (x${data.hits})`
|
||||
: `${data.skill_icon || '⚔️'} -${data.damage}`;
|
||||
addFloatingText(label, 'damage', 'enemy');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'skill_heal':
|
||||
if (data.heal) {
|
||||
addFloatingText(`${data.skill_icon || '💚'} +${data.heal}`, 'heal', 'player');
|
||||
if (pendingPlayerHpRef.current) {
|
||||
const { hp, max_hp } = pendingPlayerHpRef.current;
|
||||
setLocalCombatState(prev => ({
|
||||
...prev, playerHp: hp, playerMaxHp: max_hp
|
||||
}));
|
||||
updatePlayerState({ hp, max_hp });
|
||||
pendingPlayerHpRef.current = null;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'skill_buff':
|
||||
addFloatingText(`${data.skill_icon || '🛡️'} ${data.skill_name ? (typeof data.skill_name === 'object' ? (data.skill_name[(i18n as any).language] || data.skill_name.en) : data.skill_name) : 'Buff'}`, 'info', 'player');
|
||||
break;
|
||||
|
||||
case 'skill_effect':
|
||||
if (data.message) {
|
||||
addFloatingText(data.message, 'info', 'enemy');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'skill_analyze':
|
||||
addFloatingText(`${data.skill_icon || '🔍'} Analyzed!`, 'info', 'enemy');
|
||||
break;
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
@@ -740,6 +780,7 @@ export const Combat: React.FC<CombatProps> = ({
|
||||
onClose={handleCloseWrapper}
|
||||
onShowSupplies={() => setShowSuppliesModal(true)}
|
||||
isProcessing={isProcessingQueue}
|
||||
playerStamina={playerState?.stamina || 0}
|
||||
combatResult={combatResult}
|
||||
equipment={_equipment}
|
||||
playerName={profile?.name}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getTranslatedText } from '../../utils/i18nUtils';
|
||||
import { useAudio } from '../../contexts/AudioContext';
|
||||
@@ -7,6 +7,8 @@ import { Equipment } from './types';
|
||||
import './CombatEffects.css';
|
||||
import { GameProgressBar } from '../common/GameProgressBar';
|
||||
import { GameButton } from '../common/GameButton';
|
||||
import { GameDropdown } from '../common/GameDropdown';
|
||||
import api from '../../services/api';
|
||||
|
||||
interface CombatViewProps {
|
||||
state: CombatState;
|
||||
@@ -16,6 +18,7 @@ interface CombatViewProps {
|
||||
onClose: () => void;
|
||||
onShowSupplies: () => void;
|
||||
isProcessing: boolean;
|
||||
playerStamina: number;
|
||||
combatResult: 'victory' | 'defeat' | 'fled' | null;
|
||||
equipment?: Equipment | any;
|
||||
playerName?: string;
|
||||
@@ -30,6 +33,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
||||
onClose,
|
||||
onShowSupplies,
|
||||
isProcessing,
|
||||
playerStamina,
|
||||
combatResult,
|
||||
equipment,
|
||||
playerName,
|
||||
@@ -257,7 +261,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
||||
)}
|
||||
|
||||
{!combatResult && (
|
||||
<div className="combat-actions-group" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem', width: '100%', maxWidth: '400px', margin: '0 auto' }}>
|
||||
<div className="combat-actions-group" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem', width: '100%', maxWidth: '400px', margin: '0 auto' }}>
|
||||
<GameButton
|
||||
variant="danger"
|
||||
onClick={() => onAction('attack')}
|
||||
@@ -266,13 +270,11 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
||||
👊 {t('combat.actions.attack')}
|
||||
</GameButton>
|
||||
|
||||
<GameButton
|
||||
variant="primary"
|
||||
onClick={() => onAction('defend')}
|
||||
<AbilitiesDropdown
|
||||
onAction={onAction}
|
||||
disabled={isProcessing || !state.yourTurn}
|
||||
>
|
||||
🛡️ {t('combat.actions.defend')}
|
||||
</GameButton>
|
||||
playerStamina={playerStamina}
|
||||
/>
|
||||
|
||||
<GameButton
|
||||
variant="secondary"
|
||||
@@ -377,3 +379,108 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
||||
</div >
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// ─── Abilities Dropdown ───
|
||||
interface SkillInfo {
|
||||
id: string;
|
||||
name: any;
|
||||
icon: string;
|
||||
stamina_cost: number;
|
||||
cooldown: number;
|
||||
current_cooldown?: number;
|
||||
}
|
||||
|
||||
const AbilitiesDropdown: React.FC<{
|
||||
onAction: (action: string) => void;
|
||||
disabled: boolean;
|
||||
playerStamina?: number;
|
||||
}> = ({ onAction, disabled, playerStamina }) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [skills, setSkills] = useState<SkillInfo[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
const loadSkills = async () => {
|
||||
if (open) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api.get('/api/game/available-skills');
|
||||
setSkills(res.data.skills || []);
|
||||
setLoaded(true);
|
||||
setOpen(true);
|
||||
} catch (err) {
|
||||
console.error('Failed to load skills:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUse = (skillId: string) => {
|
||||
setOpen(false);
|
||||
onAction(`skill:${skillId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<GameButton
|
||||
variant="info"
|
||||
onClick={loadSkills}
|
||||
disabled={disabled}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
⚔️ {t('combat.actions.abilities', 'Abilities')}
|
||||
</GameButton>
|
||||
{open && skills.length > 0 && (
|
||||
<GameDropdown
|
||||
isOpen={open}
|
||||
onClose={() => setOpen(false)}
|
||||
width="250px"
|
||||
>
|
||||
{skills.map(s => {
|
||||
const onCooldown = (s.current_cooldown || 0) > 0;
|
||||
const notEnoughStamina = playerStamina !== undefined && playerStamina < s.stamina_cost;
|
||||
const isSkillDisabled = disabled || onCooldown || notEnoughStamina;
|
||||
|
||||
return (
|
||||
<GameButton
|
||||
key={s.id}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleUse(s.id)}
|
||||
disabled={isSkillDisabled}
|
||||
style={{ width: '100%', justifyContent: 'flex-start', marginBottom: '4px' }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', width: '100%', gap: '0.4rem', filter: isSkillDisabled ? 'grayscale(100%)' : 'none' }}>
|
||||
<span style={{ fontSize: '1rem' }}>{s.icon}</span>
|
||||
<span style={{ flex: 1, textAlign: 'left', color: isSkillDisabled ? '#808090' : '#d0d0e0' }}>{getTranslatedText(s.name)}</span>
|
||||
{onCooldown ? (
|
||||
<span style={{ color: '#e53e3e', fontSize: '0.65rem', fontWeight: 'bold' }}>⏳ {s.current_cooldown}T</span>
|
||||
) : (
|
||||
<span style={{ color: notEnoughStamina ? '#e53e3e' : '#a0a0b0', fontSize: '0.65rem' }}>⚡{s.stamina_cost}</span>
|
||||
)}
|
||||
</div>
|
||||
</GameButton>
|
||||
);
|
||||
})}
|
||||
</GameDropdown>
|
||||
)}
|
||||
{open && skills.length === 0 && loaded && (
|
||||
<GameDropdown
|
||||
isOpen={open}
|
||||
onClose={() => setOpen(false)}
|
||||
width="200px"
|
||||
>
|
||||
<div style={{
|
||||
padding: '0.5rem',
|
||||
color: '#8a8a9a',
|
||||
fontSize: '0.75rem',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
{t('combat.noSkills', 'No skills available')}
|
||||
</div>
|
||||
</GameDropdown>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
}
|
||||
|
||||
.game-modal-title {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
@@ -104,4 +105,22 @@
|
||||
/* Entity "Show All" modal - wider like inventory */
|
||||
.game-modal-container.entity-show-all-modal {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
/*
|
||||
* Globally standard wide modals
|
||||
* Character Sheet, Quest Journal, Workbench, Trade, Inventory
|
||||
*/
|
||||
.game-modal-container.character-sheet-modal,
|
||||
.game-modal-container.quest-journal-modal,
|
||||
.game-modal-container.workbench-modal,
|
||||
.game-modal-container.inventory-modal-redesign,
|
||||
.game-modal-container.trade-modal {
|
||||
width: 95vw !important;
|
||||
max-width: 1400px !important;
|
||||
height: 90% !important;
|
||||
max-height: 90% !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import React, { ReactNode } from 'react';
|
||||
import './GameModal.css';
|
||||
|
||||
interface GameModalProps {
|
||||
title?: string;
|
||||
title?: ReactNode;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
className?: string; // For specific styling overrides
|
||||
@@ -16,7 +16,7 @@ export const GameModal: React.FC<GameModalProps> = ({ title, onClose, children,
|
||||
}}>
|
||||
<div className={`game-modal-container ${className}`}>
|
||||
<div className="game-modal-header">
|
||||
<h2 className="game-modal-title">{title}</h2>
|
||||
<div className="game-modal-title">{title}</div>
|
||||
<button className="game-modal-close-btn" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ interface PlayerSidebarProps {
|
||||
onDropItem: (itemId: number, invId: number, quantity: number) => void
|
||||
onSpendPoint: (stat: string) => void
|
||||
onOpenQuestJournal: () => void
|
||||
onOpenCharacterSheet: () => void
|
||||
}
|
||||
|
||||
function PlayerSidebar({
|
||||
@@ -43,7 +44,8 @@ function PlayerSidebar({
|
||||
onUnequipItem,
|
||||
onDropItem,
|
||||
onSpendPoint,
|
||||
onOpenQuestJournal
|
||||
onOpenQuestJournal,
|
||||
onOpenCharacterSheet
|
||||
}: PlayerSidebarProps) {
|
||||
const [showInventory, setShowInventory] = useState(false)
|
||||
const [activeSlot, setActiveSlot] = useState<string | null>(null)
|
||||
@@ -263,6 +265,16 @@ function PlayerSidebar({
|
||||
{t('game.inventory')}
|
||||
</GameButton>
|
||||
|
||||
<GameButton
|
||||
className="quest-journal-btn"
|
||||
variant="info"
|
||||
size="sm"
|
||||
onClick={onOpenCharacterSheet}
|
||||
style={{ flex: 1, justifyContent: 'center' }}
|
||||
>
|
||||
<span>📊</span> {t('common.characterSheet', 'Stats')}
|
||||
</GameButton>
|
||||
|
||||
<GameButton
|
||||
className={`quest-journal-btn ${hasReadyQuests ? 'pulse-gold' : ''}`}
|
||||
variant={hasReadyQuests ? 'warning' : 'secondary'}
|
||||
@@ -270,7 +282,7 @@ function PlayerSidebar({
|
||||
onClick={onOpenQuestJournal}
|
||||
style={{ flex: 1, justifyContent: 'center' }}
|
||||
>
|
||||
{hasReadyQuests ? '❗ ' : '📜 '}{t('common.quests')}
|
||||
<span>{hasReadyQuests ? '❗' : '📜'}</span> {t('common.quests')}
|
||||
</GameButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
.quest-journal-modal {
|
||||
width: 90vw;
|
||||
max-width: 1200px;
|
||||
height: 95%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 95vw;
|
||||
max-width: 1400px;
|
||||
height: 90%;
|
||||
max-height: 90%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quest-journal-modal .game-modal-content {
|
||||
|
||||
@@ -222,7 +222,7 @@ export const QuestJournal: React.FC<QuestJournalProps> = ({ onClose }) => {
|
||||
>
|
||||
<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 style={{ padding: '10px 20px', background: 'var(--game-bg-panel)', borderBottom: '1px solid var(--game-border-color)' }}>
|
||||
<div className="tab-container">
|
||||
<button
|
||||
className={`journal-tab ${activeTab === 'active' ? 'active' : ''}`}
|
||||
|
||||
@@ -895,7 +895,9 @@ export function useGameEngine(
|
||||
const handleCombatAction = async (action: string) => {
|
||||
try {
|
||||
let payload: any = { action }
|
||||
if (action.includes(':')) {
|
||||
if (action.startsWith('skill:')) {
|
||||
payload = { action: 'skill', skill_id: action.substring(6) }
|
||||
} else if (action.includes(':')) {
|
||||
const [act, itemId] = action.split(':')
|
||||
payload = { action: act, item_id: itemId }
|
||||
}
|
||||
|
||||
59
pwa/src/contexts/NotificationContext.tsx
Normal file
59
pwa/src/contexts/NotificationContext.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||
|
||||
export type NotificationType = 'success' | 'error' | 'info' | 'warning' | 'quest';
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
message: string;
|
||||
type: NotificationType;
|
||||
duration?: number;
|
||||
isExiting?: boolean;
|
||||
}
|
||||
|
||||
interface NotificationContextType {
|
||||
notifications: Notification[];
|
||||
addNotification: (message: string, type: NotificationType, duration?: number) => void;
|
||||
removeNotification: (id: string) => void;
|
||||
}
|
||||
|
||||
const NotificationContext = createContext<NotificationContextType | undefined>(undefined);
|
||||
|
||||
export const useNotification = () => {
|
||||
const context = useContext(NotificationContext);
|
||||
if (!context) {
|
||||
throw new Error('useNotification must be used within a NotificationProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface NotificationProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const NotificationProvider: React.FC<NotificationProviderProps> = ({ children }) => {
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
|
||||
const addNotification = useCallback((message: string, type: NotificationType, duration = 3000) => {
|
||||
const id = Math.random().toString(36).substr(2, 9) + Date.now().toString(36);
|
||||
setNotifications((prev) => [...prev, { id, message, type, duration }]);
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeNotification(id);
|
||||
}, duration);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const removeNotification = useCallback((id: string) => {
|
||||
setNotifications((prev) => prev.map(n => n.id === id ? { ...n, isExiting: true } : n));
|
||||
setTimeout(() => {
|
||||
setNotifications((prev) => prev.filter((notification) => notification.id !== id));
|
||||
}, 400); // 400ms match CSS animation
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={{ notifications, addNotification, removeNotification }}>
|
||||
{children}
|
||||
</NotificationContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -329,8 +329,12 @@
|
||||
"enemy_attack": "Enemy hits for {{damage}} damage",
|
||||
"player_miss": "You missed!",
|
||||
"enemy_miss": "Enemy missed!",
|
||||
"item_broken": "Your {{item}} broke!",
|
||||
"xp_gain": "You gained {{xp}} XP!",
|
||||
"weapon_broke": "Your {{item_name}} broke!",
|
||||
"item_broken": "Your {{emoji}} {{item_name}} broke!",
|
||||
"combat_crit": "CRITICAL HIT!",
|
||||
"combat_dodge": "You Dodged the attack!",
|
||||
"combat_block": "You Blocked the attack!",
|
||||
"xp_gain": "Gained {{amount}} XP",
|
||||
"flee_success": "You managed to escape!",
|
||||
"defend": "You brace for impact!",
|
||||
"item_used": "Used {{item}}",
|
||||
@@ -377,6 +381,30 @@
|
||||
"potentialBaseStats": "Potential base stats. Actual stats may vary.",
|
||||
"confirmSalvage": "Are you sure you want to salvage {{name}}? This cannot be undone."
|
||||
},
|
||||
"characterSheet": {
|
||||
"title": "Character Sheet",
|
||||
"statsTab": "Stats",
|
||||
"skillsTab": "Skills",
|
||||
"perksTab": "Perks",
|
||||
"pointsAvailable": "points available",
|
||||
"baseStats": "Base Stats",
|
||||
"derivedStats": "Derived Stats",
|
||||
"attackPower": "Attack Power",
|
||||
"critChance": "Crit Chance",
|
||||
"critDamage": "Crit Damage",
|
||||
"dodgeChance": "Dodge Chance",
|
||||
"fleeChance": "Flee Chance",
|
||||
"maxHp": "Max HP",
|
||||
"maxStamina": "Max Stamina",
|
||||
"armor": "Armor",
|
||||
"blockChance": "Block Chance",
|
||||
"statusResist": "Status Resist",
|
||||
"itemEffect": "Item Effectiveness",
|
||||
"xpBonus": "XP Bonus",
|
||||
"lootQuality": "Loot Quality",
|
||||
"craftBonus": "Craft Bonus",
|
||||
"carryWeight": "Base Weight Capacity"
|
||||
},
|
||||
"categories": {
|
||||
"all": "All Items",
|
||||
"weapon": "Weapons",
|
||||
|
||||
@@ -327,7 +327,12 @@
|
||||
"enemy_attack": "El enemigo golpea por {{damage}} de daño",
|
||||
"player_miss": "¡Fallaste!",
|
||||
"enemy_miss": "¡El enemigo falló!",
|
||||
"item_broken": "¡Tu {{item}} se rompió!",
|
||||
"weapon_broke": "¡Tu {{item_name}} se ha roto!",
|
||||
"item_broken": "¡Tu {{emoji}} {{item_name}} se rompió!",
|
||||
"combat_crit": "¡GOLPE CRÍTICO!",
|
||||
"combat_dodge": "¡Esquivaste el ataque!",
|
||||
"combat_block": "¡Bloqueaste el ataque!",
|
||||
"xp_gain": "Ganaste {{amount}} XP",
|
||||
"flee_success": "¡Lograste escapar!",
|
||||
"flee_fail": "¡No pudiste escapar!",
|
||||
"defend": "¡Te preparas para el impacto!",
|
||||
@@ -375,6 +380,30 @@
|
||||
"potentialBaseStats": "Estadísticas base potenciales. Las estadísticas reales pueden variar.",
|
||||
"confirmSalvage": "¿Estás seguro de que quieres desguazar {{name}}? Esto no se puede deshacer."
|
||||
},
|
||||
"characterSheet": {
|
||||
"title": "Hoja de Personaje",
|
||||
"statsTab": "Atributos",
|
||||
"skillsTab": "Habilidades",
|
||||
"perksTab": "Talentos",
|
||||
"pointsAvailable": "puntos disponibles",
|
||||
"baseStats": "Atributos Base",
|
||||
"derivedStats": "Estadísticas Derivadas",
|
||||
"attackPower": "Poder de Ataque",
|
||||
"critChance": "Prob. Crítico",
|
||||
"critDamage": "Daño Crítico",
|
||||
"dodgeChance": "Prob. Esquivar",
|
||||
"fleeChance": "Prob. Huir",
|
||||
"maxHp": "Vida Máx.",
|
||||
"maxStamina": "Aguante Máx.",
|
||||
"armor": "Armadura",
|
||||
"blockChance": "Prob. Bloqueo",
|
||||
"statusResist": "Resistencia a Estados",
|
||||
"itemEffect": "Eficacia de Objetos",
|
||||
"xpBonus": "Bonus XP",
|
||||
"lootQuality": "Calidad de Botín",
|
||||
"craftBonus": "Bonus Fabricación",
|
||||
"carryWeight": "Capacidad Base de Carga"
|
||||
},
|
||||
"categories": {
|
||||
"all": "Todos los Objetos",
|
||||
"weapon": "Armas",
|
||||
|
||||
@@ -21,24 +21,50 @@ if (!isElectron) {
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize Twemoji after React renders
|
||||
const initTwemoji = () => {
|
||||
twemoji.parse(document.body, {
|
||||
folder: 'svg',
|
||||
ext: '.svg',
|
||||
base: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/'
|
||||
});
|
||||
const twemojiOpts = {
|
||||
folder: 'svg',
|
||||
ext: '.svg',
|
||||
base: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/'
|
||||
};
|
||||
|
||||
const initTwemoji = () => {
|
||||
twemoji.parse(document.body, twemojiOpts);
|
||||
};
|
||||
|
||||
// Create a wrapper component that initializes Twemoji
|
||||
const TwemojiWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
useEffect(() => {
|
||||
// Initial parse
|
||||
// Initial parse of entire body
|
||||
initTwemoji();
|
||||
|
||||
// Set up MutationObserver to re-parse when DOM changes
|
||||
const observer = new MutationObserver(() => {
|
||||
initTwemoji();
|
||||
// Collect added nodes and parse them in batches AFTER React finishes
|
||||
// its synchronous render cycle. Without deferral, Twemoji replaces
|
||||
// emoji text nodes with <img> elements while React is still
|
||||
// reconciling the DOM, causing NotFoundError on removeChild.
|
||||
let pendingNodes = new Set<Node>();
|
||||
let rafId: number | null = null;
|
||||
|
||||
const processPending = () => {
|
||||
for (const node of pendingNodes) {
|
||||
// Only parse nodes still in the document (React may have removed them)
|
||||
if (node.nodeType === Node.ELEMENT_NODE && document.body.contains(node)) {
|
||||
twemoji.parse(node as HTMLElement, twemojiOpts);
|
||||
}
|
||||
}
|
||||
pendingNodes.clear();
|
||||
rafId = null;
|
||||
};
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
pendingNodes.add(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pendingNodes.size > 0 && rafId === null) {
|
||||
rafId = requestAnimationFrame(processPending);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
@@ -46,7 +72,10 @@ const TwemojiWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
|
||||
Reference in New Issue
Block a user