diff --git a/pwa/public/landing-bg.jpeg b/pwa/public/landing-bg.jpeg new file mode 100644 index 0000000..261f4ed Binary files /dev/null and b/pwa/public/landing-bg.jpeg differ diff --git a/pwa/public/landing-bg.webp b/pwa/public/landing-bg.webp new file mode 100644 index 0000000..95b7da3 Binary files /dev/null and b/pwa/public/landing-bg.webp differ diff --git a/pwa/src/components/AuthenticatedLayout.tsx b/pwa/src/components/AuthenticatedLayout.tsx new file mode 100644 index 0000000..f02cd1a --- /dev/null +++ b/pwa/src/components/AuthenticatedLayout.tsx @@ -0,0 +1,13 @@ +import { Outlet } from 'react-router-dom' +import LandingHeader from './LandingHeader' + +export default function AuthenticatedLayout() { + return ( +
+ +
+ +
+
+ ) +} diff --git a/pwa/src/components/Game.tsx b/pwa/src/components/Game.tsx index cec22a7..a714b5c 100644 --- a/pwa/src/components/Game.tsx +++ b/pwa/src/components/Game.tsx @@ -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)} /> )} @@ -595,6 +598,13 @@ function Game() { {showQuestJournal && ( setShowQuestJournal(false)} /> )} + + {showCharacterSheet && ( + setShowCharacterSheet(false)} + onSpendPoint={actions.handleSpendPoint} + /> + )} ) diff --git a/pwa/src/components/LandingHeader.css b/pwa/src/components/LandingHeader.css new file mode 100644 index 0000000..d057b68 --- /dev/null +++ b/pwa/src/components/LandingHeader.css @@ -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; + } +} \ No newline at end of file diff --git a/pwa/src/components/LandingHeader.tsx b/pwa/src/components/LandingHeader.tsx new file mode 100644 index 0000000..9b64bbc --- /dev/null +++ b/pwa/src/components/LandingHeader.tsx @@ -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 ( +
+
+
navigate('/')} + style={{ cursor: 'pointer' }} + > +

Echoes of the Ash

