Files
echoes-of-the-ash/pwa/src/components/game/Workbench.tsx
2026-02-06 11:45:25 +01:00

648 lines
30 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
showCraftingMenu: boolean
showRepairMenu: boolean
workbenchTab: WorkbenchTab
craftableItems: any[]
repairableItems: any[]
uncraftableItems: any[]
craftFilter: string
repairFilter: string
uncraftFilter: string
craftCategoryFilter: string
profile: Profile | null
onCloseCrafting: () => void
onSwitchTab: (tab: WorkbenchTab) => void
onSetCraftFilter: (filter: string) => void
onSetRepairFilter: (filter: string) => void
onSetUncraftFilter: (filter: string) => void
onSetCraftCategoryFilter: (category: string) => void
onCraft: (itemId: number) => void
onRepair: (uniqueItemId: string, inventoryId: number) => void
onUncraft: (uniqueItemId: string, inventoryId: number) => void
}
function Workbench({
showCraftingMenu,
showRepairMenu,
workbenchTab,
craftableItems,
repairableItems,
uncraftableItems,
craftFilter,
repairFilter,
uncraftFilter,
craftCategoryFilter,
profile,
onCloseCrafting,
onSwitchTab,
onSetCraftFilter,
onSetRepairFilter,
onSetUncraftFilter,
onSetCraftCategoryFilter,
onCraft,
onRepair,
onUncraft
}: WorkbenchProps) {
const { t } = useTranslation()
const [selectedItem, setSelectedItem] = useState<any>(null)
// Reset selection when tab changes
useEffect(() => {
setSelectedItem(null)
}, [workbenchTab])
// Update selectedItem when items list changes (after repair/craft/salvage)
useEffect(() => {
if (selectedItem) {
const items = getItems()
// Find the updated item by unique_item_id or inventory_id
const updatedItem = items.find(item => {
if (selectedItem.unique_item_id && item.unique_item_id) {
return item.unique_item_id === selectedItem.unique_item_id
}
if (selectedItem.inventory_id && item.inventory_id) {
return item.inventory_id === selectedItem.inventory_id
}
return item.item_id === selectedItem.item_id
})
if (updatedItem) {
setSelectedItem(updatedItem)
} else {
// Item no longer exists (e.g., was salvaged)
setSelectedItem(null)
}
}
}, [craftableItems, repairableItems, uncraftableItems])
if (!showCraftingMenu && !showRepairMenu) return null
const getItems = () => {
switch (workbenchTab) {
case 'craft':
return craftableItems.filter(item =>
getTranslatedText(item.name).toLowerCase().includes(craftFilter.toLowerCase()) &&
(craftCategoryFilter === 'all' || item.category === craftCategoryFilter)
)
case 'repair':
return repairableItems
.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
return 0
})
case 'uncraft':
return uncraftableItems.filter(item =>
getTranslatedText(item.name).toLowerCase().includes(uncraftFilter.toLowerCase())
)
default:
return []
}
}
const items = getItems()
const renderItemDetails = () => {
if (!selectedItem) {
return (
<div className="workbench-empty-state">
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>🔧</div>
<h3>{t('crafting.selectItem')}</h3>
<p>{t('crafting.chooseFromList')}</p>
</div>
)
}
const item = selectedItem
const imagePath = getAssetPath(item.image_path || `images/items/${item.item_id || item.id}.webp`)
return (
<>
<div className="detail-header">
<div className="detail-image-container">
{imagePath ? (
<img
src={imagePath}
alt={getTranslatedText(item.name)}
className="detail-image"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
const icon = (e.target as HTMLImageElement).nextElementSibling;
if (icon) icon.classList.remove('hidden');
}}
/>
) : null}
<div className={`detail-image-fallback ${imagePath ? 'hidden' : ''}`} style={{ fontSize: '4rem' }}>
{item.emoji || '📦'}
</div>
</div>
<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]) => {
const icons: Record<string, string> = {
weight_capacity: `⚖️ ${t('game.weight')}`,
volume_capacity: `📦 ${t('game.volume')}`,
armor: `🛡️ ${t('stats.armor')}`,
hp_max: `❤️ ${t('stats.maxHp')}`,
stamina_max: `${t('stats.maxStamina')}`,
damage_min: `⚔️ ${t('stats.damage')} Min`,
damage_max: `⚔️ ${t('stats.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' }}>
* {t('crafting.potentialBaseStats')}
</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: `⚖️ ${t('game.weight')}`,
volume_capacity: `📦 ${t('game.volume')}`,
armor: `🛡️ ${t('stats.armor')}`,
hp_max: `❤️ ${t('stats.maxHp')}`,
stamina_max: `${t('stats.maxStamina')}`,
damage_min: `⚔️ ${t('stats.damage')} Min`,
damage_max: `⚔️ ${t('stats.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>
)}
</div>
{workbenchTab === 'craft' && (
<>
<div className="detail-requirements">
<h4>{t('crafting.requirements')}</h4>
<div className={`requirement-item ${item.meets_level ? 'met' : 'missing'}`}>
<span>{t('crafting.levelRequired', { level: item.craft_level })}</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' }}>{t('crafting.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} (${t('crafting.cost')}: ${tool.durability_cost})` : `${t('crafting.missing')} (${t('crafting.cost')}: ${tool.durability_cost})`}
</span>
</div>
))}
</>
)}
<h5 style={{ marginTop: '1rem', marginBottom: '0.5rem', color: '#aaa' }}>{t('crafting.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>{t('crafting.noMaterialsRequired')}</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 ? t('crafting.levelRequired', { level: item.craft_level }) :
!item.can_craft ? t('crafting.missingRequirements') : t('crafting.craftItem')}
</span>
{item.can_craft && (
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}>
{t('crafting.staminaCost', { cost: item.stamina_cost || 5 })}
</span>
)}
</button>
</div>
</>
)}
{workbenchTab === 'repair' && (
<>
<div className="detail-requirements">
<h4>🔧 {workbenchTab === 'repair' ? t('game.repair') : t('game.salvage')}</h4>
{!item.needs_repair ? (
<p className="repair-info full-durability" style={{ textAlign: 'center', marginBottom: '1rem' }}>{t('crafting.perfectCondition')}</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.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 ? t('crafting.alreadyFull') :
!item.can_repair ? t('crafting.missingRequirements') : t('crafting.repairItem')}
</span>
{item.needs_repair && item.can_repair && (
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}>
{t('crafting.staminaCost', { cost: item.stamina_cost || 3 })}
</span>
)}
</button>
</div>
</>
)}
{workbenchTab === 'uncraft' && (
<>
<div className="detail-requirements">
<h4> {t('game.salvage')}</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' }}>
{t('crafting.yieldReduced', { percent: Math.round((1 - durabilityRatio) * 100) })}
</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(t('crafting.confirmSalvage', { name: getTranslatedText(item.name) }))) {
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> {t('game.salvage')}</span>
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}>
{t('crafting.staminaCost', { cost: item.stamina_cost || 2 })}
</span>
</button>
</div>
</>
)}
</div>
</>
)
}
const categories = [
{ id: 'all', label: t('categories.all'), icon: '🎒' },
{ id: 'weapon', label: t('categories.weapon'), icon: '⚔️' },
{ id: 'armor', label: t('categories.armor'), icon: '🛡️' },
{ id: 'clothing', label: t('categories.clothing'), icon: '👕' },
{ id: 'tool', label: t('categories.tool'), icon: '🛠️' },
{ id: 'consumable', label: t('categories.consumable'), icon: '🍖' },
{ id: 'resource', label: t('categories.resource'), icon: '📦' },
{ id: 'misc', label: t('categories.misc'), icon: '📦' }
]
return (
<div className="workbench-overlay" onClick={(e: MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) onCloseCrafting()
}}>
<div className="workbench-menu">
<div className="workbench-header">
<h3>{t('game.workbench')}</h3>
<div className="workbench-tabs">
<button
className={`tab-btn ${workbenchTab === 'craft' ? 'active' : ''}`}
onClick={() => onSwitchTab('craft')}
>
{t('game.craft')}
</button>
<button
className={`tab-btn ${workbenchTab === 'repair' ? 'active' : ''}`}
onClick={() => onSwitchTab('repair')}
>
{t('game.repair')}
</button>
<button
className={`tab-btn ${workbenchTab === 'uncraft' ? 'active' : ''}`}
onClick={() => onSwitchTab('uncraft')}
>
{t('game.salvage')}
</button>
</div>
<button className="close-btn" onClick={onCloseCrafting}></button>
</div>
<div className="workbench-content-grid">
{/* Column 1: Categories Sidebar */}
<div className="workbench-sidebar">
<h4 className="sidebar-title">{t('location.lootableItems').replace(':', '')}</h4>
<div className="category-list">
{categories.map(cat => (
<button
key={cat.id}
className={`category-btn ${craftCategoryFilter === cat.id ? 'active' : ''}`}
onClick={() => onSetCraftCategoryFilter(cat.id)}
>
<span className="cat-icon">{cat.icon}</span>
<span className="cat-label">{cat.label}</span>
</button>
))}
</div>
</div>
{/* Column 2: Items List */}
<div className="workbench-items-column">
<div className="workbench-filters">
<div className="game-search-container">
<span className="game-search-icon">🔍</span>
<input
type="text"
placeholder={t('game.searchItems')}
value={workbenchTab === 'craft' ? craftFilter : workbenchTab === 'repair' ? repairFilter : uncraftFilter}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
if (workbenchTab === 'craft') onSetCraftFilter(e.target.value)
else if (workbenchTab === 'repair') onSetRepairFilter(e.target.value)
else onSetUncraftFilter(e.target.value)
}}
className="game-search-input"
/>
</div>
</div>
<div className="workbench-items-list">
{items.filter(item => {
// Text search filter
const searchFilter = workbenchTab === 'craft' ? craftFilter : workbenchTab === 'repair' ? repairFilter : uncraftFilter
const matchesSearch = !searchFilter || getTranslatedText(item.name).toLowerCase().includes(searchFilter.toLowerCase())
// Category filter (apply to all tabs)
let matchesCategory = true
if (craftCategoryFilter !== 'all') {
// Assuming item has a 'type' property that matches category IDs
matchesCategory = item.type === craftCategoryFilter
}
return matchesSearch && matchesCategory
}).length === 0 ? (
<div className="empty-state">
{t('game.noItemsFound')}
</div>
) : (
items
.filter(item => {
// Text search filter
const searchFilter = workbenchTab === 'craft' ? craftFilter : workbenchTab === 'repair' ? repairFilter : uncraftFilter
const matchesSearch = !searchFilter || getTranslatedText(item.name).toLowerCase().includes(searchFilter.toLowerCase())
// Category filter (apply to all tabs)
let matchesCategory = true
if (craftCategoryFilter !== 'all') {
// Assuming item has a 'type' property that matches category IDs
matchesCategory = item.type === craftCategoryFilter
}
return matchesSearch && matchesCategory
})
.map((item: any, idx: number) => {
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}
className={`workbench-item-card ${selectedItem === item ? 'selected' : ''} ${workbenchTab === 'craft' && item.can_craft ? 'craftable' : ''} ${workbenchTab === 'repair' && item.needs_repair ? 'repairable' : ''} ${workbenchTab === 'uncraft' && item.can_uncraft ? 'salvageable' : ''}`}
onClick={() => setSelectedItem(item)}
>
{/* Item Image/Icon */}
<div className="item-image-thumb">
{imagePath ? (
<img
src={imagePath}
alt={getTranslatedText(item.name)}
className="item-thumb-img"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
const icon = (e.target as HTMLImageElement).nextElementSibling;
if (icon) icon.classList.remove('hidden');
}}
/>
) : null}
<div className={`item-thumb-emoji ${imagePath ? 'hidden' : ''}`}>
{item.emoji || '📦'}
</div>
</div>
<div className="item-card-content">
<div className="item-header-row">
<span
className={`item-name ${(workbenchTab === 'repair' || workbenchTab === 'uncraft') && item.tier ? `text-tier-${item.tier}` : ''}`}
>
{getTranslatedText(item.name)}
</span>
{item.location === 'equipped' && <span className="item-card-equipped" style={{ marginLeft: '0.5rem' }}>{t('game.equipped')}</span>}
</div>
<div className="item-meta-row">
</div>
{/* Stats display for repair/salvage items */}
{(workbenchTab === 'repair' || workbenchTab === 'uncraft') && (() => {
const statsSource = item.unique_item_data?.unique_stats ?? item.unique_item_data ?? item.base_stats ?? item.stats ?? {};
const damage_min = statsSource.damage_min;
const damage_max = statsSource.damage_max;
const armor = statsSource.armor;
return (damage_min || armor) ? (
<div className="item-stats-mini">
{damage_min && (
<span className="stat-mini"> {damage_min}-{damage_max}</span>
)}
{armor && (
<span className="stat-mini">🛡 {armor}</span>
)}
</div>
) : null;
})()}
{/* Condition bar for Salvage tab */}
{workbenchTab === 'uncraft' && item.durability_percent !== undefined && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div className="mini-progress-bar" style={{ flex: 1 }}>
<div
className={`mini-progress-fill ${item.durability_percent < 30 ? 'critical' : item.durability_percent < 70 ? 'warning' : 'good'}`}
style={{ width: `${item.durability_percent}%` }}
></div>
</div>
{(item.current_durability !== undefined && item.current_durability !== null) && (
<span className="stat-mini durability" style={{ flexShrink: 0 }}>🔧 {item.current_durability}/{item.max_durability}</span>
)}
</div>
)}
{/* Progress Bar for Repair tab */}
{workbenchTab === 'repair' && item.durability_percent !== undefined && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div className="mini-progress-bar" style={{ flex: 1 }}>
<div
className={`mini-progress-fill ${item.durability_percent < 30 ? 'critical' : item.durability_percent < 70 ? 'warning' : 'good'}`}
style={{ width: `${item.durability_percent}%` }}
></div>
</div>
{(item.current_durability !== undefined && item.current_durability !== null) && (
<span className="stat-mini durability" style={{ flexShrink: 0 }}>🔧 {item.current_durability}/{item.max_durability}</span>
)}
</div>
)}
</div>
</div>
)
})
)}
</div>
</div>
{/* Column 3: Details */}
<div className="workbench-details-column">
{renderItemDetails()}
</div>
</div>
</div>
</div>
)
}
export default Workbench