Update unit components with editing context

This commit is contained in:
Justin Edmund 2025-09-15 04:08:11 -07:00
parent 795d9761d7
commit c9bd155ed6
3 changed files with 455 additions and 177 deletions

View file

@ -1,16 +1,23 @@
<script lang="ts">
import type { GridCharacterItemView, PartyView } from '$lib/api/schemas/party'
import type { GridCharacter } from '$lib/types/api/party'
import type { Party } from '$lib/types/api/party'
import { getContext } from 'svelte'
import Icon from '$lib/components/Icon.svelte'
export let item: GridCharacterItemView | undefined
export let position: number
export let mainWeaponElement: number | null | undefined
export let partyElement: number | null | undefined
interface Props {
item?: GridCharacter
position: number
mainWeaponElement?: number | null
partyElement?: number | null
}
let { item, position, mainWeaponElement, partyElement }: Props = $props()
type PartyCtx = {
getParty: () => PartyView
updateParty: (p: PartyView) => void
getParty: () => Party
updateParty: (p: Party) => void
canEdit: () => boolean
getEditKey: () => string | null
services: { gridService: any; partyService: any }
}
const ctx = getContext<PartyCtx>('party')
@ -22,11 +29,19 @@
if (maybe && typeof maybe === 'object') return maybe.en || maybe.ja || '—'
return '—'
}
function characterImageUrl(c?: GridCharacterItemView): string {
const id = (c as any)?.object?.granblueId
if (!id) return '/images/placeholders/placeholder-weapon-grid.png'
const uncap = (c as any)?.uncapLevel ?? 0
const transStep = (c as any)?.transcendenceStep ?? 0
// Use $derived to ensure consistent computation between server and client
let imageUrl = $derived(() => {
// Handle both new structure (item.character) and old structure (item.object) for compatibility
const characterData = item?.character || (item as any)?.object
// If no item or no granblueId, return placeholder
if (!item || !characterData?.granblueId) {
return '/images/placeholders/placeholder-weapon-grid.png'
}
const id = characterData.granblueId
const uncap = item?.uncapLevel ?? 0
const transStep = item?.transcendenceStep ?? 0
let suffix = '01'
if (transStep > 0) suffix = '04'
else if (uncap >= 5) suffix = '03'
@ -36,70 +51,135 @@
suffix = `${suffix}_0${element}`
}
return `/images/character-main/${id}_${suffix}.jpg`
}
})
async function remove() {
if (!item?.id) return
const party = ctx.getParty()
const editKey = ctx.services.partyService.getEditKey(party.shortcode)
const editKey = ctx.getEditKey()
const updated = await ctx.services.gridService.removeCharacter(party.id, item.id as any, editKey || undefined)
ctx.updateParty(updated)
}
async function add() {
const party = ctx.getParty()
const editKey = ctx.services.partyService.getEditKey(party.shortcode)
const id = window.prompt('Enter character ID to add')
if (!id) return
const res = await ctx.services.gridService.addCharacter(party.id, id, position, editKey || undefined)
if (res.conflicts) {
window.alert('Conflict detected. Please resolve via UI in a later step.')
return
}
ctx.updateParty(res.party)
}
async function replaceItem() {
if (!item?.id) return add()
const party = ctx.getParty()
const editKey = ctx.services.partyService.getEditKey(party.shortcode)
const id = window.prompt('Enter new character ID')
if (!id) return
const res = await ctx.services.gridService.replaceCharacter(party.id, item.id as any, id, editKey || undefined)
if (res.conflicts) {
window.alert('Conflict detected. Please resolve via UI in a later step.')
return
}
ctx.updateParty(res.party)
}
</script>
<div class="unit">
<img class="image" alt={item ? displayName(item.object) : ''} src={characterImageUrl(item)} />
<div class="name">{item ? displayName(item.object) : '—'}</div>
{#if ctx.canEdit() && !item}
<button class="add" title="Add" on:click={add}></button>
{/if}
{#if ctx.canEdit() && item?.id}
<div class="actions">
<button class="replace" title="Replace" on:click={replaceItem}>↺</button>
<button class="remove" title="Remove" on:click={remove}>×</button>
<div class="unit" class:empty={!item}>
{#key (item ? (item as any).id ?? position : `empty-${position}`)}
<div
class="frame character cell"
class:editable={ctx?.canEdit()}
on:click={() => ctx?.openPicker && ctx.openPicker({ type: 'character', position, item })}
>
<img class="image" class:placeholder={!item || !(item?.character?.granblueId || (item as any)?.object?.granblueId)} alt={item ? displayName(item?.character || (item as any)?.object) : ''} src={imageUrl()} />
{#if !item && ctx?.canEdit()}
<span class="icon">
<Icon name="plus" size={24} />
</span>
{/if}
{#if ctx.canEdit() && item?.id}
<div class="actions">
<button class="remove" title="Remove" on:click|stopPropagation={remove}>×</button>
</div>
{/if}
</div>
{/if}
{#if ctx.canEdit() && item?.id}
<button class="remove" title="Remove" on:click={remove}>×</button>
{/if}
{/key}
<div class="name">{item ? displayName(item?.character || (item as any)?.object) : ''}</div>
</div>
<style lang="scss">
@use '$src/themes/colors' as *;
@use '$src/themes/typography' as *;
@use '$src/themes/spacing' as *;
@use '$src/themes/rep' as rep;
.unit { position: relative; width: 100%; display: flex; flex-direction: column; align-items: center; gap: $unit; }
.image { width: 100%; height: auto; border: 1px solid $grey-75; border-radius: 8px; display: block; }
.name { font-size: $font-small; text-align: center; color: $grey-50; }
.actions { position: absolute; top: 6px; right: 6px; display: flex; gap: 6px; }
.remove, .replace, .add { background: rgba(0,0,0,.6); color: white; border: none; border-radius: 12px; width: 24px; height: 24px; line-height: 24px; cursor: pointer; }
.add { position: absolute; top: 6px; right: 6px; }
.unit {
position: relative;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: $unit;
&.empty .name {
display: none;
}
}
.frame {
position: relative;
width: 100%;
overflow: hidden;
border-radius: 8px;
background: var(--card-bg, #f5f5f5);
border: 1px solid transparent;
transition: all 0.2s ease-in-out;
display: flex;
align-items: center;
justify-content: center;
&.editable {
cursor: pointer;
&:hover {
border-color: var(--primary-color, #0066cc);
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
transform: scale(1.01);
}
}
}
.frame.character.cell {
@include rep.aspect(rep.$char-cell-w, rep.$char-cell-h);
}
.image {
position: relative;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
z-index: 2;
&.placeholder {
opacity: 0;
}
}
.icon {
position: absolute;
z-index: 1;
color: var(--icon-secondary, #999);
transition: color 0.2s ease-in-out;
}
.frame.editable:hover .icon {
color: var(--icon-secondary-hover, #666);
}
.name {
font-size: $font-small;
text-align: center;
color: $grey-50;
}
.actions {
position: absolute;
top: 6px;
right: 6px;
display: flex;
gap: 6px;
z-index: 3;
}
.remove {
background: rgba(0,0,0,.6);
color: white;
border: none;
border-radius: 12px;
width: 24px;
height: 24px;
line-height: 24px;
cursor: pointer;
}
</style>

View file

@ -1,14 +1,21 @@
<script lang="ts">
import type { GridSummonItemView, PartyView } from '$lib/api/schemas/party'
import type { GridSummon } from '$lib/types/api/party'
import type { Party } from '$lib/types/api/party'
import { getContext } from 'svelte'
import Icon from '$lib/components/Icon.svelte'
export let item: GridSummonItemView | undefined
export let position: number
interface Props {
item?: GridSummon
position: number
}
let { item, position }: Props = $props()
type PartyCtx = {
getParty: () => PartyView
updateParty: (p: PartyView) => void
getParty: () => Party
updateParty: (p: Party) => void
canEdit: () => boolean
getEditKey: () => string | null
services: { gridService: any; partyService: any }
}
const ctx = getContext<PartyCtx>('party')
@ -20,75 +27,184 @@
if (maybe && typeof maybe === 'object') return maybe.en || maybe.ja || '—'
return '—'
}
function summonImageUrl(s?: GridSummonItemView): string {
const id = (s as any)?.object?.granblueId
const isMain = s?.main || s?.position === -1 || s?.friend || s?.position === 6
if (!id) return isMain ? '/images/placeholders/placeholder-summon-main.png' : '/images/placeholders/placeholder-summon-grid.png'
// Use $derived to ensure consistent computation between server and client
let imageUrl = $derived(() => {
// Check position first for main/friend summon determination
const isMain = position === -1 || position === 6 || item?.main || item?.friend
// Handle both new structure (item.summon) and old structure (item.object) for compatibility
const summonData = item?.summon || (item as any)?.object
// If no item or no granblueId, return placeholder
if (!item || !summonData?.granblueId) {
return isMain
? '/images/placeholders/placeholder-summon-main.png'
: '/images/placeholders/placeholder-summon-grid.png'
}
const id = summonData.granblueId
const folder = isMain ? 'summon-main' : 'summon-grid'
return `/images/${folder}/${id}.jpg`
}
})
async function remove() {
if (!item?.id) return
const party = ctx.getParty()
const editKey = ctx.services.partyService.getEditKey(party.shortcode)
const editKey = ctx.getEditKey()
const updated = await ctx.services.gridService.removeSummon(party.id, item.id as any, editKey || undefined)
ctx.updateParty(updated)
}
async function add() {
const party = ctx.getParty()
const editKey = ctx.services.partyService.getEditKey(party.shortcode)
const id = window.prompt('Enter summon ID to add')
if (!id) return
const updated = await ctx.services.gridService.addSummon(party.id, id, position, editKey || undefined)
ctx.updateParty(updated)
}
async function replaceItem() {
if (!item?.id) return add()
const party = ctx.getParty()
const editKey = ctx.services.partyService.getEditKey(party.shortcode)
const id = window.prompt('Enter new summon ID')
if (!id) return
const updated = await ctx.services.gridService.replaceSummon(party.id, item.id as any, id, editKey || undefined)
ctx.updateParty(updated)
}
</script>
<div class="unit">
<img class="image" alt={item ? displayName(item.object) : ''} src={summonImageUrl(item)} />
<div class="name">{item ? displayName(item.object) : '—'}</div>
{#if ctx.canEdit() && !item}
<button class="add" title="Add" on:click={add}></button>
{/if}
{#if ctx.canEdit() && item?.id}
<div class="actions">
<button class="replace" title="Replace" on:click={replaceItem}>↺</button>
<button class="remove" title="Remove" on:click={remove}>×</button>
<div class="unit" class:empty={!item}>
{#key (item ? (item as any).id ?? position : `empty-${position}`)}
<div
class="frame summon"
class:main={item?.main || position === -1}
class:friend={item?.friend || position === 6}
class:cell={!((item?.main || position === -1) || (item?.friend || position === 6))}
class:editable={ctx?.canEdit()}
on:click={() => ctx?.openPicker && ctx.openPicker({ type: 'summon', position, item })}
>
<img class="image" class:placeholder={!item || !(item?.summon?.granblueId || (item as any)?.object?.granblueId)} alt={item ? displayName(item?.summon || (item as any)?.object) : ''} src={imageUrl()} />
{#if !item && ctx?.canEdit()}
<span class="icon">
<Icon name="plus" size={24} />
</span>
{/if}
{#if ctx.canEdit() && item?.id}
<div class="actions">
<button class="remove" title="Remove" on:click|stopPropagation={remove}>×</button>
</div>
{/if}
{#if item?.main || position === -1}
<span class="badge">Main</span>
{/if}
{#if item?.friend || position === 6}
<span class="badge" style="left:auto; right:6px">Friend</span>
{/if}
</div>
{/if}
{#if ctx.canEdit() && item?.id}
<button class="remove" title="Remove" on:click={remove}>×</button>
{/if}
{#if item?.main || position === -1}
<span class="badge">Main</span>
{/if}
{#if item?.friend || position === 6}
<span class="badge" style="left:auto; right:6px">Friend</span>
{/if}
{/key}
<div class="name">{item ? displayName(item?.summon || (item as any)?.object) : ''}</div>
</div>
<style lang="scss">
@use '$src/themes/colors' as *;
@use '$src/themes/typography' as *;
@use '$src/themes/spacing' as *;
@use '$src/themes/rep' as rep;
.unit { position: relative; width: 100%; display: flex; flex-direction: column; align-items: center; gap: $unit; }
.image { width: 100%; height: auto; border: 1px solid $grey-75; border-radius: 8px; display: block; }
.name { font-size: $font-small; text-align: center; color: $grey-50; }
.actions { position: absolute; top: 6px; right: 6px; display: flex; gap: 6px; }
.remove, .replace, .add { background: rgba(0,0,0,.6); color: white; border: none; border-radius: 12px; width: 24px; height: 24px; line-height: 24px; cursor: pointer; }
.add { position: absolute; top: 6px; right: 6px; }
.badge { position: absolute; left: 6px; top: 6px; background: #333; color: #fff; font-size: 11px; padding: 2px 6px; border-radius: 10px; }
.unit {
position: relative;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: $unit;
&.empty .name {
display: none;
}
}
.frame {
position: relative;
width: 100%;
overflow: hidden;
border-radius: 8px;
background: var(--card-bg, #f5f5f5);
border: 1px solid transparent;
transition: all 0.2s ease-in-out;
display: flex;
align-items: center;
justify-content: center;
&.editable {
cursor: pointer;
&:hover {
border-color: var(--primary-color, #0066cc);
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
transform: scale(1.02);
}
}
&.summon.main.editable:hover,
&.summon.friend.editable:hover {
transform: scale(1.01);
}
}
.frame.summon.main,
.frame.summon.friend {
@include rep.aspect(rep.$summon-main-w, rep.$summon-main-h);
}
.frame.summon.cell {
@include rep.aspect(rep.$summon-cell-w, rep.$summon-cell-h);
}
.image {
position: relative;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
z-index: 2;
&.placeholder {
opacity: 0;
}
}
.icon {
position: absolute;
z-index: 1;
color: var(--icon-secondary, #999);
transition: color 0.2s ease-in-out;
}
.frame.editable:hover .icon {
color: var(--icon-secondary-hover, #666);
}
.name {
font-size: $font-small;
text-align: center;
color: $grey-50;
}
.actions {
position: absolute;
top: 6px;
right: 6px;
display: flex;
gap: 6px;
z-index: 3;
}
.remove {
background: rgba(0,0,0,.6);
color: white;
border: none;
border-radius: 12px;
width: 24px;
height: 24px;
line-height: 24px;
cursor: pointer;
}
.badge {
position: absolute;
left: 6px;
top: 6px;
background: #333;
color: #fff;
font-size: 11px;
padding: 2px 6px;
border-radius: 10px;
z-index: 3;
}
</style>

View file

@ -1,14 +1,21 @@
<script lang="ts">
import type { GridWeaponItemView, PartyView } from '$lib/api/schemas/party'
import type { GridWeapon } from '$lib/types/api/party'
import type { Party } from '$lib/types/api/party'
import { getContext } from 'svelte'
import Icon from '$lib/components/Icon.svelte'
export let item: GridWeaponItemView | undefined
export let position: number
interface Props {
item?: GridWeapon
position: number
}
let { item, position }: Props = $props()
type PartyCtx = {
getParty: () => PartyView
updateParty: (p: PartyView) => void
getParty: () => Party
updateParty: (p: Party) => void
canEdit: () => boolean
getEditKey: () => string | null
services: { gridService: any; partyService: any }
}
@ -22,79 +29,76 @@
return '—'
}
function weaponImageUrl(w?: GridWeaponItemView): string {
const id = (w as any)?.object?.granblueId
const isMain = !!(w && ((w as any).mainhand || (w as any).position === -1))
if (!id) return isMain
? '/images/placeholders/placeholder-weapon-main.png'
: '/images/placeholders/placeholder-weapon-grid.png'
// Use $derived to ensure consistent computation between server and client
let imageUrl = $derived(() => {
// Check position first for main weapon determination
const isMain = position === -1 || item?.mainhand
// Handle both new structure (item.weapon) and old structure (item.object) for compatibility
const weaponData = item?.weapon || (item as any)?.object
// If no item or no granblueId, return placeholder
if (!item || !weaponData?.granblueId) {
return isMain
? '/images/placeholders/placeholder-weapon-main.png'
: '/images/placeholders/placeholder-weapon-grid.png'
}
const id = weaponData.granblueId
const folder = isMain ? 'weapon-main' : 'weapon-grid'
const objElement = (w as any)?.object?.element
const instElement = (w as any)?.element
const objElement = weaponData?.element
const instElement = item?.element
if (objElement === 0 && instElement) {
return `/images/${folder}/${id}_${instElement}.jpg`
}
return `/images/${folder}/${id}.jpg`
}
})
async function remove() {
if (!item?.id) return
const party = ctx.getParty()
const editKey = ctx.services.partyService.getEditKey(party.shortcode)
const editKey = ctx.getEditKey()
const updated = await ctx.services.gridService.removeWeapon(party.id, item.id as any, editKey || undefined)
ctx.updateParty(updated)
}
async function add() {
const party = ctx.getParty()
const editKey = ctx.services.partyService.getEditKey(party.shortcode)
const id = window.prompt('Enter weapon ID to add')
if (!id) return
const res = await ctx.services.gridService.addWeapon(party.id, id, position, editKey || undefined)
if (res.conflicts) {
window.alert('Conflict detected. Please resolve via UI in a later step.')
return
}
ctx.updateParty(res.party)
}
async function replaceItem() {
if (!item?.id) return add()
const party = ctx.getParty()
const editKey = ctx.services.partyService.getEditKey(party.shortcode)
const id = window.prompt('Enter new weapon ID')
if (!id) return
const res = await ctx.services.gridService.replaceWeapon(party.id, item.id as any, id, editKey || undefined)
if (res.conflicts) {
window.alert('Conflict detected. Please resolve via UI in a later step.')
return
}
ctx.updateParty(res.party)
}
</script>
<div class="unit">
<img class="image" alt={item ? displayName(item.object) : ''} src={weaponImageUrl(item)} />
<div class="name">{item ? displayName(item.object) : '—'}</div>
{#if ctx.canEdit() && !item}
<button class="add" title="Add" on:click={add}></button>
{/if}
{#if ctx.canEdit() && item?.id}
<div class="actions">
<button class="replace" title="Replace" on:click={replaceItem}>↺</button>
<button class="remove" title="Remove" on:click={remove}>×</button>
<div class="unit" class:empty={!item}>
{#key (item ? (item as any).id ?? position : `empty-${position}`)}
<div
class="frame weapon"
class:main={item?.mainhand || position === -1}
class:cell={!(item?.mainhand || position === -1)}
class:editable={ctx?.canEdit()}
on:click={() => ctx?.openPicker && ctx.openPicker({ type: 'weapon', position, item })}
>
<img class="image" class:placeholder={!item || !(item?.weapon?.granblueId || (item as any)?.object?.granblueId)} alt={item ? displayName(item?.weapon || (item as any)?.object) : ''} src={imageUrl()} />
{#if !item && ctx?.canEdit()}
<span class="icon">
<Icon name="plus" size={24} />
</span>
{/if}
{#if ctx.canEdit() && item?.id}
<div class="actions">
<button class="remove" title="Remove" on:click|stopPropagation={remove}>×</button>
</div>
{/if}
{#if item?.mainhand || position === -1}
<span class="badge">Main</span>
{/if}
</div>
{/if}
{#if (item as any)?.mainhand || position === -1}
<span class="badge">Main</span>
{/if}
{/key}
<div class="name">{item ? displayName(item?.weapon || (item as any)?.object) : ''}</div>
</div>
<style lang="scss">
@use '$src/themes/colors' as *;
@use '$src/themes/typography' as *;
@use '$src/themes/spacing' as *;
@use '$src/themes/rep' as rep;
.unit {
position: relative;
@ -103,23 +107,101 @@
flex-direction: column;
align-items: center;
gap: $unit;
&.empty .name {
display: none;
}
}
.image { width: 100%; height: auto; border: 1px solid $grey-75; border-radius: 8px; display: block; }
.name { font-size: $font-small; text-align: center; color: $grey-50; }
.actions { position: absolute; top: 6px; right: 6px; display: flex; gap: 6px; }
.remove, .replace, .add {
.frame {
position: relative;
width: 100%;
overflow: hidden;
border-radius: 8px;
background: var(--card-bg, #f5f5f5);
border: 1px solid transparent;
transition: all 0.2s ease-in-out;
display: flex;
align-items: center;
justify-content: center;
&.editable {
cursor: pointer;
&:hover {
border-color: var(--primary-color, #0066cc);
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
transform: scale(1.02);
}
}
&.weapon.main.editable:hover {
transform: scale(1.01);
}
}
.frame.weapon.main { @include rep.aspect(rep.$weapon-main-w, rep.$weapon-main-h); }
.frame.weapon.cell { @include rep.aspect(rep.$weapon-cell-w, rep.$weapon-cell-h); }
.image {
position: relative;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
z-index: 2;
&.placeholder {
opacity: 0;
}
}
.icon {
position: absolute;
z-index: 1;
color: var(--icon-secondary, #999);
transition: color 0.2s ease-in-out;
}
.frame.editable:hover .icon {
color: var(--icon-secondary-hover, #666);
}
.name {
font-size: $font-small;
text-align: center;
color: $grey-50;
}
.actions {
position: absolute;
top: 6px;
right: 6px;
display: flex;
gap: 6px;
z-index: 3;
}
.remove {
background: rgba(0,0,0,.6);
color: white;
border: none;
border-radius: 12px;
width: 24px; height: 24px; line-height: 24px;
width: 24px;
height: 24px;
line-height: 24px;
cursor: pointer;
}
.add { position: absolute; top: 6px; right: 6px; }
.badge {
position: absolute;
left: 6px; top: 6px;
background: #333; color: #fff;
font-size: 11px; padding: 2px 6px; border-radius: 10px;
left: 6px;
top: 6px;
background: #333;
color: #fff;
font-size: 11px;
padding: 2px 6px;
border-radius: 10px;
z-index: 3;
}
</style>