+ Official +
+
+ +
+ + + {isAuthenticated ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+ ) +} diff --git a/pwa/src/components/PrivacyPolicy.tsx b/pwa/src/components/PrivacyPolicy.tsx new file mode 100644 index 0000000..b2ebb7a --- /dev/null +++ b/pwa/src/components/PrivacyPolicy.tsx @@ -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 ( +
+
+

{t('legal.privacy.title')}

+ +
+

{t('legal.privacy.lastUpdated')}

+ +

{t('legal.privacy.sections.1.title')}

+

{t('legal.privacy.sections.1.content')}

+ +

{t('legal.privacy.sections.2.title')}

+

{t('legal.privacy.sections.2.content')}

+ +

{t('legal.privacy.sections.3.title')}

+

{t('legal.privacy.sections.3.content')}

+ +

{t('legal.privacy.sections.4.title')}

+

{t('legal.privacy.sections.4.content')}

+ +

{t('legal.privacy.sections.5.title')}

+

{t('legal.privacy.sections.5.content')}

+
+ +
+ navigate('/')} + > + {t('legal.privacy.back')} + +
+
+
+ ) +} diff --git a/pwa/src/components/PublicLayout.tsx b/pwa/src/components/PublicLayout.tsx new file mode 100644 index 0000000..58de0f1 --- /dev/null +++ b/pwa/src/components/PublicLayout.tsx @@ -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 ( +
+ +
+ +
+
+ ) +} diff --git a/pwa/src/components/TermsOfService.tsx b/pwa/src/components/TermsOfService.tsx new file mode 100644 index 0000000..417b56d --- /dev/null +++ b/pwa/src/components/TermsOfService.tsx @@ -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 ( +
+
+

{t('legal.terms.title')}

+ +
+

{t('legal.terms.lastUpdated')}

+ +

{t('legal.terms.sections.1.title')}

+

{t('legal.terms.sections.1.content')}

+ +

{t('legal.terms.sections.2.title')}

+

{t('legal.terms.sections.2.content')}

+ +

{t('legal.terms.sections.3.title')}

+

{t('legal.terms.sections.3.content')}

+ +

{t('legal.terms.sections.4.title')}

+

{t('legal.terms.sections.4.content')}

+ +

{t('legal.terms.sections.5.title')}

+

{t('legal.terms.sections.5.content')}

+
+ +
+ navigate('/')} + > + {t('legal.terms.back')} + +
+
+
+ ) +} diff --git a/pwa/src/components/common/GameItemCard.tsx b/pwa/src/components/common/GameItemCard.tsx new file mode 100644 index 0000000..1a985bd --- /dev/null +++ b/pwa/src/components/common/GameItemCard.tsx @@ -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 = ({ + 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 ? ( + + ) : 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 = ( +
+
+ {item.image_path ? ( + {getTranslatedText(item.name)} { + (e.target as HTMLImageElement).style.display = 'none'; + (e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden'); + }} + /> + ) : null} +
+ {item.emoji || 'πŸ“¦'} +
+
+ + {/* Equipped Badge */} + {showEquipped && item.is_equipped && ( +
E
+ )} + + {/* Quantity Badge */} + {showQuantity && (item.is_infinite || (item._displayQuantity || item.quantity) > 1) && ( +
+ {item.is_infinite ? '∞' : `x${item._displayQuantity || item.quantity}`} +
+ )} + + {/* 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 ( +
+ {Math.round(item.value * tradeMarkup * multiplier)} +
+ ); + })()} + + {/* Durability Bar */} + {showDurability && item.max_durability && item.max_durability > 0 && ( +
+ +
+ )} +
+ ); + + // If tooltip is enabled, wrap it. Otherwise return bare card. + if (showTooltip && tooltipContent) { + return ( + + {cardContent} + + ); + } + + return cardContent; +}; diff --git a/pwa/src/components/common/GameProgressBar.tsx b/pwa/src/components/common/GameProgressBar.tsx index 1674e09..c11c650 100644 --- a/pwa/src/components/common/GameProgressBar.tsx +++ b/pwa/src/components/common/GameProgressBar.tsx @@ -11,6 +11,7 @@ interface GameProgressBarProps { height?: string; align?: 'left' | 'right'; labelAlignment?: 'left' | 'right'; + customColor?: string; } export const GameProgressBar: React.FC = ({ @@ -22,7 +23,8 @@ export const GameProgressBar: React.FC = ({ 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 = ({ // 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 diff --git a/pwa/src/components/common/ItemStatBadges.tsx b/pwa/src/components/common/ItemStatBadges.tsx new file mode 100644 index 0000000..d257f50 --- /dev/null +++ b/pwa/src/components/common/ItemStatBadges.tsx @@ -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 ( +
+ {/* Capacity */} + {(stats.weight_capacity) && ( + + βš–οΈ +{stats.weight_capacity}kg + + )} + {(stats.volume_capacity) && ( + + πŸ“¦ +{stats.volume_capacity}L + + )} + + {/* Combat */} + {(stats.damage_min) && ( + + βš”οΈ {stats.damage_min}-{stats.damage_max} + + )} + {(stats.armor) && ( + + πŸ›‘οΈ +{stats.armor} + + )} + {(stats.armor_penetration) && ( + + πŸ’” +{stats.armor_penetration} {t('stats.pen')} + + )} + {(stats.crit_chance) && ( + + 🎯 +{Math.round(stats.crit_chance * 100)}% {t('stats.crit')} + + )} + {(stats.accuracy) && ( + + πŸ‘οΈ +{Math.round(stats.accuracy * 100)}% {t('stats.acc')} + + )} + {(stats.dodge_chance) && ( + + πŸ’¨ +{Math.round(stats.dodge_chance * 100)}% Dodge + + )} + {(stats.lifesteal) && ( + + πŸ§› +{Math.round(stats.lifesteal * 100)}% {t('stats.life')} + + )} + + {/* Attributes */} + {(stats.strength_bonus) && ( + + πŸ’ͺ +{stats.strength_bonus} {t('stats.str')} + + )} + {(stats.agility_bonus) && ( + + πŸƒ +{stats.agility_bonus} {t('stats.agi')} + + )} + {(stats.endurance_bonus) && ( + + πŸ‹οΈ +{stats.endurance_bonus} {t('stats.end')} + + )} + {(stats.hp_bonus) && ( + + ❀️ +{stats.hp_bonus} {t('stats.hpMax')} + + )} + {(stats.stamina_bonus) && ( + + ⚑ +{stats.stamina_bonus} {t('stats.stmMax')} + + )} + + {/* Consumables */} + {(item.hp_restore || effects.hp_restore) && ( + + ❀️ +{item.hp_restore || effects.hp_restore} HP + + )} + {(item.stamina_restore || effects.stamina_restore) && ( + + ⚑ +{item.stamina_restore || effects.stamina_restore} Stm + + )} + + {/* Status Effects */} + {effects.status_effect && ( + + )} + + {effects.cures && effects.cures.length > 0 && ( + + πŸ’Š {t('game.cures')}: {effects.cures.map((c: string) => t(`game.effects.${c}`, c)).join(', ')} + + )} +
+ ); +}; diff --git a/pwa/src/components/common/LanguageSelector.css b/pwa/src/components/common/LanguageSelector.css new file mode 100644 index 0000000..967078f --- /dev/null +++ b/pwa/src/components/common/LanguageSelector.css @@ -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; +} \ No newline at end of file diff --git a/pwa/src/components/common/LanguageSelector.tsx b/pwa/src/components/common/LanguageSelector.tsx new file mode 100644 index 0000000..9cb7942 --- /dev/null +++ b/pwa/src/components/common/LanguageSelector.tsx @@ -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 ( +
+ + 🌐 + {i18n.language.startsWith('es') ? 'ES' : 'EN'} + +
+ ) +} diff --git a/pwa/src/components/common/Notification.css b/pwa/src/components/common/Notification.css new file mode 100644 index 0000000..813f515 --- /dev/null +++ b/pwa/src/components/common/Notification.css @@ -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; + } +} \ No newline at end of file diff --git a/pwa/src/components/common/NotificationContainer.tsx b/pwa/src/components/common/NotificationContainer.tsx new file mode 100644 index 0000000..3d880ab --- /dev/null +++ b/pwa/src/components/common/NotificationContainer.tsx @@ -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 ( +
+ {notifications.map((notification) => ( +
removeNotification(notification.id)} + > + {notification.message} +
+ ))} +
+ ); +}; diff --git a/pwa/src/components/game/CharacterSheet.css b/pwa/src/components/game/CharacterSheet.css new file mode 100644 index 0000000..e83dde6 --- /dev/null +++ b/pwa/src/components/game/CharacterSheet.css @@ -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; + } +} \ No newline at end of file diff --git a/pwa/src/components/game/CharacterSheet.tsx b/pwa/src/components/game/CharacterSheet.tsx new file mode 100644 index 0000000..b78b727 --- /dev/null +++ b/pwa/src/components/game/CharacterSheet.tsx @@ -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; + effects: Record; + 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 = { + strength: 'πŸ’ͺ', + agility: 'πŸƒ', + endurance: 'πŸ«€', + intellect: '🧠', +}; + +const STAT_COLORS: Record = { + strength: '#e74c3c', + agility: '#2ecc71', + endurance: '#f39c12', + intellect: '#3498db', +}; + +export function CharacterSheet({ onClose, onSpendPoint }: CharacterSheetProps) { + const { t } = useTranslation(); + const [data, setData] = useState(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 ( + +
βŒ› {t('common.loading', 'Loading...')}
+
+ ); + } + + const { base_stats, derived_stats, skills, perks, character } = data; + + const renderStatsTab = () => ( +
+ {/* Base Stats Section */} +
+
+

{t('characterSheet.baseStats', 'Base Stats')}

+ + {/* Vitals Moved Here */} +
+ + + +
+ + {base_stats.unspent_points > 0 && ( +
+ ✨ {base_stats.unspent_points} {t('characterSheet.pointsAvailable', 'points available')} +
+ )} +
+ {(['strength', 'agility', 'endurance', 'intellect'] as const).map(stat => ( +
+ {STAT_ICONS[stat]} + {t(`stats.${stat}Full`)} +
+ +
+ {base_stats.unspent_points > 0 && base_stats[stat] < base_stats.stat_cap && ( + + )} +
+ ))} +
+
+
+ + {/* Derived Stats Section */} +
+
+

{t('characterSheet.derivedStats', 'Derived Stats')}

+
+ + + + + + + + + + + + + + + +
+
+
+
+ ); + + const renderSkillsTab = () => { + const grouped: Record = { + strength: [], + agility: [], + endurance: [], + intellect: [], + }; + skills.forEach(s => { + if (grouped[s.stat_requirement]) { + grouped[s.stat_requirement].push(s); + } + }); + + return ( +
+ {Object.entries(grouped).map(([stat, statSkills]) => ( +
+

+ {STAT_ICONS[stat]} {t(`stats.${stat}Full`)} +

+
+ {statSkills.map(skill => ( +
+
+ {skill.icon} + {getTranslatedText(skill.name)} + {skill.unlocked ? ( + βœ“ + ) : ( + πŸ”’ + )} +
+

{getTranslatedText(skill.description)}

+
+ ⚑ {skill.stamina_cost} + πŸ”„ {skill.cooldown}t + + {t(`stats.${skill.stat_requirement}`)}: {skill.stat_threshold} + + + {t('stats.level')}: {skill.level_requirement} + +
+
+ ))} +
+
+ ))} +
+ ); + }; + + const renderPerksTab = () => ( +
+
+ ⭐ {t('characterSheet.perkPoints', 'Perk Points')}: + + {perks.available_points} / {perks.total_points} + + + ({t('characterSheet.nextPerkAt', 'Next at Lv')} {((perks.used_points + perks.available_points + 1) * 5)}) + +
+
+ {perks.all_perks.map(perk => { + const canSelect = perk.meets_requirements && !perk.owned && perks.available_points > 0; + return ( +
+
+ {perk.icon} +
+ {getTranslatedText(perk.name)} +

{getTranslatedText(perk.description)}

+
+ {perk.owned ? ( + βœ“ {t('characterSheet.owned', 'Owned')} + ) : canSelect ? ( + handleSelectPerk(perk.id)} + disabled={selectingPerk} + > + {t('characterSheet.select', 'Select')} + + ) : ( + πŸ”’ + )} +
+
+ {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 ( + + {displayKey} {isMax ? '≀' : 'β‰₯'} {val} + + ); + })} +
+
+ ); + })} +
+
+ ); + + const modalTitle = ( +
+ {character.name} β€” Lv. {character.level} +
+ ); + + return ( + + {/* Tab Navigation */} +
+ + + +
+ + {/* Tab Content */} +
+ {activeTab === 'stats' && renderStatsTab()} + {activeTab === 'skills' && renderSkillsTab()} + {activeTab === 'perks' && renderPerksTab()} +
+
+ ); +} + +function DerivedStatRow({ icon, label, value }: { icon: string; label: string; value: any }) { + return ( +
+ {icon} + {label} + {value} +
+ ); +} diff --git a/pwa/src/components/game/Combat.tsx b/pwa/src/components/game/Combat.tsx index bff4474..4931ef0 100644 --- a/pwa/src/components/game/Combat.tsx +++ b/pwa/src/components/game/Combat.tsx @@ -495,6 +495,46 @@ export const Combat: React.FC = ({ 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 = ({ onClose={handleCloseWrapper} onShowSupplies={() => setShowSuppliesModal(true)} isProcessing={isProcessingQueue} + playerStamina={playerState?.stamina || 0} combatResult={combatResult} equipment={_equipment} playerName={profile?.name} diff --git a/pwa/src/components/game/CombatView.tsx b/pwa/src/components/game/CombatView.tsx index 8c861da..75977e1 100644 --- a/pwa/src/components/game/CombatView.tsx +++ b/pwa/src/components/game/CombatView.tsx @@ -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 = ({ onClose, onShowSupplies, isProcessing, + playerStamina, combatResult, equipment, playerName, @@ -257,7 +261,7 @@ export const CombatView: React.FC = ({ )} {!combatResult && ( -
+
onAction('attack')} @@ -266,13 +270,11 @@ export const CombatView: React.FC = ({ πŸ‘Š {t('combat.actions.attack')} - onAction('defend')} + - πŸ›‘οΈ {t('combat.actions.defend')} - + playerStamina={playerStamina} + /> = ({
); }; + + +// ─── 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([]); + 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 ( +
+ + βš”οΈ {t('combat.actions.abilities', 'Abilities')} + + {open && skills.length > 0 && ( + 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 ( + handleUse(s.id)} + disabled={isSkillDisabled} + style={{ width: '100%', justifyContent: 'flex-start', marginBottom: '4px' }} + > +
+ {s.icon} + {getTranslatedText(s.name)} + {onCooldown ? ( + ⏳ {s.current_cooldown}T + ) : ( + ⚑{s.stamina_cost} + )} +
+
+ ); + })} +
+ )} + {open && skills.length === 0 && loaded && ( + setOpen(false)} + width="200px" + > +
+ {t('combat.noSkills', 'No skills available')} +
+
+ )} +
+ ); +}; diff --git a/pwa/src/components/game/GameModal.css b/pwa/src/components/game/GameModal.css index 65d9df3..516375b 100644 --- a/pwa/src/components/game/GameModal.css +++ b/pwa/src/components/game/GameModal.css @@ -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; } \ No newline at end of file diff --git a/pwa/src/components/game/GameModal.tsx b/pwa/src/components/game/GameModal.tsx index 4843a3c..2607916 100644 --- a/pwa/src/components/game/GameModal.tsx +++ b/pwa/src/components/game/GameModal.tsx @@ -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 = ({ title, onClose, children, }}>
-

