Release v0.2.10: Update package-lock.json and CI config

This commit is contained in:
Joan
2025-12-30 18:51:21 +01:00
parent 8b31011334
commit 592f38827e
108 changed files with 2755 additions and 1112 deletions

4
pwa/.gitignore vendored
View File

@@ -6,6 +6,10 @@ yarn.lock
# Build output
dist/
build/
dist-electron/
# Copied assets (generated at build time)
public/images/
# Environment variables
.env

View File

@@ -0,0 +1,29 @@
/**
* electron-builder afterPack hook
* Removes "type": "module" from the packaged package.json
* to ensure CommonJS files work correctly in the Electron app.
*/
const fs = require('fs')
const path = require('path')
exports.default = async function (context) {
const appDir = context.appOutDir
const resourcesPath = path.join(appDir, 'resources', 'app')
const packageJsonPath = path.join(resourcesPath, 'package.json')
console.log('afterPack: Checking for package.json at:', packageJsonPath)
if (fs.existsSync(packageJsonPath)) {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'))
// Remove the "type": "module" field to ensure CommonJS compat
if (packageJson.type === 'module') {
delete packageJson.type
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2))
console.log('afterPack: Removed "type": "module" from package.json')
}
} else {
console.log('afterPack: package.json not found, skipping...')
}
}

View File

@@ -40,7 +40,7 @@ function createWindow() {
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js')
preload: path.join(__dirname, 'preload.cjs')
},
icon: path.join(__dirname, 'icons/icon.png'),
title: 'Echoes of the Ash'

View File

