hensei-web/src/lib/components/units/WeaponUnit.svelte

390 lines
10 KiB
Svelte

<script lang="ts">
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'
import ContextMenu from '$lib/components/ui/ContextMenu.svelte'
import { ContextMenu as ContextMenuBase, DropdownMenu as DropdownMenuBase } from 'bits-ui'
import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte'
import { getWeaponImage } from '$lib/features/database/detail/image'
import { openDetailsSidebar } from '$lib/features/details/openDetailsSidebar.svelte'
import { getAwakeningImage, getWeaponKeyImages, getAxSkillImages } from '$lib/utils/modifiers'
import * as m from '$lib/paraglide/messages'
interface Props {
item?: GridWeapon
position: number
}
let { item, position }: Props = $props()
type PartyCtx = {
getParty: () => Party
updateParty: (p: Party) => void
canEdit: () => boolean
getEditKey: () => string | null
services: { gridService: any; partyService: any }
}
const ctx = getContext<PartyCtx>('party')
function displayName(input: any): string {
if (!input) return '—'
const maybe = input.name ?? input
if (typeof maybe === 'string') return maybe
if (maybe && typeof maybe === 'object') return maybe.en || maybe.ja || '—'
return '—'
}
// Use $derived to ensure consistent computation between server and client
let imageUrl = $derived.by(() => {
const isMain = position === -1 || item?.mainhand
const variant = isMain ? 'main' : 'grid'
// For weapons with null element that have an instance element, use it
const element = (item?.weapon?.element === 0 && item?.element) ? item.element : undefined
return getWeaponImage(item?.weapon?.granblueId, variant, element)
})
// Get awakening image URL using utility
let awakeningImage = $derived(getAwakeningImage(item?.awakening))
// Get weapon key images using utility
let weaponKeyImages = $derived(
getWeaponKeyImages(
item?.weaponKeys,
item?.weapon?.element,
item?.weapon?.proficiency,
item?.weapon?.series,
item?.weapon?.name
)
)
// Get AX skill images using utility
let axSkillImages = $derived(getAxSkillImages(item?.ax))
async function remove() {
if (!item?.id) return
try {
const party = ctx.getParty()
const editKey = ctx.getEditKey()
const updated = await ctx.services.gridService.removeWeapon(party.id, item.id as any, editKey || undefined)
if (updated) {
ctx.updateParty(updated)
}
} catch (err) {
console.error('Error removing weapon:', err)
}
}
function viewDetails() {
if (!item) return
openDetailsSidebar({
type: 'weapon',
item
})
}
function replace() {
if (ctx?.openPicker) {
ctx.openPicker({ type: 'weapon', position, item })
}
}
</script>
<div class="unit" class:empty={!item} class:extra={position >= 9}>
{#if item}
<ContextMenu showGearButton={true}>
{#snippet children()}
{#key item?.id ?? position}
<div
class="frame weapon"
class:main={item?.mainhand || position === -1}
class:cell={!(item?.mainhand || position === -1)}
class:extra={position >= 9}
class:editable={ctx?.canEdit()}
onclick={() => viewDetails()}
>
<div class="modifiers">
{#if awakeningImage}
<img
class="awakening"
src={awakeningImage}
alt={`${item?.awakening?.type?.name?.en || 'Awakening'} Lv${item?.awakening?.level || 0}`}
/>
{/if}
<div class="skills">
{#each axSkillImages as skill}
<img class="skill" src={skill.url} alt={skill.alt} />
{/each}
{#each weaponKeyImages as skill}
<img class="skill" src={skill.url} alt={skill.alt} />
{/each}
</div>
</div>
<img
class="image"
class:placeholder={!item?.weapon?.granblueId}
alt={displayName(item?.weapon)}
src={imageUrl}
/>
</div>
{/key}
{/snippet}
{#snippet contextMenu()}
<ContextMenuBase.Item class="context-menu-item" onclick={viewDetails}>
{m.context_view_details()}
</ContextMenuBase.Item>
{#if ctx?.canEdit()}
<ContextMenuBase.Item class="context-menu-item" onclick={replace}>
{m.context_replace()}
</ContextMenuBase.Item>
<ContextMenuBase.Separator class="context-menu-separator" />
<ContextMenuBase.Item class="context-menu-item danger" onclick={remove}>
{m.context_remove()}
</ContextMenuBase.Item>
{/if}
{/snippet}
{#snippet dropdownMenu()}
<DropdownMenuBase.Item class="dropdown-menu-item" onclick={viewDetails}>
{m.context_view_details()}
</DropdownMenuBase.Item>
{#if ctx?.canEdit()}
<DropdownMenuBase.Item class="dropdown-menu-item" onclick={replace}>
{m.context_replace()}
</DropdownMenuBase.Item>
<DropdownMenuBase.Separator class="dropdown-menu-separator" />
<DropdownMenuBase.Item class="dropdown-menu-item danger" onclick={remove}>
{m.context_remove()}
</DropdownMenuBase.Item>
{/if}
{/snippet}
</ContextMenu>
{:else}
{#key `empty-${position}`}
<div
class="frame weapon"
class:main={position === -1}
class:cell={position !== -1}
class:extra={position >= 9}
class:editable={ctx?.canEdit()}
onclick={() => ctx?.canEdit() && ctx?.openPicker && ctx.openPicker({ type: 'weapon', position, item })}
>
<img
class="image placeholder"
alt=""
src={position === -1 ? '/images/placeholders/placeholder-weapon-main.png' : '/images/placeholders/placeholder-weapon-grid.png'}
/>
{#if ctx?.canEdit()}
<span class="icon">
<Icon name="plus" size={24} />
</span>
{/if}
</div>
{/key}
{/if}
{#if item}
<UncapIndicator
type="weapon"
uncapLevel={item.uncapLevel}
transcendenceStage={item.transcendenceStep}
flb={item.weapon?.uncap?.flb}
ulb={item.weapon?.uncap?.ulb}
transcendence={item.weapon?.uncap?.transcendence}
editable={ctx?.canEdit()}
updateUncap={async (level) => {
if (!item?.id || !ctx) return
try {
const editKey = ctx.getEditKey()
const updated = await ctx.services.gridService.updateWeaponUncap(item.id, level, undefined, editKey || undefined)
if (updated) {
ctx.updateParty(updated)
}
} catch (err) {
console.error('Failed to update weapon uncap:', err)
// TODO: Show user-friendly error notification
}
}}
updateTranscendence={async (stage) => {
if (!item?.id || !ctx) return
try {
const editKey = ctx.getEditKey()
// When setting transcendence > 0, also set uncap to max (6)
const maxUncap = stage > 0 ? 6 : undefined
const updated = await ctx.services.gridService.updateWeaponUncap(item.id, maxUncap, stage, editKey || undefined)
if (updated) {
ctx.updateParty(updated)
}
} catch (err) {
console.error('Failed to update weapon transcendence:', err)
// TODO: Show user-friendly error notification
}
}}
/>
{/if}
<div class="name">{item ? displayName(item?.weapon) : ''}</div>
</div>
<style lang="scss">
@use '$src/themes/colors' as *;
@use '$src/themes/typography' as *;
@use '$src/themes/spacing' as *;
@use '$src/themes/layout' as *;
@use '$src/themes/rep' as rep;
.unit {
position: relative;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: $unit;
&.empty .name {
display: none;
}
&.extra {
.frame {
background: var(--extra-purple-card-bg);
}
.icon {
color: var(--extra-purple-secondary);
}
&:hover .icon {
color: var(--extra-purple-primary);
}
.name {
font-weight: $medium;
color: var(--extra-purple-text);
}
}
}
.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;
cursor: pointer;
&.editable:hover {
opacity: 0.95;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
transform: $scale-wide;
}
}
.frame.weapon.main {
@include rep.aspect(rep.$weapon-main-w, rep.$weapon-main-h);
&.editable:hover {
transform: $scale-tall;
}
}
.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;
}
.modifiers {
position: absolute;
width: 100%;
height: 100%;
z-index: 3;
pointer-events: none;
.awakening {
position: absolute;
width: 30%;
height: auto;
}
.skills {
position: absolute;
display: flex;
gap: calc($unit / 4);
padding: calc($unit / 2);
.skill {
width: 20%;
height: auto;
}
}
}
// Position modifiers for grid weapons
.frame.weapon.cell {
.awakening {
top: 14%;
left: -3.5%;
}
.skills {
bottom: 2%;
right: 2%;
justify-content: flex-end;
}
}
// Position modifiers for main weapons
.frame.weapon.main {
.awakening {
width: 40%;
top: 67%;
left: -3.5%;
}
.skills {
bottom: 12%;
right: -3.5%;
justify-content: flex-end;
.skill {
width: 25%;
}
}
}
</style>