Release v0.2.10: Update package-lock.json and CI config
This commit is contained in:
4
pwa/.gitignore
vendored
4
pwa/.gitignore
vendored
@@ -6,6 +6,10 @@ yarn.lock
|
||||
# Build output
|
||||
dist/
|
||||
build/
|
||||
dist-electron/
|
||||
|
||||
# Copied assets (generated at build time)
|
||||
public/images/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
29
pwa/electron/afterPack.cjs
Normal file
29
pwa/electron/afterPack.cjs
Normal 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...')
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
86
pwa/src/components/LanguageSelector.css
Normal file
86
pwa/src/components/LanguageSelector.css
Normal 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;
|
||||
}
|
||||
40
pwa/src/components/LanguageSelector.tsx
Normal file
40
pwa/src/components/LanguageSelector.tsx
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
29
pwa/src/i18n/index.ts
Normal 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
|
||||
136
pwa/src/i18n/locales/en.json
Normal file
136
pwa/src/i18n/locales/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
136
pwa/src/i18n/locales/es.json
Normal file
136
pwa/src/i18n/locales/es.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
|
||||
41
pwa/src/utils/assetPath.ts
Normal file
41
pwa/src/utils/assetPath.ts
Normal 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
|
||||
}
|
||||
31
pwa/src/utils/i18nUtils.ts
Normal file
31
pwa/src/utils/i18nUtils.ts
Normal 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 ''
|
||||
}
|
||||
Reference in New Issue
Block a user