@@ -9,17 +9,18 @@
},
"homepage": "https://echoesoftheash.com",
"type": "module",
"main": "electron/main.js",
"main": "electron/main.cjs",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"copy-assets": "rm -rf ./public/images && cp -r ../images ./public/",
"electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:5173 && electron .\"",
"electron:build": "npm run build && electron-builder",
"electron:build:win": "npm run build && electron-builder --win",
"electron:build:linux": "npm run build && electron-builder --linux",
"electron:build:mac": "npm run build && electron-builder --mac"
"electron:build": "npm run copy-assets && npm run build && electron-builder",
"electron:build:win": "npm run copy-assets && npm run build && electron-builder --win",
"electron:build:linux": "npm run copy-assets && npm run build && electron-builder --linux",
"electron:build:mac": "npm run copy-assets && npm run build && electron-builder --mac"
},
"dependencies": {
"react": "^18.2.0",
@@ -27,7 +28,10 @@
"react-router-dom": "^6.20.0",
"axios": "^1.6.2",
"zustand": "^4.4.7",
"twemoji": "^14.0.2"
"twemoji": "^14.0.2",
"i18next": "^23.7.0",
"react-i18next": "^14.0.0",
"i18next-browser-languagedetector": "^7.2.0"
},
"devDependencies": {
"@types/react": "^18.2.43",
@@ -52,6 +56,7 @@
"build": {
"appId": "com.echoesoftheash.game",
"productName": "Echoes of the Ash",
"afterPack": "./electron/afterPack.cjs",
"directories": {
"output": "dist-electron"
},

View File

@@ -3,6 +3,8 @@ import { useNavigate, useLocation } from 'react-router-dom'
import { useAuth } from '../hooks/useAuth'
import { useGameWebSocket } from '../hooks/useGameWebSocket'
import api from '../services/api'
import { useTranslation } from 'react-i18next'
import LanguageSelector from './LanguageSelector'
import './Game.css'
interface GameHeaderProps {
@@ -13,6 +15,7 @@ export default function GameHeader({ className = '' }: GameHeaderProps) {
const navigate = useNavigate()
const location = useLocation()
const { currentCharacter, logout } = useAuth()
const { t } = useTranslation()
const [playerCount, setPlayerCount] = useState<number>(0)
// Fetch initial player count
@@ -63,19 +66,20 @@ export default function GameHeader({ className = '' }: GameHeaderProps) {
onClick={() => navigate('/game')}
className={`nav-link ${isActive('/game') ? 'active' : ''}`}
>
🎮 Game
🎮 {t('common.game')}
</button>
<button
onClick={() => navigate('/leaderboards')}
className={`nav-link ${isActive('/leaderboards') ? 'active' : ''}`}
>
🏆 Leaderboards
🏆 {t('common.leaderboards')}
</button>
</nav>
<div className="user-info">
<div className="player-count-badge" title="Online Players">
<LanguageSelector />
<div className="player-count-badge" title={t('game.onlineCount', { count: playerCount })}>
<span className="status-dot"></span>
<span className="count-text">{playerCount} Online</span>
<span className="count-text">{t('game.onlineCount', { count: playerCount })}</span>
</div>
<button
onClick={() => navigate(`/profile/${currentCharacter?.id}`)}
@@ -87,9 +91,9 @@ export default function GameHeader({ className = '' }: GameHeaderProps) {
onClick={() => navigate('/account')}
className="button-secondary"
>
Account
{t('common.account')}
</button>
<button onClick={logout} className="button-secondary">Logout</button>
<button onClick={logout} className="button-secondary">{t('auth.logout')}</button>
</div>
</header>
)

View File

@@ -0,0 +1,86 @@
.language-selector {
position: relative;
display: inline-block;
}
.language-btn {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.75rem;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
color: #fff;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.language-btn:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.3);
}
.lang-flag {
font-size: 1.1rem;
}
.lang-code {
font-weight: 500;
letter-spacing: 0.5px;
}
.language-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 0.25rem;
background: #1a1a2e;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
overflow: hidden;
opacity: 0;
visibility: hidden;
transform: translateY(-8px);
transition: all 0.2s ease;
z-index: 1000;
min-width: 140px;
}
.language-selector:hover .language-dropdown,
.language-selector:focus-within .language-dropdown {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.lang-option {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.65rem 1rem;
border: none;
background: transparent;
color: #ccc;
cursor: pointer;
text-align: left;
font-size: 0.9rem;
transition: all 0.15s;
}
.lang-option:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.lang-option.active {
background: rgba(100, 100, 255, 0.2);
color: #8bf;
}
.lang-name {
font-weight: 400;
}

View File

@@ -0,0 +1,40 @@
import { useTranslation } from 'react-i18next'
import './LanguageSelector.css'
const languages = [
{ code: 'en', name: 'English', flag: '🇬🇧' },
{ code: 'es', name: 'Español', flag: '🇪🇸' }
]
function LanguageSelector() {
const { i18n } = useTranslation()
const changeLanguage = (langCode: string) => {
i18n.changeLanguage(langCode)
}
const currentLang = languages.find(l => l.code === i18n.language) || languages[0]
return (
<div className="language-selector">
<button className="language-btn">
<span className="lang-flag">{currentLang.flag}</span>
<span className="lang-code">{currentLang.code.toUpperCase()}</span>
</button>
<div className="language-dropdown">
{languages.map(lang => (
<button
key={lang.code}
className={`lang-option ${i18n.language === lang.code ? 'active' : ''}`}
onClick={() => changeLanguage(lang.code)}
>
<span className="lang-flag">{lang.flag}</span>
<span className="lang-name">{lang.name}</span>
</button>
))}
</div>
</div>
)
}
export default LanguageSelector

View File

@@ -1,5 +1,8 @@
import { MouseEvent, ChangeEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { PlayerState, Profile, Equipment } from './types'
import { getAssetPath } from '../../utils/assetPath'
import { getTranslatedText } from '../../utils/i18nUtils'
import './InventoryModal.css'
interface InventoryModalProps {
@@ -31,6 +34,7 @@ function InventoryModal({
onUnequipItem,
onDropItem
}: InventoryModalProps) {
useTranslation()
// Categories for the sidebar
const categories = [
{ id: 'all', label: 'All Items', icon: '🎒' },
@@ -51,7 +55,7 @@ function InventoryModal({
// Filter items based on search and category
const filteredItems = allItems
.filter((item: any) => {
const itemName = item.name || 'Unknown Item';
const itemName = getTranslatedText(item.name) || 'Unknown Item';
const matchesSearch = itemName.toLowerCase().includes(inventoryFilter.toLowerCase())
const matchesCategory = inventoryCategoryFilter === 'all' || item.type === inventoryCategoryFilter
return matchesSearch && matchesCategory
@@ -60,7 +64,7 @@ function InventoryModal({
// Equipped items first
if (a.is_equipped && !b.is_equipped) return -1;
if (!a.is_equipped && b.is_equipped) return 1;
return (a.name || '').localeCompare(b.name || '');
return (getTranslatedText(a.name) || '').localeCompare(getTranslatedText(b.name) || '');
})
const renderItemCard = (item: any, i: number) => {
@@ -75,8 +79,8 @@ function InventoryModal({
<div className="item-image-section small">
{item.image_path ? (
<img
src={item.image_path}
alt={item.name}
src={getAssetPath(item.image_path)}
alt={getTranslatedText(item.name)}
className="item-img-thumb"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
@@ -95,7 +99,7 @@ function InventoryModal({
<div className="item-info-section">
<div className="item-header-compact">
<span className="item-emoji-inline">{item.emoji}</span>
<h4 className={`item-name-compact text-tier-${item.tier || 0}`}>{item.name}</h4>
<h4 className={`item-name-compact text-tier-${item.tier || 0}`}>{getTranslatedText(item.name)}</h4>
{item.is_equipped && <span className="item-card-equipped">Equipped</span>}
</div>
@@ -116,7 +120,7 @@ function InventoryModal({
{/* Stats & Durability */}
<div className="stats-durability-column">
{item.description && <p className="item-description-compact">{item.description}</p>}
{item.description && <p className="item-description-compact">{getTranslatedText(item.description)}</p>}
{/* Stats Row - Button-like Badges */}
<div className="stat-badges-container">
@@ -315,7 +319,7 @@ function InventoryModal({
{equipment?.backpack ? (
<div className="backpack-status active">
<span className="backpack-icon">🎒</span>
<span className="backpack-name">{equipment.backpack.name}</span>
<span className="backpack-name">{getTranslatedText(equipment.backpack.name)}</span>
<span className="backpack-stats">
(+{equipment.backpack.unique_stats?.weight_capacity || equipment.backpack.stats?.weight_capacity || 0}kg /
+{equipment.backpack.unique_stats?.volume_capacity || equipment.backpack.stats?.volume_capacity || 0}L)

View File

@@ -1,6 +1,9 @@
import type { Location, PlayerState, CombatState, Profile, WorkbenchTab } from './types'
import { useTranslation } from 'react-i18next'
import Workbench from './Workbench'
import { getAssetPath } from '../../utils/assetPath'
import { getTranslatedText } from '../../utils/i18nUtils'
interface LocationViewProps {
location: Location
@@ -79,11 +82,12 @@ function LocationView({
onRepair,
onUncraft
}: LocationViewProps) {
useTranslation()
return (
<div className="location-view">
<div className="location-info">
<h2 className="centered-heading">
{location.name}
{getTranslatedText(location.name)}
{location.danger_level !== undefined && location.danger_level === 0 && (
<span className="danger-badge danger-safe" title="Safe Zone"> Safe</span>
)}
@@ -132,8 +136,8 @@ function LocationView({
{location.image_url && (
<div className="location-image-container">
<img
src={location.image_url}
alt={location.name}
src={getAssetPath(location.image_url)}
alt={getTranslatedText(location.name)}
className="location-image"
onError={(e: any) => (e.currentTarget.style.display = 'none')}
/>
@@ -141,7 +145,7 @@ function LocationView({
)}
<div className="location-description-box">
<p className="location-description">{location.description}</p>
<p className="location-description">{getTranslatedText(location.description)}</p>
</div>
</div>
@@ -176,7 +180,7 @@ function LocationView({
{enemy.id && (
<div className="entity-image">
<img
src={enemy.image_path ? `/${enemy.image_path}` : `/images/npcs/${enemy.name.toLowerCase().replace(/ /g, '_')}.webp`}
src={getAssetPath(enemy.image_path || `images/npcs/${enemy.name.toLowerCase().replace(/ /g, '_')}.webp`)}
alt={enemy.name}
onError={(e: any) => { e.currentTarget.style.display = 'none' }}
/>
@@ -301,7 +305,7 @@ function LocationView({
<div key={i} className="entity-card item-card">
{item.image_path ? (
<img
src={item.image_path}
src={getAssetPath(item.image_path)}
alt={item.name}
className="entity-icon"
onError={(e) => {

View File

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

View File

@@ -1,5 +1,8 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { PlayerState, Profile, Equipment } from './types'
import { getAssetPath } from '../../utils/assetPath'
import { getTranslatedText } from '../../utils/i18nUtils'
import InventoryModal from './InventoryModal'
interface PlayerSidebarProps {
@@ -37,16 +40,18 @@ function PlayerSidebar({
const { t } = useTranslation()
const renderEquipmentSlot = (slot: string, item: any, emoji: string, label: string) => (
<div className={`equipment-slot ${item ? 'filled' : 'empty'}`}>
{item ? (
<>
<button className="equipment-unequip-btn" onClick={() => onUnequipItem(slot)} title="Unequip"></button>
<button className="equipment-unequip-btn" onClick={() => onUnequipItem(slot)} title={t('game.unequip')}></button>
<div className="equipment-item-content">
{item.image_path ? (
<img
src={item.image_path}
alt={item.name}
src={getAssetPath(item.image_path)}
alt={getTranslatedText(item.name)}
className="equipment-emoji"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
@@ -56,52 +61,52 @@ function PlayerSidebar({
/>
) : null}
<span className={`equipment-emoji ${item.image_path ? 'hidden' : ''}`}>{item.emoji}</span>
<span className={`equipment-name ${item.tier ? `tier-${item.tier}` : ''}`}>{item.name}</span>
<span className={`equipment-name ${item.tier ? `tier-${item.tier}` : ''}`}>{getTranslatedText(item.name)}</span>
{item.durability && item.durability !== null && (
<span className="equipment-durability">{item.durability}/{item.max_durability}</span>
)}
</div>
<div className="equipment-tooltip">
{item.description && <div className="item-tooltip-desc">{item.description}</div>}
{item.description && <div className="item-tooltip-desc">{getTranslatedText(item.description)}</div>}
{/* Use unique_stats if available, otherwise fall back to base stats */}
{(item.unique_stats || item.stats) && Object.keys(item.unique_stats || item.stats).length > 0 && (
<>
{(item.unique_stats?.armor || item.stats?.armor) && (
<div className="item-tooltip-stat">
🛡 Armor: +{item.unique_stats?.armor || item.stats?.armor}
{t('stats.armor')}: +{item.unique_stats?.armor || item.stats?.armor}
</div>
)}
{(item.unique_stats?.hp_max || item.stats?.hp_max) && (
<div className="item-tooltip-stat">
Max HP: +{item.unique_stats?.hp_max || item.stats?.hp_max}
{t('stats.hp')}: +{item.unique_stats?.hp_max || item.stats?.hp_max}
</div>
)}
{(item.unique_stats?.stamina_max || item.stats?.stamina_max) && (
<div className="item-tooltip-stat">
Max Stamina: +{item.unique_stats?.stamina_max || item.stats?.stamina_max}
{t('stats.stamina')}: +{item.unique_stats?.stamina_max || item.stats?.stamina_max}
</div>
)}
{(item.unique_stats?.damage_min !== undefined || item.stats?.damage_min !== undefined) &&
(item.unique_stats?.damage_max !== undefined || item.stats?.damage_max !== undefined) && (
<div className="item-tooltip-stat">
Damage: {item.unique_stats?.damage_min || item.stats?.damage_min}-{item.unique_stats?.damage_max || item.stats?.damage_max}
{t('stats.damage')}: {item.unique_stats?.damage_min || item.stats?.damage_min}-{item.unique_stats?.damage_max || item.stats?.damage_max}
</div>
)}
{(item.unique_stats?.weight_capacity || item.stats?.weight_capacity) && (
<div className="item-tooltip-stat">
Weight: +{item.unique_stats?.weight_capacity || item.stats?.weight_capacity}kg
{t('stats.weight')}: +{item.unique_stats?.weight_capacity || item.stats?.weight_capacity}kg
</div>
)}
{(item.unique_stats?.volume_capacity || item.stats?.volume_capacity) && (
<div className="item-tooltip-stat">
📦 Volume: +{item.unique_stats?.volume_capacity || item.stats?.volume_capacity}L
{t('stats.volume')}: +{item.unique_stats?.volume_capacity || item.stats?.volume_capacity}L
</div>
)}
</>
)}
{item.durability !== undefined && item.durability !== null && (
<div className="item-tooltip-stat">
🔧 Durability: {item.durability}/{item.max_durability}
{t('stats.durability')}: {item.durability}/{item.max_durability}
</div>
)}
{item.tier !== undefined && item.tier !== null && item.tier > 0 && (
@@ -126,12 +131,12 @@ function PlayerSidebar({
<div className={`right-sidebar mobile-menu-panel ${mobileMenuOpen === 'right' ? 'open' : ''}`}>
{/* Profile Stats */}
<div className="profile-sidebar">
<h3>👤 Character</h3>
<h3>{t('game.character')}</h3>
<div className="sidebar-stat-bars">
<div className="sidebar-stat-bar">
<div className="sidebar-stat-header">
<span className="sidebar-stat-label"> HP</span>
<span className="sidebar-stat-label">{t('stats.hp')}</span>
<span className="sidebar-stat-numbers">{playerState.health}/{playerState.max_health}</span>
</div>
<div className="sidebar-progress-bar">
@@ -145,7 +150,7 @@ function PlayerSidebar({
<div className="sidebar-stat-bar">
<div className="sidebar-stat-header">
<span className="sidebar-stat-label"> Stamina</span>
<span className="sidebar-stat-label">{t('stats.stamina')}</span>
<span className="sidebar-stat-numbers">{playerState.stamina}/{playerState.max_stamina}</span>
</div>
<div className="sidebar-progress-bar">
@@ -161,13 +166,13 @@ function PlayerSidebar({
{profile && (
<div className="sidebar-stats">
<div className="sidebar-stat-row">
<span className="sidebar-label">Level:</span>
<span className="sidebar-label">{t('stats.level')}:</span>
<span className="sidebar-value">{profile.level}</span>
</div>
<div className="sidebar-stat-bar">
<div className="sidebar-stat-header">
<span className="sidebar-stat-label"> XP</span>
<span className="sidebar-stat-label">{t('stats.xp')}</span>
<span className="sidebar-stat-numbers">{profile.xp} / {(profile.level * 100)}</span>
</div>
<div className="sidebar-progress-bar">
@@ -181,7 +186,7 @@ function PlayerSidebar({
{profile.unspent_points > 0 && (
<div className="sidebar-stat-row highlight">
<span className="sidebar-label"> Unspent:</span>
<span className="sidebar-label">{t('stats.unspentPoints')}:</span>
<span className="sidebar-value">{profile.unspent_points}</span>
</div>
)}
@@ -191,28 +196,28 @@ function PlayerSidebar({
{/* Compact 2x2 Stats Grid */}
<div className="stats-grid">
<div className="sidebar-stat-row compact">
<span className="sidebar-label">💪 STR:</span>
<span className="sidebar-label">{t('stats.strength')}:</span>
<span className="sidebar-value">{profile.strength}</span>
{profile.unspent_points > 0 && (
<button className="stat-plus-btn" onClick={() => onSpendPoint('strength')}>+</button>
)}
</div>
<div className="sidebar-stat-row compact">
<span className="sidebar-label">🏃 AGI:</span>
<span className="sidebar-label">{t('stats.agility')}:</span>
<span className="sidebar-value">{profile.agility}</span>
{profile.unspent_points > 0 && (
<button className="stat-plus-btn" onClick={() => onSpendPoint('agility')}>+</button>
)}
</div>
<div className="sidebar-stat-row compact">
<span className="sidebar-label">🛡 END:</span>
<span className="sidebar-label">{t('stats.endurance')}:</span>
<span className="sidebar-value">{profile.endurance}</span>
{profile.unspent_points > 0 && (
<button className="stat-plus-btn" onClick={() => onSpendPoint('endurance')}>+</button>
)}
</div>
<div className="sidebar-stat-row compact">
<span className="sidebar-label">🧠 INT:</span>
<span className="sidebar-label">{t('stats.intellect')}:</span>
<span className="sidebar-value">{profile.intellect}</span>
{profile.unspent_points > 0 && (
<button className="stat-plus-btn" onClick={() => onSpendPoint('intellect')}>+</button>
@@ -225,7 +230,7 @@ function PlayerSidebar({
{/* Inventory Capacity - matching HP/Stamina/XP style */}
<div className="sidebar-stat-bar">
<div className="sidebar-stat-header">
<span className="sidebar-stat-label"> Weight</span>
<span className="sidebar-stat-label">{t('stats.weight')}</span>
<span className="sidebar-stat-numbers">{(profile.current_weight || 0).toFixed(1)}/{profile.max_weight || 0}kg</span>
</div>
<div className="sidebar-progress-bar">
@@ -239,7 +244,7 @@ function PlayerSidebar({
<div className="sidebar-stat-bar">
<div className="sidebar-stat-header">
<span className="sidebar-stat-label">📦 Volume</span>
<span className="sidebar-stat-label">{t('stats.volume')}</span>
<span className="sidebar-stat-numbers">{(profile.current_volume || 0).toFixed(1)}/{profile.max_volume || 0}L</span>
</div>
<div className="sidebar-progress-bar">
@@ -271,7 +276,7 @@ function PlayerSidebar({
transition: 'all 0.2s'
}}
>
🎒 Open Inventory
{t('game.inventory')}
</button>
</div>
)}
@@ -279,24 +284,24 @@ function PlayerSidebar({
{/* Equipment Display - Proper Grid Layout */}
<div className="equipment-sidebar">
<h3> Equipment</h3>
<h3>{t('game.equipment')}</h3>
<div className="equipment-grid">
{/* Row 1: Head */}
<div className="equipment-row">
{renderEquipmentSlot('head', equipment.head, '🪖', 'Head')}
{renderEquipmentSlot('head', equipment.head, '🪖', t('equipment.head'))}
</div>
{/* Row 2: Weapon, Torso, Backpack */}
<div className="equipment-row three-cols">
{renderEquipmentSlot('weapon', equipment.weapon, '⚔️', 'Weapon')}
{renderEquipmentSlot('torso', equipment.torso, '👕', 'Torso')}
{renderEquipmentSlot('backpack', equipment.backpack, '🎒', 'Backpack')}
{renderEquipmentSlot('weapon', equipment.weapon, '⚔️', t('equipment.weapon'))}
{renderEquipmentSlot('torso', equipment.torso, '👕', t('equipment.torso'))}
{renderEquipmentSlot('backpack', equipment.backpack, '🎒', t('equipment.backpack'))}
</div>
{/* Row 3: Legs & Feet */}
<div className="equipment-row two-cols">
{renderEquipmentSlot('legs', equipment.legs, '👖', 'Legs')}
{renderEquipmentSlot('feet', equipment.feet, '👟', 'Feet')}
{renderEquipmentSlot('legs', equipment.legs, '👖', t('equipment.legs'))}
{renderEquipmentSlot('feet', equipment.feet, '👟', t('equipment.feet'))}
</div>
</div>
</div>

View File

@@ -1,5 +1,8 @@
import { useState, useEffect, type MouseEvent, type ChangeEvent } from 'react'
import { useTranslation } from 'react-i18next'
import type { Profile, WorkbenchTab } from './types'
import { getAssetPath } from '../../utils/assetPath'
import { getTranslatedText } from '../../utils/i18nUtils'
import './Workbench.css'
interface WorkbenchProps {
@@ -47,6 +50,8 @@ function Workbench({
onRepair,
onUncraft
}: WorkbenchProps) {
useTranslation()
const [selectedItem, setSelectedItem] = useState<any>(null)
// Reset selection when tab changes
@@ -84,12 +89,12 @@ function Workbench({
switch (workbenchTab) {
case 'craft':
return craftableItems.filter(item =>
item.name.toLowerCase().includes(craftFilter.toLowerCase()) &&
getTranslatedText(item.name).toLowerCase().includes(craftFilter.toLowerCase()) &&
(craftCategoryFilter === 'all' || item.category === craftCategoryFilter)
)
case 'repair':
return repairableItems
.filter(item => item.name.toLowerCase().includes(repairFilter.toLowerCase()))
.filter(item => getTranslatedText(item.name).toLowerCase().includes(repairFilter.toLowerCase()))
.sort((a, b) => {
if (a.needs_repair && !b.needs_repair) return -1
if (!a.needs_repair && b.needs_repair) return 1
@@ -97,7 +102,7 @@ function Workbench({
})
case 'uncraft':
return uncraftableItems.filter(item =>
item.name.toLowerCase().includes(uncraftFilter.toLowerCase())
getTranslatedText(item.name).toLowerCase().includes(uncraftFilter.toLowerCase())
)
default:
return []
@@ -118,7 +123,7 @@ function Workbench({
}
const item = selectedItem
const imagePath = item.image_path || `/images/items/${item.item_id || item.id}.webp`
const imagePath = getAssetPath(item.image_path || `images/items/${item.item_id || item.id}.webp`)
return (
<>
@@ -127,7 +132,7 @@ function Workbench({
{imagePath ? (
<img
src={imagePath}
alt={item.name}
alt={getTranslatedText(item.name)}
className="detail-image"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
@@ -140,14 +145,43 @@ function Workbench({
{item.emoji || '📦'}
</div>
</div>
<h2 className="detail-title">{item.emoji} {item.name}</h2>
{item.description && <p className="detail-description">{item.description}</p>}
<div className="item-detail-header">
<h2 className="detail-title">{item.emoji} {getTranslatedText(item.name)}</h2>
{item.description && <p className="detail-description">{getTranslatedText(item.description)}</p>}
{/* Base Stats Display for Crafting */}
{workbenchTab === 'craft' && (item.base_stats || item.stats) && (
<div style={{ marginTop: '1rem' }}>
<div className="item-stats" style={{ display: 'flex', gap: '1rem', justifyContent: 'center', flexWrap: 'wrap' }}>
{Object.entries(item.base_stats || item.stats).map(([key, value]) => {
{/* Base Stats Display for Crafting */}
{workbenchTab === 'craft' && (item.base_stats || item.stats) && (
<div style={{ marginTop: '1rem' }}>
<div className="item-stats" style={{ display: 'flex', gap: '1rem', justifyContent: 'center', flexWrap: 'wrap' }}>
{Object.entries(item.base_stats || item.stats).map(([key, value]) => {
const icons: Record<string, string> = {
weight_capacity: '⚖️ Weight',
volume_capacity: '📦 Volume',
armor: '🛡️ Armor',
hp_max: '❤️ Max HP',
stamina_max: '⚡ Max Stamina',
damage_min: '⚔️ Damage Min',
damage_max: '⚔️ Damage Max'
}
const label = icons[key] || key.replace('_', ' ')
const unit = key.includes('weight') ? 'kg' : key.includes('volume') ? 'L' : ''
return (
<div key={key} className="stat-badge" style={{ background: 'rgba(0,0,0,0.3)', padding: '0.3rem 0.6rem', borderRadius: '4px', fontSize: '0.9rem', color: '#ccc' }}>
<span style={{ color: '#aaa' }}>{label}:</span> <span style={{ color: '#fff', fontWeight: 'bold' }}>+{Math.round(Number(value))}{unit}</span>
</div>
)
})}
</div>
<p style={{ fontSize: '0.8rem', color: '#888', fontStyle: 'italic', marginTop: '0.5rem', textAlign: 'center' }}>
* Potential base stats. Actual stats may vary.
</p>
</div>
)}
{/* Stats Display for Repair/Salvage */}
{workbenchTab !== 'craft' && (item.unique_item_data?.unique_stats || item.unique_item_data || item.base_stats || item.stats) && (
<div className="item-stats" style={{ display: 'flex', gap: '1rem', justifyContent: 'center', marginTop: '1rem', flexWrap: 'wrap' }}>
{Object.entries(item.unique_item_data?.unique_stats ?? item.unique_item_data ?? item.base_stats ?? item.stats ?? {}).filter(([k]) => !['id', 'item_id', 'durability', 'max_durability', 'created_at', 'tier'].includes(k)).map(([key, value]) => {
const icons: Record<string, string> = {
weight_capacity: '⚖️ Weight',
volume_capacity: '📦 Volume',
@@ -166,257 +200,230 @@ function Workbench({
)
})}
</div>
<p style={{ fontSize: '0.8rem', color: '#888', fontStyle: 'italic', marginTop: '0.5rem', textAlign: 'center' }}>
* Potential base stats. Actual stats may vary.
</p>
</div>
)}
</div>
{workbenchTab === 'craft' && (
<>
<div className="detail-requirements">
<h4>📊 Requirements</h4>
{item.craft_level && item.craft_level > 1 && (
<div className={`requirement-item ${item.meets_level ? 'met' : 'missing'}`}>
<span>Level {item.craft_level} Required</span>
<span>{item.meets_level ? '✅' : `❌ (Lv. ${profile?.level || 1})`}</span>
</div>
)}
{item.tools && item.tools.length > 0 && (
<>
<h5 style={{ marginTop: '1rem', marginBottom: '0.5rem', color: '#aaa' }}>Tools</h5>
{item.tools.map((tool: any, i: number) => (
<div key={i} className={`requirement-item ${tool.has_tool ? 'met' : 'missing'}`}>
<span>{tool.emoji} {getTranslatedText(tool.name)}</span>
<span>
{tool.has_tool ? `${tool.tool_durability}/${tool.max_durability} (Cost: ${tool.durability_cost})` : `❌ Missing (Cost: ${tool.durability_cost})`}
</span>
</div>
))}
</>
)}
<h5 style={{ marginTop: '1rem', marginBottom: '0.5rem', color: '#aaa' }}>Materials</h5>
{item.materials && item.materials.length > 0 ? (
item.materials.map((mat: any, i: number) => (
<div key={i} className={`requirement-item ${mat.has_enough ? 'met' : 'missing'}`}>
<span>{mat.emoji} {getTranslatedText(mat.name)}</span>
<span>{mat.available} / {mat.required}</span>
</div>
))
) : (
<div className="requirement-item met">
<span>No materials required</span>
</div>
)}
</div>
<div className="detail-actions">
<button
className="craft-btn"
disabled={!item.can_craft || (profile?.stamina || 0) < (item.stamina_cost || 1)}
onClick={() => onCraft(item.item_id)}
style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
>
<span>
{!item.meets_level ? `Need Level ${item.craft_level}` :
!item.can_craft ? 'Missing Requirements' : '🔨 Craft Item'}
</span>
{item.can_craft && (
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}>
{item.stamina_cost || 5} Stamina
</span>
)}
</button>
</div>
</>
)}
{/* Stats Display for Repair/Salvage */}
{workbenchTab !== 'craft' && (item.unique_item_data?.unique_stats || item.unique_item_data || item.base_stats || item.stats) && (
<div className="item-stats" style={{ display: 'flex', gap: '1rem', justifyContent: 'center', marginTop: '1rem', flexWrap: 'wrap' }}>
{Object.entries(item.unique_item_data?.unique_stats ?? item.unique_item_data ?? item.base_stats ?? item.stats ?? {}).filter(([k]) => !['id', 'item_id', 'durability', 'max_durability', 'created_at', 'tier'].includes(k)).map(([key, value]) => {
const icons: Record<string, string> = {
weight_capacity: '⚖️ Weight',
volume_capacity: '📦 Volume',
armor: '🛡️ Armor',
hp_max: '❤️ Max HP',
stamina_max: '⚡ Max Stamina',
damage_min: '⚔️ Damage Min',
damage_max: '⚔️ Damage Max'
}
const label = icons[key] || key.replace('_', ' ')
const unit = key.includes('weight') ? 'kg' : key.includes('volume') ? 'L' : ''
return (
<div key={key} className="stat-badge" style={{ background: 'rgba(0,0,0,0.3)', padding: '0.3rem 0.6rem', borderRadius: '4px', fontSize: '0.9rem', color: '#ccc' }}>
<span style={{ color: '#aaa' }}>{label}:</span> <span style={{ color: '#fff', fontWeight: 'bold' }}>+{Math.round(Number(value))}{unit}</span>
{workbenchTab === 'repair' && (
<>
<div className="detail-requirements">
<h4>🔧 Repair Status</h4>
{!item.needs_repair ? (
<p className="repair-info full-durability" style={{ textAlign: 'center', marginBottom: '1rem' }}> Item is in perfect condition</p>
) : (
<>
<div className="repair-preview-text">
<span className="current">Current: {item.durability_percent}%</span>
<span className="restored">After Repair: {Math.min(100, item.durability_percent + (item.repair_percentage || 0))}%</span>
</div>
<div className="repair-preview-bar">
<div
className="repair-preview-current"
style={{ width: `${item.durability_percent}%` }}
></div>
<div
className="repair-preview-restored"
style={{
left: `${item.durability_percent}%`,
width: `${Math.min(100 - item.durability_percent, item.repair_percentage || 0)}%`
}}
></div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem', fontSize: '0.85rem', color: '#aaa' }}>
<span>{item.current_durability}/{item.max_durability}</span>
<span>+{Math.round((item.max_durability || 0) * ((item.repair_percentage || 0) / 100))} durability</span>
</div>
</>
)}
{item.needs_repair && (
<>
{item.tools && item.tools.length > 0 && (
<>
<h5 style={{ marginTop: '1rem', marginBottom: '0.5rem', color: '#aaa' }}>Tools</h5>
{item.tools.map((tool: any, i: number) => (
<div key={i} className={`requirement-item ${tool.has_tool ? 'met' : 'missing'}`}>
<span>{tool.emoji} {getTranslatedText(tool.name)}</span>
<span>
{tool.has_tool ? `${tool.tool_durability}/${tool.max_durability} (Cost: ${tool.durability_cost})` : `❌ Missing (Cost: ${tool.durability_cost})`}
</span>
</div>
))}
</>
)}
<h5 style={{ marginTop: '1rem', marginBottom: '0.5rem', color: '#aaa' }}>Materials</h5>
{item.materials.map((mat: any, i: number) => (
<div key={i} className={`requirement-item ${mat.has_enough ? 'met' : 'missing'}`}>
<span>{mat.emoji} {getTranslatedText(mat.name)}</span>
<span>{mat.available} / {mat.quantity}</span>
</div>
))}
</>
)}
</div>
<div className="detail-actions">
<button
className="repair-btn"
disabled={!item.can_repair || (profile?.stamina || 0) < (item.stamina_cost || 1)}
onClick={() => onRepair(item.unique_item_id, item.inventory_id)}
style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
>
<span>
{!item.needs_repair ? 'Already Full' :
!item.can_repair ? 'Missing Requirements' : '🛠️ Repair Item'}
</span>
{item.needs_repair && item.can_repair && (
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}>
{item.stamina_cost || 3} Stamina
</span>
)}
</button>
</div>
</>
)}
{workbenchTab === 'uncraft' && (
<>
<div className="detail-requirements">
<h4> Salvage Preview</h4>
{/* Show durability bar if we have durability data */}
{(item.unique_item_data || item.durability_percent !== undefined) && (
<div className="durability-display" style={{ marginBottom: '1rem' }}>
<div className="durability-bar" style={{ height: '8px' }}>
<div
className={`durability-fill ${(item.unique_item_data?.durability_percent || item.durability_percent) === 100 ? 'full' : ''}`}
style={{ width: `${item.unique_item_data?.durability_percent || item.durability_percent || 0}%` }}
></div>
</div>
<div style={{ textAlign: 'right', fontSize: '0.8rem', marginTop: '0.2rem', color: '#aaa' }}>
Condition: {item.unique_item_data?.durability_percent || item.durability_percent || 0}%
</div>
</div>
)
})}
</div>
)}
<div className="materials-list">
{(() => {
const durabilityRatio = item.unique_item_data?.durability_percent !== undefined
? (item.unique_item_data.durability_percent || 0) / 100
: item.durability_percent !== undefined
? (item.durability_percent || 0) / 100
: 1.0
const adjustedYield = (item.uncraft_yield || item.base_yield || []).map((mat: any) => ({
...mat,
adjusted_quantity: Math.round((mat.quantity || 0) * durabilityRatio)
}))
return (
<>
{durabilityRatio < 1.0 && (
<div className="uncraft-warning" style={{ marginBottom: '1rem', color: '#ffc107' }}>
Yield reduced by {Math.round((1 - durabilityRatio) * 100)}% due to damage
</div>
)}
{item.loss_chance && (
<div className="uncraft-warning" style={{ marginBottom: '1rem', color: '#ff9800' }}>
{Math.round(item.loss_chance * 100)}% chance to lose each material
</div>
)}
{adjustedYield.map((mat: any, i: number) => (
<div key={i} className="requirement-item met">
<span>{mat.emoji} {getTranslatedText(mat.name)}</span>
<span>x{mat.adjusted_quantity}</span>
</div>
))}
</>
)
})()}
</div>
</div>
<div className="detail-actions">
<button
className="uncraft-btn"
disabled={(profile?.stamina || 0) < (item.stamina_cost || 1)}
onClick={() => {
if (window.confirm(`Are you sure you want to salvage ${getTranslatedText(item.name)}? This cannot be undone.`)) {
onUncraft(item.unique_item_id, item.inventory_id)
}
}}
style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', backgroundColor: '#d32f2f', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
>
<span> Salvage Item</span>
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}>
{item.stamina_cost || 2} Stamina
</span>
</button>
</div>
</>
)}
</div>
{workbenchTab === 'craft' && (
<>
<div className="detail-requirements">
<h4>📊 Requirements</h4>
{item.craft_level && item.craft_level > 1 && (
<div className={`requirement-item ${item.meets_level ? 'met' : 'missing'}`}>
<span>Level {item.craft_level} Required</span>
<span>{item.meets_level ? '✅' : `❌ (Lv. ${profile?.level || 1})`}</span>
</div>
)}
{item.tools && item.tools.length > 0 && (
<>
<h5 style={{ marginTop: '1rem', marginBottom: '0.5rem', color: '#aaa' }}>Tools</h5>
{item.tools.map((tool: any, i: number) => (
<div key={i} className={`requirement-item ${tool.has_tool ? 'met' : 'missing'}`}>
<span>{tool.emoji} {tool.name}</span>
<span>
{tool.has_tool ? `${tool.tool_durability}/${tool.max_durability} (Cost: ${tool.durability_cost})` : `❌ Missing (Cost: ${tool.durability_cost})`}
</span>
</div>
))}
</>
)}
<h5 style={{ marginTop: '1rem', marginBottom: '0.5rem', color: '#aaa' }}>Materials</h5>
{item.materials && item.materials.length > 0 ? (
item.materials.map((mat: any, i: number) => (
<div key={i} className={`requirement-item ${mat.has_enough ? 'met' : 'missing'}`}>
<span>{mat.emoji} {mat.name}</span>
<span>{mat.available} / {mat.required}</span>
</div>
))
) : (
<div className="requirement-item met">
<span>No materials required</span>
</div>
)}
</div>
<div className="detail-actions">
<button
className="craft-btn"
disabled={!item.can_craft || (profile?.stamina || 0) < (item.stamina_cost || 1)}
onClick={() => onCraft(item.item_id)}
style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
>
<span>
{!item.meets_level ? `Need Level ${item.craft_level}` :
!item.can_craft ? 'Missing Requirements' : '🔨 Craft Item'}
</span>
{item.can_craft && (
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}>
{item.stamina_cost || 5} Stamina
</span>
)}
</button>
</div>
</>
)}
{workbenchTab === 'repair' && (
<>
<div className="detail-requirements">
<h4>🔧 Repair Status</h4>
{!item.needs_repair ? (
<p className="repair-info full-durability" style={{ textAlign: 'center', marginBottom: '1rem' }}> Item is in perfect condition</p>
) : (
<>
<div className="repair-preview-text">
<span className="current">Current: {item.durability_percent}%</span>
<span className="restored">After Repair: {Math.min(100, item.durability_percent + (item.repair_percentage || 0))}%</span>
</div>
<div className="repair-preview-bar">
<div
className="repair-preview-current"
style={{ width: `${item.durability_percent}%` }}
></div>
<div
className="repair-preview-restored"
style={{
left: `${item.durability_percent}%`,
width: `${Math.min(100 - item.durability_percent, item.repair_percentage || 0)}%`
}}
></div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem', fontSize: '0.85rem', color: '#aaa' }}>
<span>{item.current_durability}/{item.max_durability}</span>
<span>+{Math.round((item.max_durability || 0) * ((item.repair_percentage || 0) / 100))} durability</span>
</div>
</>
)}
{item.needs_repair && (
<>
{item.tools && item.tools.length > 0 && (
<>
<h5 style={{ marginTop: '1rem', marginBottom: '0.5rem', color: '#aaa' }}>Tools</h5>
{item.tools.map((tool: any, i: number) => (
<div key={i} className={`requirement-item ${tool.has_tool ? 'met' : 'missing'}`}>
<span>{tool.emoji} {tool.name}</span>
<span>
{tool.has_tool ? `${tool.tool_durability}/${tool.max_durability} (Cost: ${tool.durability_cost})` : `❌ Missing (Cost: ${tool.durability_cost})`}
</span>
</div>
))}
</>
)}
<h5 style={{ marginTop: '1rem', marginBottom: '0.5rem', color: '#aaa' }}>Materials</h5>
{item.materials.map((mat: any, i: number) => (
<div key={i} className={`requirement-item ${mat.has_enough ? 'met' : 'missing'}`}>
<span>{mat.emoji} {mat.name}</span>
<span>{mat.available} / {mat.quantity}</span>
</div>
))}
</>
)}
</div>
<div className="detail-actions">
<button
className="repair-btn"
disabled={!item.can_repair || (profile?.stamina || 0) < (item.stamina_cost || 1)}
onClick={() => onRepair(item.unique_item_id, item.inventory_id)}
style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
>
<span>
{!item.needs_repair ? 'Already Full' :
!item.can_repair ? 'Missing Requirements' : '🛠️ Repair Item'}
</span>
{item.needs_repair && item.can_repair && (
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}>
{item.stamina_cost || 3} Stamina
</span>
)}
</button>
</div>
</>
)}
{workbenchTab === 'uncraft' && (
<>
<div className="detail-requirements">
<h4> Salvage Preview</h4>
{/* Show durability bar if we have durability data */}
{(item.unique_item_data || item.durability_percent !== undefined) && (
<div className="durability-display" style={{ marginBottom: '1rem' }}>
<div className="durability-bar" style={{ height: '8px' }}>
<div
className={`durability-fill ${(item.unique_item_data?.durability_percent || item.durability_percent) === 100 ? 'full' : ''}`}
style={{ width: `${item.unique_item_data?.durability_percent || item.durability_percent || 0}%` }}
></div>
</div>
<div style={{ textAlign: 'right', fontSize: '0.8rem', marginTop: '0.2rem', color: '#aaa' }}>
Condition: {item.unique_item_data?.durability_percent || item.durability_percent || 0}%
</div>
</div>
)}
<div className="materials-list">
{(() => {
const durabilityRatio = item.unique_item_data?.durability_percent !== undefined
? (item.unique_item_data.durability_percent || 0) / 100
: item.durability_percent !== undefined
? (item.durability_percent || 0) / 100
: 1.0
const adjustedYield = (item.uncraft_yield || item.base_yield || []).map((mat: any) => ({
...mat,
adjusted_quantity: Math.round((mat.quantity || 0) * durabilityRatio)
}))
return (
<>
{durabilityRatio < 1.0 && (
<div className="uncraft-warning" style={{ marginBottom: '1rem', color: '#ffc107' }}>
Yield reduced by {Math.round((1 - durabilityRatio) * 100)}% due to damage
</div>
)}
{item.loss_chance && (
<div className="uncraft-warning" style={{ marginBottom: '1rem', color: '#ff9800' }}>
{Math.round(item.loss_chance * 100)}% chance to lose each material
</div>
)}
{adjustedYield.map((mat: any, i: number) => (
<div key={i} className="requirement-item met">
<span>{mat.emoji} {mat.name}</span>
<span>x{mat.adjusted_quantity}</span>
</div>
))}
</>
)
})()}
</div>
</div>
<div className="detail-actions">
<button
className="uncraft-btn"
disabled={(profile?.stamina || 0) < (item.stamina_cost || 1)}
onClick={() => {
if (window.confirm(`Are you sure you want to salvage ${item.name}? This cannot be undone.`)) {
onUncraft(item.unique_item_id, item.inventory_id)
}
}}
style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', backgroundColor: '#d32f2f', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
>
<span> Salvage Item</span>
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}>
{item.stamina_cost || 2} Stamina
</span>
</button>
</div>
</>
)}
</>
)
}
@@ -500,7 +507,7 @@ function Workbench({
{items.filter(item => {
// Text search filter
const searchFilter = workbenchTab === 'craft' ? craftFilter : workbenchTab === 'repair' ? repairFilter : uncraftFilter
const matchesSearch = !searchFilter || item.name.toLowerCase().includes(searchFilter.toLowerCase())
const matchesSearch = !searchFilter || getTranslatedText(item.name).toLowerCase().includes(searchFilter.toLowerCase())
// Category filter (apply to all tabs)
let matchesCategory = true
@@ -521,7 +528,7 @@ function Workbench({
.filter(item => {
// Text search filter
const searchFilter = workbenchTab === 'craft' ? craftFilter : workbenchTab === 'repair' ? repairFilter : uncraftFilter
const matchesSearch = !searchFilter || item.name.toLowerCase().includes(searchFilter.toLowerCase())
const matchesSearch = !searchFilter || getTranslatedText(item.name).toLowerCase().includes(searchFilter.toLowerCase())
// Category filter (apply to all tabs)
let matchesCategory = true
@@ -533,7 +540,7 @@ function Workbench({
return matchesSearch && matchesCategory
})
.map((item: any, idx: number) => {
const imagePath = item.image_path || `/images/items/${item.item_id || item.id}.webp`
const imagePath = getAssetPath(item.image_path || `images/items/${item.item_id || item.id}.webp`)
return (
<div
key={item.unique_item_id || item.item_id || idx}
@@ -545,7 +552,7 @@ function Workbench({
{imagePath ? (
<img
src={imagePath}
alt={item.name}
alt={getTranslatedText(item.name)}
className="item-thumb-img"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
@@ -564,7 +571,7 @@ function Workbench({
<span
className={`item-name ${(workbenchTab === 'repair' || workbenchTab === 'uncraft') && item.tier ? `text-tier-${item.tier}` : ''}`}
>
{item.name}
{getTranslatedText(item.name)}
</span>
{item.location === 'equipped' && <span className="item-card-equipped" style={{ marginLeft: '0.5rem' }}>Equipped</span>}
</div>

View File

@@ -16,13 +16,13 @@ export interface DirectionDetail {
stamina_cost: number
distance: number
destination: string
destination_name?: string
destination_name?: string | { [key: string]: string }
}
export interface Location {
id: string
name: string
description: string
name: string | { [key: string]: string }
description: string | { [key: string]: string }
directions: string[]
directions_detailed?: DirectionDetail[]
danger_level?: number

29
pwa/src/i18n/index.ts Normal file
View File

@@ -0,0 +1,29 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import en from './locales/en.json'
import es from './locales/es.json'
const resources = {
en: { translation: en },
es: { translation: es }
}
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: 'en',
supportedLngs: ['en', 'es'],
interpolation: {
escapeValue: false // React already escapes
},
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage']
}
})
export default i18n

View File

@@ -0,0 +1,136 @@
{
"common": {
"loading": "Loading...",
"error": "Error",
"save": "Save",
"cancel": "Cancel",
"confirm": "Confirm",
"close": "Close",
"yes": "Yes",
"no": "No",
"game": "Game",
"leaderboards": "Leaderboards",
"account": "Account"
},
"auth": {
"login": "Login",
"logout": "Logout",
"register": "Register",
"username": "Username",
"password": "Password",
"email": "Email",
"forgotPassword": "Forgot Password?",
"createAccount": "Create Account",
"alreadyHaveAccount": "Already have an account?",
"dontHaveAccount": "Don't have an account?"
},
"game": {
"travel": "🧭 Travel",
"surroundings": "🌿 Surroundings",
"character": "👤 Character",
"equipment": "⚔️ Equipment",
"inventory": "🎒 Open Inventory",
"workbench": "🔧 Workbench",
"craft": "🔨 Craft",
"repair": "🛠️ Repair",
"salvage": "♻️ Salvage",
"pickUp": "Pick Up",
"drop": "Drop",
"dropAll": "All",
"use": "Use",
"equip": "Equip",
"unequip": "Unequip",
"attack": "Attack",
"flee": "Flee",
"rest": "Rest",
"onlineCount": "{{count}} Online"
},
"stats": {
"hp": "❤️ HP",
"maxHp": "❤️ Max HP",
"stamina": "⚡ Stamina",
"maxStamina": "⚡ Max Stamina",
"xp": "⭐ XP",
"level": "Level",
"unspentPoints": "⭐ Unspent",
"weight": "⚖️ Weight",
"volume": "📦 Volume",
"strength": "💪 STR",
"strengthFull": "Strength",
"strengthDesc": "Increases melee damage and carry capacity",
"agility": "🏃 AGI",
"agilityFull": "Agility",
"agilityDesc": "Improves dodge chance and critical hits",
"endurance": "🛡️ END",
"enduranceFull": "Endurance",
"enduranceDesc": "Increases HP and stamina",
"intellect": "🧠 INT",
"intellectFull": "Intellect",
"intellectDesc": "Enhances crafting and resource gathering",
"armor": "🛡️ Armor",
"damage": "⚔️ Damage",
"durability": "Durability"
},
"combat": {
"inCombat": "In Combat",
"yourTurn": "Your Turn",
"enemyTurn": "Enemy's Turn",
"victory": "Victory!",
"defeat": "Defeat",
"youDied": "You Died",
"respawn": "Respawn",
"fleeSuccess": "You escaped!",
"fleeFailed": "Failed to escape!"
},
"equipment": {
"head": "Head",
"torso": "Torso",
"legs": "Legs",
"feet": "Feet",
"weapon": "Weapon",
"backpack": "Backpack",
"noBackpack": "No Backpack Equipped",
"equipped": "Equipped"
},
"crafting": {
"requirements": "📊 Requirements",
"materials": "Materials",
"tools": "Tools",
"levelRequired": "Level {{level}} Required",
"missingRequirements": "Missing Requirements",
"craftItem": "🔨 Craft Item",
"repairItem": "🛠️ Repair Item",
"salvageItem": "♻️ Salvage Item",
"staminaCost": "⚡ {{cost}} Stamina",
"alreadyFull": "Already Full",
"perfectCondition": "✅ Item is in perfect condition",
"yieldReduced": "⚠️ Yield reduced by {{percent}}% due to damage"
},
"categories": {
"all": "All Items",
"weapon": "Weapons",
"armor": "Armor",
"clothing": "Clothing",
"backpack": "Backpacks",
"tool": "Tools",
"consumable": "Consumables",
"resource": "Resources",
"quest": "Quest",
"misc": "Misc"
},
"messages": {
"notEnoughStamina": "Not enough stamina",
"inventoryFull": "Inventory full",
"itemDropped": "Item dropped",
"itemPickedUp": "Item picked up",
"waitBeforeMoving": "Wait {{seconds}}s before moving",
"cannotTravelInCombat": "Cannot travel during combat",
"cannotInteractInCombat": "Cannot interact during combat"
},
"landing": {
"heroTitle": "Echoes of the Ash",
"heroSubtitle": "A post-apocalyptic survival RPG",
"playNow": "Play Now",
"features": "Features"
}
}

View File

@@ -0,0 +1,136 @@
{
"common": {
"loading": "Cargando...",
"error": "Error",
"save": "Guardar",
"cancel": "Cancelar",
"confirm": "Confirmar",
"close": "Cerrar",
"yes": "Sí",
"no": "No",
"game": "Juego",
"leaderboards": "Clasificación",
"account": "Cuenta"
},
"auth": {
"login": "Iniciar Sesión",
"logout": "Cerrar Sesión",
"register": "Registrarse",
"username": "Usuario",
"password": "Contraseña",
"email": "Correo",
"forgotPassword": "¿Olvidaste tu contraseña?",
"createAccount": "Crear Cuenta",
"alreadyHaveAccount": "¿Ya tienes una cuenta?",
"dontHaveAccount": "¿No tienes cuenta?"
},
"game": {
"travel": "🧭 Viajar",
"surroundings": "🌿 Alrededores",
"character": "👤 Personaje",
"equipment": "⚔️ Equipamiento",
"inventory": "🎒 Abrir Inventario",
"workbench": "🔧 Banco de Trabajo",
"craft": "🔨 Fabricar",
"repair": "🛠️ Reparar",
"salvage": "♻️ Desmontar",
"pickUp": "Recoger",
"drop": "Soltar",
"dropAll": "Todo",
"use": "Usar",
"equip": "Equipar",
"unequip": "Desequipar",
"attack": "Atacar",
"flee": "Huir",
"rest": "Descansar",
"onlineCount": "{{count}} En línea"
},
"stats": {
"hp": "❤️ Vida",
"maxHp": "❤️ Vida Máx.",
"stamina": "⚡ Aguante",
"maxStamina": "⚡ Aguante Máx.",
"xp": "⭐ XP",
"level": "Nivel",
"unspentPoints": "⭐ Sin gastar",
"weight": "⚖️ Peso",
"volume": "📦 Volumen",
"strength": "💪 FUE",
"strengthFull": "Fuerza",
"strengthDesc": "Aumenta el daño cuerpo a cuerpo y capacidad de carga",
"agility": "🏃 AGI",
"agilityFull": "Agilidad",
"agilityDesc": "Mejora la esquiva y golpes críticos",
"endurance": "🛡️ RES",
"enduranceFull": "Resistencia",
"enduranceDesc": "Aumenta la vida y energía",
"intellect": "🧠 INT",
"intellectFull": "Intelecto",
"intellectDesc": "Mejora la fabricación y recolección",
"armor": "🛡️ Armadura",
"damage": "⚔️ Daño",
"durability": "Durabilidad"
},
"combat": {
"inCombat": "En Combate",
"yourTurn": "Tu Turno",
"enemyTurn": "Turno del Enemigo",
"victory": "¡Victoria!",
"defeat": "Derrota",
"youDied": "Has Muerto",
"respawn": "Revivir",
"fleeSuccess": "¡Escapaste!",
"fleeFailed": "¡No pudiste escapar!"
},
"equipment": {
"head": "Cabeza",
"torso": "Torso",
"legs": "Piernas",
"feet": "Pies",
"weapon": "Arma",
"backpack": "Mochila",
"noBackpack": "Sin Mochila Equipada",
"equipped": "Equipado"
},
"crafting": {
"requirements": "📊 Requisitos",
"materials": "Materiales",
"tools": "Herramientas",
"levelRequired": "Nivel {{level}} Requerido",
"missingRequirements": "Faltan Requisitos",
"craftItem": "🔨 Fabricar",
"repairItem": "🛠️ Reparar",
"salvageItem": "♻️ Desmontar",
"staminaCost": "⚡ {{cost}} Energía",
"alreadyFull": "Ya está Completo",
"perfectCondition": "✅ El objeto está en perfecto estado",
"yieldReduced": "⚠️ Rendimiento reducido {{percent}}% por daño"
},
"categories": {
"all": "Todos",
"weapon": "Armas",
"armor": "Armadura",
"clothing": "Ropa",
"backpack": "Mochilas",
"tool": "Herramientas",
"consumable": "Consumibles",
"resource": "Recursos",
"quest": "Misión",
"misc": "Varios"
},
"messages": {
"notEnoughStamina": "No tienes suficiente energía",
"inventoryFull": "Inventario lleno",
"itemDropped": "Objeto soltado",
"itemPickedUp": "Objeto recogido",
"waitBeforeMoving": "Espera {{seconds}}s antes de moverte",
"cannotTravelInCombat": "No puedes viajar en combate",
"cannotInteractInCombat": "No puedes interactuar en combate"
},
"landing": {
"heroTitle": "Ecos de la Ceniza",
"heroSubtitle": "Un RPG de supervivencia post-apocalíptico",
"playNow": "Jugar Ahora",
"features": "Características"
}
}

View File

@@ -2,6 +2,7 @@ import React, { useEffect } from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
import './i18n' // Initialize i18n
import { registerSW } from 'virtual:pwa-register'
import twemoji from 'twemoji'

View File

@@ -0,0 +1,41 @@
/**
* Asset Path Utility
*
* Resolves asset paths based on runtime environment:
* - Electron: Returns local path (assets bundled with app)
* - Browser: Returns full server URL
*/
// Detect if running in Electron
const isElectron = !!(window as any).electronAPI?.isElectron
// Base URL for remote assets (browser mode)
const ASSET_BASE_URL = import.meta.env.VITE_ASSET_URL ||
(import.meta.env.PROD ? 'https://api-staging.echoesoftheash.com' : '')
/**
* Resolves an asset path for the current environment
* @param path - The asset path (e.g., "images/items/knife.webp" or "/images/items/knife.webp")
* @returns The resolved path for the current environment
*/
export function getAssetPath(path: string): string {
if (!path) return ''
// Normalize path (ensure leading slash)
const normalizedPath = path.startsWith('/') ? path : `/${path}`
if (isElectron) {
// In Electron, assets are served relative to the app
return normalizedPath
}
// In browser, prepend the server URL
return `${ASSET_BASE_URL}${normalizedPath}`
}
/**
* Check if we're running in Electron
*/
export function isElectronApp(): boolean {
return isElectron
}

View File

@@ -0,0 +1,31 @@
import i18n from '../i18n'
export type I18nString = string | { [key: string]: string }
/**
* Safely extracts the translated string from a value that could be a string or an I18nString object.
* @param value The value to translate (string or object with language keys)
* @returns The translated string for the current language, or fallback to English/first available
*/
export const getTranslatedText = (value: I18nString | undefined | null): string => {
if (!value) return ''
// If it's already a string, return it
if (typeof value === 'string') return value
// If it's an object, try to get the current language
const currentLang = i18n.language || 'en'
// 1. Try current language
if (value[currentLang]) return value[currentLang]
// 2. Try English fallback
if (value['en']) return value['en']
// 3. Return the first available key
const firstKey = Object.keys(value)[0]
if (firstKey) return value[firstKey]
// 4. Fallback empty
return ''
}