{title}

+
{title}
diff --git a/pwa/src/components/game/PlayerSidebar.tsx b/pwa/src/components/game/PlayerSidebar.tsx index 3c29d14..318a053 100644 --- a/pwa/src/components/game/PlayerSidebar.tsx +++ b/pwa/src/components/game/PlayerSidebar.tsx @@ -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(null) @@ -263,6 +265,16 @@ function PlayerSidebar({ {t('game.inventory')} + + πŸ“Š {t('common.characterSheet', 'Stats')} + + - {hasReadyQuests ? '❗ ' : 'πŸ“œ '}{t('common.quests')} + {hasReadyQuests ? '❗' : 'πŸ“œ'} {t('common.quests')}
diff --git a/pwa/src/components/game/QuestJournal.css b/pwa/src/components/game/QuestJournal.css index 783595d..501fc5b 100644 --- a/pwa/src/components/game/QuestJournal.css +++ b/pwa/src/components/game/QuestJournal.css @@ -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 { diff --git a/pwa/src/components/game/QuestJournal.tsx b/pwa/src/components/game/QuestJournal.tsx index 4f161ba..c914896 100644 --- a/pwa/src/components/game/QuestJournal.tsx +++ b/pwa/src/components/game/QuestJournal.tsx @@ -222,7 +222,7 @@ export const QuestJournal: React.FC = ({ onClose }) => { >
{/* Header / Tabs */} -
+