Compare commits

...

2 Commits

Author SHA1 Message Date
Warren H fd6a1ce3c7 Android build v1.0.30 2026-06-19 22:10:14 -04:00
Warren H f8b98e6b23 Android build v1.0.29 2026-06-19 21:58:23 -04:00
7 changed files with 857 additions and 80 deletions
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 46
versionName "1.0.28"
versionCode 48
versionName "1.0.30"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+524 -8
View File
@@ -4395,6 +4395,10 @@ h2 {
outline-color: var(--gold);
}
.customize-tab-back {
display: none;
}
.embedded-screen .gear-summary,
.embedded-screen .talent-toolbar {
margin-top: 16px;
@@ -6851,6 +6855,109 @@ h2 {
outline-color: var(--gold);
}
.workshop-bottom-display {
gap: 8px;
}
.workshop-bottom-summary {
background: #1c1e25;
border: 2px solid #0a0b0e;
color: var(--muted);
font-size: 15px;
line-height: 1.15;
outline: 2px solid #41404a;
padding: 10px;
}
.workshop-bottom-grid {
display: grid;
flex: 1;
gap: 8px;
grid-template-columns: repeat(2, minmax(0, 1fr));
min-height: 0;
overflow: hidden;
}
.workshop-bottom-grid article {
align-items: center;
background: #1c1e25;
border: 2px solid #0a0b0e;
color: var(--ink);
display: grid;
gap: 8px;
grid-template-columns: 36px minmax(0, 1fr) auto;
min-height: 58px;
outline: 2px solid #41404a;
padding: 8px;
}
.workshop-bottom-grid article > span {
align-items: center;
background: #15161c;
color: var(--gold);
display: flex;
font-family: 'Press Start 2P', monospace;
height: 34px;
justify-content: center;
}
.workshop-bottom-grid strong,
.workshop-bottom-grid small {
display: block;
}
.workshop-bottom-grid strong {
font-family: 'Press Start 2P', monospace;
font-size: 7px;
line-height: 1.25;
}
.workshop-bottom-grid small,
.workshop-bottom-grid p {
color: var(--muted);
font-size: 12px;
line-height: 1.05;
margin-top: 3px;
}
.workshop-bottom-grid i {
color: var(--gold);
font-size: 12px;
font-style: normal;
text-align: right;
}
.equipment-action-strip,
.crafting-action-row {
align-items: center;
background: #1c1e25;
border: 2px solid #0a0b0e;
display: flex;
gap: 8px;
margin-top: 8px;
min-height: 42px;
outline: 2px solid #3e3d47;
padding: 7px;
}
.equipment-action-strip .comparison-delta {
display: grid;
gap: 4px;
grid-template-columns: repeat(2, minmax(0, 1fr));
min-width: 190px;
}
.equipment-action-strip > p {
color: var(--muted);
margin: 0;
}
.crafting-action-row {
justify-content: flex-end;
margin-top: 6px;
padding: 0;
}
@media (max-height: 620px) {
.game-shell.workshop-shell {
height: 100dvh;
@@ -6871,6 +6978,10 @@ h2 {
padding: 6px;
}
.workshop-shell .customize-heading {
display: none;
}
.workshop-shell .screen-heading {
padding-bottom: 3px;
}
@@ -6892,6 +7003,10 @@ h2 {
padding: 5px 8px;
}
.workshop-shell .customize-tab-back {
display: block;
}
.workshop-shell .customize-tabs,
.workshop-shell .equipment-tabs,
.workshop-shell .talent-page-tabs {
@@ -6907,6 +7022,11 @@ h2 {
padding: 5px 7px;
}
.workshop-shell .customize-tabs {
grid-template-columns: 70px repeat(4, minmax(0, 1fr));
margin-top: 0;
}
.workshop-shell .equipment-screen,
.workshop-shell .talent-screen {
gap: 0;
@@ -6965,6 +7085,23 @@ h2 {
padding: 5px;
}
.workshop-shell .item-comparison,
.workshop-shell .crafting-detail-panel {
display: none;
}
.workshop-shell .equipment-action-strip {
gap: 5px;
margin-top: 5px;
min-height: 36px;
padding: 5px;
}
.workshop-shell .equipment-action-strip .comparison-delta {
grid-template-columns: repeat(2, minmax(0, 1fr));
min-width: 140px;
}
.workshop-shell .comparison-arrow {
font-size: 11px;
padding: 0;
@@ -7020,7 +7157,7 @@ h2 {
.workshop-shell .equipment-layout {
gap: 6px;
grid-template-columns: minmax(0, 1.05fr) minmax(178px, 0.95fr);
grid-template-columns: minmax(0, 1.15fr) minmax(210px, 0.85fr);
margin-top: 5px;
}
@@ -7115,12 +7252,15 @@ h2 {
.workshop-shell .crafting-layout {
gap: 6px;
grid-template-columns: minmax(132px, 0.42fr) minmax(230px, 1fr) minmax(190px, 0.72fr);
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
margin-top: 6px;
}
.workshop-shell .crafting-filters {
display: grid;
gap: 6px;
grid-template-columns: minmax(0, 1fr) auto;
}
.workshop-shell .crafting-filter-grid,
@@ -7128,9 +7268,13 @@ h2 {
gap: 4px;
}
.workshop-shell .crafting-filter-grid {
grid-template-columns: repeat(10, minmax(0, 1fr));
}
.workshop-shell .crafting-filter-grid button {
min-height: 28px;
padding: 4px;
min-height: 32px;
padding: 4px 2px;
}
.workshop-shell .crafting-filter-grid strong {
@@ -7313,15 +7457,249 @@ h2 {
}
.workshop-shell .crafting-list > button {
grid-template-columns: 24px minmax(0, 1fr);
min-height: 34px;
min-height: 68px;
}
.workshop-shell .crafting-list > button i {
.workshop-shell .crafting-action-row {
background: transparent;
border: 0;
margin-top: 5px;
min-height: 28px;
outline: 0;
padding: 0;
}
.workshop-shell .crafting-action-row .primary-button {
font-size: 8px;
min-height: 28px;
padding: 4px 8px;
}
.workshop-shell .customize-layout {
gap: 8px;
grid-template-columns: 148px minmax(0, 1fr);
margin-top: 5px;
overflow: hidden;
}
.workshop-shell .class-picker {
padding-right: 6px;
}
.workshop-shell .class-picker > .eyebrow {
display: none;
}
.workshop-shell .crafting-list > button:nth-child(n+4) {
.workshop-shell .class-picker > button {
gap: 6px;
margin-bottom: 5px;
min-height: 44px;
padding: 5px;
}
.workshop-shell .class-picker > button > span {
flex-basis: 28px;
height: 28px;
}
.workshop-shell .class-picker strong {
font-size: 7px;
}
.workshop-shell .class-picker small {
font-size: 10px;
margin-top: 2px;
}
.workshop-shell .loadout-editor {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.workshop-shell .class-detail {
gap: 8px;
padding: 6px;
}
.workshop-shell .class-portrait {
flex-basis: 32px;
height: 32px;
}
.workshop-shell .class-detail h2 {
font-size: 9px;
}
.workshop-shell .class-detail p:last-child,
.workshop-shell .loadout-heading > span {
display: none;
}
.workshop-shell .loadout-heading,
.workshop-shell .ability-library-heading,
.workshop-shell .save-row {
margin-top: 6px;
}
.workshop-shell .ability-slots {
gap: 5px;
margin-top: 5px;
}
.workshop-shell .ability-slots button {
min-height: 40px;
padding: 5px 2px;
}
.workshop-shell .ability-slots span {
font-size: 12px;
margin-bottom: 4px;
}
.workshop-shell .ability-slots strong {
font-size: 9px;
line-height: 1.1;
}
.workshop-shell .ability-library {
flex: 1;
gap: 5px;
grid-template-columns: repeat(5, minmax(0, 1fr));
margin-top: 5px;
max-height: none;
min-height: 0;
overflow: hidden;
}
.workshop-shell .ability-library > button {
align-content: center;
gap: 5px;
grid-template-columns: 1fr;
min-height: 58px;
padding: 5px;
text-align: center;
}
.workshop-shell .ability-library > button > span {
height: 26px;
}
.workshop-shell .ability-library strong {
font-size: 8px;
line-height: 1.1;
}
.workshop-shell .ability-library small,
.workshop-shell .ability-library i {
font-size: 9px;
line-height: 1;
}
.workshop-shell .ability-library small {
display: none;
}
.workshop-shell .ability-library i {
font-size: 8px;
}
.workshop-shell .ability-library > button:nth-child(n+6) {
display: none;
}
.workshop-shell .save-row .primary-button {
font-size: 8px;
min-height: 28px;
padding: 4px 8px;
}
.workshop-shell .save-row > span {
font-size: 11px;
}
.workshop-bottom-display {
padding: 6px;
}
.workshop-bottom-grid {
gap: 6px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.workshop-bottom-grid article {
min-height: 50px;
padding: 6px;
}
}
@media (max-width: 700px) and (max-height: 620px) {
.workshop-shell .equipment-layout {
grid-template-columns: minmax(0, 1fr) minmax(190px, 0.7fr);
}
.workshop-shell .equipment-action-strip .comparison-delta {
display: none;
}
.workshop-shell .crafting-layout {
grid-template-columns: minmax(0, 1fr);
}
.workshop-shell .crafting-filters {
grid-template-columns: minmax(0, 1fr);
}
.workshop-shell .crafting-filter-grid {
grid-template-columns: repeat(10, minmax(0, 1fr));
}
.workshop-shell .crafting-filter-grid button {
min-height: 34px;
padding: 4px 2px;
}
.workshop-shell .crafting-filter-grid strong {
font-size: 5px;
}
.workshop-shell .crafting-filter-grid span {
font-size: 10px;
}
.workshop-shell .crafting-level-row {
flex-wrap: nowrap;
}
.workshop-shell .crafting-list > button {
display: grid;
min-height: 68px;
}
.workshop-shell .customize-layout {
grid-template-columns: 118px minmax(0, 1fr);
}
.workshop-shell .ability-library {
grid-template-columns: 1fr;
}
.workshop-shell .ability-library > button:nth-child(n+6) {
display: none;
}
.workshop-bottom-grid {
grid-template-columns: 1fr;
}
}
@media (max-height: 620px) {
.workshop-shell .crafting-list > button {
grid-template-columns: 24px minmax(0, 1fr);
}
.workshop-shell .crafting-list > button i {
display: none;
}
@@ -7341,3 +7719,141 @@ h2 {
grid-column: auto !important;
}
}
@media (max-height: 620px) {
.workshop-shell .customize-screen > .customize-layout {
gap: 8px;
grid-template-columns: 148px minmax(0, 1fr);
margin-top: 5px;
overflow: hidden;
}
.workshop-shell .class-picker {
padding-right: 6px;
}
.workshop-shell .class-picker > .eyebrow {
display: none;
}
.workshop-shell .class-picker > button {
gap: 6px;
margin-bottom: 5px;
min-height: 44px;
padding: 5px;
}
.workshop-shell .class-picker > button > span {
flex-basis: 28px;
height: 28px;
}
.workshop-shell .class-picker strong {
font-size: 7px;
}
.workshop-shell .class-picker small {
font-size: 10px;
margin-top: 2px;
}
.workshop-shell .loadout-editor {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.workshop-shell .class-detail {
gap: 8px;
padding: 6px;
}
.workshop-shell .class-detail p:last-child,
.workshop-shell .loadout-heading > span,
.workshop-shell .ability-library small {
display: none;
}
.workshop-shell .class-portrait {
flex-basis: 32px;
height: 32px;
}
.workshop-shell .class-detail h2 {
font-size: 9px;
}
.workshop-shell .loadout-heading,
.workshop-shell .ability-library-heading,
.workshop-shell .save-row {
margin-top: 6px;
}
.workshop-shell .ability-slots {
gap: 5px;
margin-top: 5px;
}
.workshop-shell .ability-slots button {
min-height: 40px;
padding: 5px 2px;
}
.workshop-shell .ability-slots span {
font-size: 12px;
margin-bottom: 4px;
}
.workshop-shell .ability-slots strong {
font-size: 9px;
line-height: 1.1;
}
.workshop-shell .ability-library {
flex: 1;
gap: 5px;
grid-template-columns: repeat(5, minmax(0, 1fr));
margin-top: 5px;
max-height: none;
min-height: 0;
overflow: hidden;
}
.workshop-shell .ability-library > button {
align-content: center;
gap: 5px;
grid-template-columns: 1fr;
min-height: 58px;
padding: 5px;
text-align: center;
}
.workshop-shell .ability-library > button > span {
height: 26px;
}
.workshop-shell .ability-library strong {
font-size: 8px;
line-height: 1.1;
}
.workshop-shell .ability-library i {
font-size: 8px;
line-height: 1;
}
.workshop-shell .ability-library > button:nth-child(n+6) {
display: none;
}
}
@media (max-width: 700px) and (max-height: 620px) {
.workshop-shell .customize-screen > .customize-layout {
grid-template-columns: 118px minmax(0, 1fr);
}
.workshop-shell .ability-library {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}
+41 -2
View File
@@ -4,6 +4,7 @@ import {
type CharacterProfile,
type GameClass,
} from '../profile'
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
import { EquipmentScreen } from './EquipmentScreen'
import { TalentScreen } from './TalentScreen'
@@ -14,7 +15,8 @@ type Props = {
}
export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
const [activeTab, setActiveTab] = useState<'equipment' | 'talents' | 'class'>('class')
const [activeTab, setActiveTab] = useState<'equipment' | 'crafting' | 'talents' | 'class'>('class')
const { enabled: dualScreenEnabled } = useDualScreen()
const [classId, setClassId] = useState(profile.character.classId)
const [slots, setSlots] = useState<Array<number | null>>(profile.abilitySlots)
const [selectedSlot, setSelectedSlot] = useState(0)
@@ -63,6 +65,29 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
)
}
const classWorkshopState = useMemo<DualScreenWorkshopState | null>(() => {
if (activeTab !== 'class') return null
return {
mode: 'class',
title: 'Ability Library',
subtitle: gameClass.name,
summary: `Selected slot ${selectedSlot + 1}. ${message || 'Choose an ability for the active loadout.'}`,
items: gameClass.spells.map((ability) => {
const locked = ability.unlockLevel > profile.character.level
const equipped = slots.includes(ability.id)
return {
glyph: locked ? 'L' : ability.glyph,
title: ability.name,
meta: locked ? `Level ${ability.unlockLevel}` : `${ability.cost} ${gameClass.resourceName}`,
detail: ability.description,
status: equipped ? 'Equipped' : locked ? 'Locked' : '',
}
}),
}
}, [activeTab, gameClass, message, profile.character.level, selectedSlot, slots])
useDualScreenWorkshopPublisher(classWorkshopState, dualScreenEnabled)
async function persistChanges() {
saveScroll()
setSaving(true)
@@ -80,7 +105,7 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
return (
<section className="content-screen customize-screen">
<div className="screen-heading">
<div className="screen-heading customize-heading">
<div>
<p className="eyebrow">Character Workshop</p>
<h1>Customize Character</h1>
@@ -89,8 +114,10 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
</div>
<div className="customize-tabs" role="tablist" aria-label="Customize character sections">
<button className="back-button customize-tab-back" onClick={onBack} type="button">Back</button>
{([
{ key: 'equipment', label: 'Equipment' },
{ key: 'crafting', label: 'Crafting' },
{ key: 'talents', label: 'Talents' },
{ key: 'class', label: 'Class' },
] as const).map((tab) => (
@@ -110,6 +137,18 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
{activeTab === 'equipment' && (
<EquipmentScreen
embedded
mode="equipment"
showModeTabs={false}
profile={profile}
onUpdated={onSaved}
/>
)}
{activeTab === 'crafting' && (
<EquipmentScreen
embedded
mode="crafting"
showModeTabs={false}
profile={profile}
onUpdated={onSaved}
/>
+191 -67
View File
@@ -9,6 +9,7 @@ import {
type EquipmentSlot,
type Item,
} from '../profile'
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
const SLOT_LABELS: Record<EquipmentSlot, string> = {
weapon: 'Weapon',
@@ -24,16 +25,28 @@ const SLOT_LABELS: Record<EquipmentSlot, string> = {
}
const EQUIPMENT_LIST_PAGE_SIZE = 3
const CRAFTING_LIST_PAGE_SIZE = 6
const CRAFTING_LIST_PAGE_SIZE = 3
const CRAFTING_FILTER_SLOTS = (Object.keys(SLOT_LABELS) as EquipmentSlot[])
.filter((slot) => slot !== 'component')
type Props = {
profile: CharacterProfile
onBack?: () => void
onUpdated: (profile: CharacterProfile) => void
embedded?: boolean
mode?: 'equipment' | 'crafting'
showModeTabs?: boolean
}
export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }: Props) {
export function EquipmentScreen({
profile,
onBack,
onUpdated,
embedded = false,
mode,
showModeTabs = true,
}: Props) {
const { enabled: dualScreenEnabled } = useDualScreen()
const totalItemCount = profile.inventory.reduce(
(total, item) => total + item.quantity,
0,
@@ -49,7 +62,7 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
const [crafting, setCrafting] = useState(false)
const [upgrading, setUpgrading] = useState(false)
const [showSetBonuses, setShowSetBonuses] = useState(false)
const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>('equipment')
const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>(mode ?? 'equipment')
const [inventoryPage, setInventoryPage] = useState(0)
const [recipePage, setRecipePage] = useState(0)
const [message, setMessage] = useState('')
@@ -173,6 +186,10 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
}
}, [equipmentTab])
useEffect(() => {
if (mode) setEquipmentTab(mode)
}, [mode])
function saveScroll() {
scrollRef.current = window.scrollY
}
@@ -247,6 +264,143 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
}
}
function renderEquipmentActions() {
if (!selectedItem) {
return <p>Select an item to inspect it.</p>
}
if (selectedItem.slot === 'component') {
return <p className="component-note">Used in crafting.</p>
}
return (
<>
<ComparisonDelta selected={selectedItem} equipped={comparisonItem} />
<button
className="primary-button"
disabled={selectedItem.equipped || equipping || breakingDown || upgrading}
onClick={equipSelected}
type="button"
>
{selectedItem.equipped ? 'Equipped' : equipping ? 'Equipping...' : 'Equip Item'}
</button>
{upgradeRecipe && (
<button
className="primary-button"
disabled={!upgradeRecipe.canCraft || equipping || breakingDown || upgrading}
onClick={upgradeSelected}
type="button"
>
{upgrading ? 'Upgrading...' : `Upgrade to iLvl ${upgradeRecipe.item.itemLevel}`}
</button>
)}
{(!selectedItem.equipped || selectedItem.quantity > 1) && (
<button
className="breakdown-button"
disabled={equipping || breakingDown || upgrading}
onClick={breakdownSelected}
type="button"
>
{breakingDown
? 'Breaking Down...'
: selectedItem.quantity > 1
? 'Break Down Duplicate'
: 'Break Down'}
</button>
)}
</>
)
}
const workshopState = useMemo<DualScreenWorkshopState>(() => {
if (equipmentTab === 'crafting') {
if (!selectedRecipe) {
return {
mode: 'crafting',
title: 'Craft Output',
subtitle: 'No recipe selected',
items: [],
}
}
return {
mode: 'crafting',
title: selectedRecipe.item.name,
subtitle: `${SLOT_LABELS[selectedRecipe.item.slot]} - Item Level ${selectedRecipe.item.itemLevel}`,
summary: selectedRecipe.item.description,
items: [
{
glyph: selectedRecipe.item.glyph,
title: 'Craft Output',
meta: `+${selectedRecipe.item.healingPower} Healing Power / +${selectedRecipe.item.maxResourceBonus} Max Resource`,
status: selectedRecipe.canCraft ? 'Ready' : 'Missing components',
},
...selectedRecipe.components.map((component) => ({
glyph: component.item.glyph,
title: component.item.name,
meta: `Item Level ${component.item.itemLevel}`,
status: `${component.owned}/${component.quantity}`,
})),
],
}
}
if (!selectedItem) {
return {
mode: 'equipment',
title: 'Equipment Detail',
subtitle: 'No item selected',
items: [],
}
}
return {
mode: 'equipment',
title: selectedItem.slot === 'component' ? 'Crafting Component' : selectedItem.name,
subtitle: `${SLOT_LABELS[selectedItem.slot]} - Item Level ${selectedItem.itemLevel}`,
summary: selectedItem.description,
items: selectedItem.slot === 'component'
? [{
glyph: selectedItem.glyph,
title: selectedItem.name,
meta: `Owned: ${selectedItem.quantity}`,
status: 'Component',
}]
: [
{
glyph: selectedItem.glyph,
title: selectedItem.name,
meta: `+${selectedItem.healingPower} Healing Power / +${selectedItem.maxResourceBonus} Max Resource`,
status: selectedItem.equipped ? 'Equipped' : 'Inventory',
},
...(comparisonItem && comparisonItem.id !== selectedItem.id
? [{
glyph: comparisonItem.glyph,
title: comparisonItem.name,
meta: `+${comparisonItem.healingPower} Healing Power / +${comparisonItem.maxResourceBonus} Max Resource`,
status: 'Currently Equipped',
}]
: [{
title: selectedItem.equipped ? 'Already Equipped' : 'Empty Slot',
status: 'Comparison',
}]),
...(upgradeRecipe
? [
{
glyph: upgradeRecipe.item.glyph,
title: `Upgrade to ${upgradeRecipe.item.name}`,
meta: `Item Level ${upgradeRecipe.item.itemLevel}`,
status: upgradeRecipe.canCraft ? 'Ready' : 'Missing materials',
},
...upgradeRecipe.components.map((component) => ({
glyph: component.item.glyph,
title: component.item.name,
meta: `Required for upgrade`,
status: `${component.owned}/${component.quantity}`,
})),
]
: []),
],
}
}, [comparisonItem, equipmentTab, selectedItem, selectedRecipe, upgradeRecipe])
useDualScreenWorkshopPublisher(workshopState, dualScreenEnabled)
const content = (
<>
{!embedded && (
@@ -273,22 +427,24 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
<GearStat value={`+${profile.gearStats.maxResourceBonus}`} label={`Max ${profile.character.resourceName}`} />
</div>
<nav className="equipment-tabs">
<button
className={`equipment-tab ${equipmentTab === 'equipment' ? 'active' : ''}`}
onClick={() => setEquipmentTab('equipment')}
type="button"
>
Equipment
</button>
<button
className={`equipment-tab ${equipmentTab === 'crafting' ? 'active' : ''}`}
onClick={() => setEquipmentTab('crafting')}
type="button"
>
Crafting
</button>
</nav>
{showModeTabs && (
<nav className="equipment-tabs">
<button
className={`equipment-tab ${equipmentTab === 'equipment' ? 'active' : ''}`}
onClick={() => setEquipmentTab('equipment')}
type="button"
>
Equipment
</button>
<button
className={`equipment-tab ${equipmentTab === 'crafting' ? 'active' : ''}`}
onClick={() => setEquipmentTab('crafting')}
type="button"
>
Crafting
</button>
</nav>
)}
{equipmentTab === 'equipment' ? (
<>
@@ -297,9 +453,6 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
selectedItem.slot === 'component' ? (
<>
<ItemDetail title="Crafting Component" item={selectedItem} />
<div className="equip-action">
<p className="component-note">Used in crafting.</p>
</div>
</>
) : (
<>
@@ -313,41 +466,6 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
<h2>{selectedItem.equipped ? 'Already Equipped' : 'Empty Slot'}</h2>
</div>
)}
<div className="equip-action">
<ComparisonDelta selected={selectedItem} equipped={comparisonItem} />
<button
className="primary-button"
disabled={selectedItem.equipped || equipping || breakingDown || upgrading}
onClick={equipSelected}
type="button"
>
{selectedItem.equipped ? 'Equipped' : equipping ? 'Equipping...' : 'Equip Item'}
</button>
{upgradeRecipe && (
<button
className="primary-button"
disabled={!upgradeRecipe.canCraft || equipping || breakingDown || upgrading}
onClick={upgradeSelected}
type="button"
>
{upgrading ? 'Upgrading...' : `Upgrade to iLvl ${upgradeRecipe.item.itemLevel}`}
</button>
)}
{(!selectedItem.equipped || selectedItem.quantity > 1) && (
<button
className="breakdown-button"
disabled={equipping || breakingDown || upgrading}
onClick={breakdownSelected}
type="button"
>
{breakingDown
? 'Breaking Down...'
: selectedItem.quantity > 1
? 'Break Down Duplicate'
: 'Break Down'}
</button>
)}
</div>
</>
)
) : (
@@ -355,6 +473,10 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
)}
</section>
<section className="equipment-action-strip">
{renderEquipmentActions()}
</section>
<div className="equipment-layout">
<section className="equipped-panel">
<EquipmentHeading eyebrow="Currently Worn" title="Equipment Slots" />
@@ -469,7 +591,7 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
<strong>All</strong>
<span>{profile.craftingRecipes.length}</span>
</button>
{(Object.entries(SLOT_LABELS) as [EquipmentSlot, string][]).map(([slot, label]) => (
{CRAFTING_FILTER_SLOTS.map((slot) => (
<button
className={slotFilter === slot ? 'active' : ''}
disabled={(slotRecipeCounts.get(slot) ?? 0) === 0}
@@ -480,7 +602,7 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
}}
type="button"
>
<strong>{label}</strong>
<strong>{SLOT_LABELS[slot]}</strong>
<span>{slotRecipeCounts.get(slot) ?? 0}</span>
</button>
))}
@@ -557,6 +679,16 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
previousDisabled={recipePage <= 0}
/>
)}
<div className="crafting-action-row">
<button
className="primary-button"
disabled={selectedRecipeRequiresUpgrade || !selectedRecipe?.canCraft || crafting}
onClick={craftSelected}
type="button"
>
{crafting ? 'Crafting...' : selectedRecipeRequiresUpgrade ? 'Upgrade Existing Item' : 'Craft Item'}
</button>
</div>
</section>
<section className="crafting-detail-panel">
@@ -579,14 +711,6 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
</div>
))}
</div>
<button
className="primary-button"
disabled={selectedRecipeRequiresUpgrade || !selectedRecipe.canCraft || crafting}
onClick={craftSelected}
type="button"
>
{crafting ? 'Crafting...' : selectedRecipeRequiresUpgrade ? 'Upgrade Existing Item' : 'Craft Item'}
</button>
</div>
) : (
<p className="inventory-empty">Select a recipe.</p>
+99 -1
View File
@@ -56,12 +56,28 @@ export type DualScreenCombatState = {
targetGroup: 0 | 1 | 2
}
export type DualScreenWorkshopState = {
mode: 'class' | 'equipment' | 'crafting' | 'talents'
title: string
subtitle: string
summary?: string
items: Array<{
glyph?: string
title: string
meta?: string
detail?: string
status?: string
}>
}
type DualScreenMessage =
| { type: 'combat-state'; state: DualScreenCombatState }
| { type: 'workshop-state'; state: DualScreenWorkshopState }
| { type: 'companion-ready' }
| { type: 'companion-heartbeat' }
| { type: 'control-action'; action: InputAction }
| { type: 'combat-ended' }
| { type: 'workshop-ended' }
type DualScreenContextValue = {
enabled: boolean
@@ -280,16 +296,64 @@ export function useDualScreenPublisher(
}, [enabled, state])
}
export function useDualScreenWorkshopPublisher(
state: DualScreenWorkshopState | null,
enabled: boolean,
) {
const stateRef = useRef(state)
useEffect(() => {
stateRef.current = state
}, [state])
useEffect(() => {
if (!enabled || !state) return
const channel = createChannel()
if (!channel) return
const publish = () => {
if (stateRef.current) {
channel.postMessage({
type: 'workshop-state',
state: stateRef.current,
} satisfies DualScreenMessage)
}
}
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === 'companion-ready') publish()
}
publish()
return () => {
channel.postMessage({ type: 'workshop-ended' } satisfies DualScreenMessage)
channel.close()
}
}, [enabled, state])
useEffect(() => {
if (!enabled || !state) return
const channel = createChannel()
channel?.postMessage({ type: 'workshop-state', state } satisfies DualScreenMessage)
channel?.close()
}, [enabled, state])
}
export function DualScreenBottomDisplay() {
const [state, setState] = useState<DualScreenCombatState | null>(loadRecentSnapshot)
const [workshopState, setWorkshopState] = useState<DualScreenWorkshopState | null>(null)
useEffect(() => {
const channel = createChannel()
if (!channel) return
const announce = () => channel.postMessage({ type: 'companion-ready' } satisfies DualScreenMessage)
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === 'combat-state') setState(event.data.state)
if (event.data.type === 'combat-state') {
setState(event.data.state)
setWorkshopState(null)
}
if (event.data.type === 'workshop-state') {
setWorkshopState(event.data.state)
setState(null)
}
if (event.data.type === 'combat-ended') setState(null)
if (event.data.type === 'workshop-ended') setWorkshopState(null)
}
announce()
const timer = window.setInterval(() => {
@@ -307,6 +371,40 @@ export function DualScreenBottomDisplay() {
channel?.close()
}
if (!state && workshopState) {
return (
<main className="dual-bottom-display workshop-bottom-display">
<header className="dual-controls-header">
<div>
<p className="eyebrow">{workshopState.mode}</p>
<h1>{workshopState.title}</h1>
</div>
<div className="dual-controls-progress">
<span>{workshopState.subtitle}</span>
</div>
</header>
{workshopState.summary && (
<section className="workshop-bottom-summary">
{workshopState.summary}
</section>
)}
<section className="workshop-bottom-grid">
{workshopState.items.map((item, index) => (
<article key={`${item.title}-${index}`}>
{item.glyph && <span>{item.glyph}</span>}
<div>
<strong>{item.title}</strong>
{item.meta && <small>{item.meta}</small>}
{item.detail && <p>{item.detail}</p>}
</div>
{item.status && <i>{item.status}</i>}
</article>
))}
</section>
</main>
)
}
if (!state) {
return (
<main className="dual-bottom-display dual-bottom-waiting">