- Fix Button variant errors (outlined -> ghost, contained -> primary) - Fix search.queries.ts import path and property names (snake_case -> camelCase) - Fix PartyContext export from party.service.ts - Fix User type missing avatar property - Fix exactOptionalPropertyTypes violations in Unit components - Fix MenuItems Props interface - Fix RequestOptions, SearchParams, SearchFilters types - Fix UpdateUncapParams type - Fix Select.ItemIndicator and maxLength errors - Fix Summon/Weapon hp/atk properties in entity.adapter.ts Co-Authored-By: Justin Edmund <justin@jedmund.com>
549 lines
12 KiB
Svelte
549 lines
12 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 UnitMenuContainer from '$lib/components/ui/menu/UnitMenuContainer.svelte'
|
|
import MenuItems from '$lib/components/ui/menu/MenuItems.svelte'
|
|
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 { sidebar } from '$lib/stores/sidebar.svelte'
|
|
import * as m from '$lib/paraglide/messages'
|
|
|
|
interface Props {
|
|
item?: GridWeapon | undefined
|
|
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 }
|
|
openPicker?: (opts: { type: 'weapon' | 'summon' | 'character'; position: number; item?: any }) => void
|
|
}
|
|
|
|
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))
|
|
|
|
// Check if this item is currently active in the sidebar
|
|
let isActive = $derived(item?.id && sidebar.activeItemId === String(item.id))
|
|
|
|
// Determine element class for focus ring
|
|
let elementClass = $derived.by(() => {
|
|
// For weapons with null element that have an instance element, use it
|
|
const element =
|
|
item?.weapon?.element === 0 && item?.element ? item.element : item?.weapon?.element
|
|
|
|
switch (element) {
|
|
case 1:
|
|
return 'wind'
|
|
case 2:
|
|
return 'fire'
|
|
case 3:
|
|
return 'water'
|
|
case 4:
|
|
return 'earth'
|
|
case 5:
|
|
return 'dark'
|
|
case 6:
|
|
return 'light'
|
|
default:
|
|
return 'neutral'
|
|
}
|
|
})
|
|
|
|
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 {elementClass}"
|
|
class:empty={!item}
|
|
class:extra={position >= 9}
|
|
class:is-active={isActive}
|
|
>
|
|
{#if item}
|
|
<UnitMenuContainer showGearButton={true}>
|
|
{#snippet trigger()}
|
|
<div
|
|
class="focus-ring-wrapper {elementClass}"
|
|
class:is-active={isActive}
|
|
class:editable={ctx?.canEdit()}
|
|
class:main={item?.mainhand || position === -1}
|
|
>
|
|
{#key item?.id ?? position}
|
|
<div
|
|
class="frame weapon {elementClass}"
|
|
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 {elementClass}"
|
|
class:placeholder={!item?.weapon?.granblueId}
|
|
alt={displayName(item?.weapon)}
|
|
src={imageUrl}
|
|
/>
|
|
</div>
|
|
{/key}
|
|
</div>
|
|
{/snippet}
|
|
|
|
{#snippet contextMenu()}
|
|
<MenuItems
|
|
onViewDetails={viewDetails}
|
|
onReplace={ctx?.canEdit() ? replace : undefined}
|
|
onRemove={ctx?.canEdit() ? remove : undefined}
|
|
canEdit={ctx?.canEdit()}
|
|
variant="context"
|
|
viewDetailsLabel={m.context_view_details()}
|
|
replaceLabel={m.context_replace()}
|
|
removeLabel={m.context_remove()}
|
|
/>
|
|
{/snippet}
|
|
|
|
{#snippet dropdownMenu()}
|
|
<MenuItems
|
|
onViewDetails={viewDetails}
|
|
onReplace={ctx?.canEdit() ? replace : undefined}
|
|
onRemove={ctx?.canEdit() ? remove : undefined}
|
|
canEdit={ctx?.canEdit()}
|
|
variant="dropdown"
|
|
viewDetailsLabel={m.context_view_details()}
|
|
replaceLabel={m.context_replace()}
|
|
removeLabel={m.context_remove()}
|
|
/>
|
|
{/snippet}
|
|
</UnitMenuContainer>
|
|
{: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 colors;
|
|
@use '$src/themes/typography' as typography;
|
|
@use '$src/themes/spacing' as spacing;
|
|
@use '$src/themes/layout' as layout;
|
|
@use '$src/themes/rep' as rep;
|
|
|
|
.unit {
|
|
position: relative;
|
|
width: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: spacing.$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: typography.$medium;
|
|
color: var(--extra-purple-text);
|
|
}
|
|
}
|
|
}
|
|
|
|
.focus-ring-wrapper {
|
|
position: relative;
|
|
display: block;
|
|
transition: transform 0.2s ease-in-out;
|
|
|
|
&::before {
|
|
content: '';
|
|
position: absolute;
|
|
inset: 0;
|
|
border-radius: 8px;
|
|
pointer-events: none;
|
|
z-index: 10;
|
|
}
|
|
|
|
&.editable:hover {
|
|
transform: layout.$scale-wide;
|
|
}
|
|
|
|
&.editable.main:hover {
|
|
transform: layout.$scale-tall;
|
|
}
|
|
}
|
|
|
|
.frame {
|
|
position: relative;
|
|
width: 100%;
|
|
overflow: hidden;
|
|
border-radius: 8px;
|
|
background: var(--card-bg, #f5f5f5);
|
|
transition: opacity 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);
|
|
}
|
|
}
|
|
|
|
.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: typography.$font-small;
|
|
text-align: center;
|
|
color: colors.$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(spacing.$unit / 4);
|
|
padding: calc(spacing.$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%;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pulsing focus ring animation
|
|
@keyframes pulse-focus-ring {
|
|
0%,
|
|
100% {
|
|
box-shadow: 0 0 4px 3px currentColor;
|
|
}
|
|
50% {
|
|
box-shadow: 0 0 4px 6px currentColor;
|
|
}
|
|
}
|
|
|
|
// Element-specific focus rings
|
|
.focus-ring-wrapper.is-active::before {
|
|
animation: pulse-focus-ring 2s ease-in-out infinite;
|
|
}
|
|
|
|
.focus-ring-wrapper.is-active {
|
|
&.fire::before {
|
|
@include colors.focus-ring-fire();
|
|
color: rgba(250, 109, 109, 0.2);
|
|
}
|
|
|
|
&.water::before {
|
|
@include colors.focus-ring-water();
|
|
color: rgba(108, 201, 255, 0.2);
|
|
}
|
|
|
|
&.earth::before {
|
|
@include colors.focus-ring-earth();
|
|
color: rgba(253, 159, 91, 0.2);
|
|
}
|
|
|
|
&.wind::before {
|
|
@include colors.focus-ring-wind();
|
|
color: rgba(62, 228, 137, 0.2);
|
|
}
|
|
|
|
&.light::before {
|
|
@include colors.focus-ring-light();
|
|
color: rgba(232, 214, 51, 0.2);
|
|
}
|
|
|
|
&.dark::before {
|
|
@include colors.focus-ring-dark();
|
|
color: rgba(222, 123, 255, 0.2);
|
|
}
|
|
|
|
&.neutral::before {
|
|
@include colors.focus-ring-neutral();
|
|
color: rgba(0, 0, 0, 0.1);
|
|
}
|
|
}
|
|
|
|
// Element-specific name colors when active
|
|
.unit.is-active {
|
|
.name {
|
|
font-weight: typography.$bold;
|
|
}
|
|
|
|
&.fire .name {
|
|
color: colors.$fire--text--light;
|
|
}
|
|
|
|
&.water .name {
|
|
color: colors.$water--text--light;
|
|
}
|
|
|
|
&.earth .name {
|
|
color: colors.$earth--text--light;
|
|
}
|
|
|
|
&.wind .name {
|
|
color: colors.$wind--text--light;
|
|
}
|
|
|
|
&.light .name {
|
|
color: colors.$light--text--light;
|
|
}
|
|
|
|
&.dark .name {
|
|
color: colors.$dark--text--light;
|
|
}
|
|
|
|
&.neutral .name {
|
|
color: colors.$grey-40;
|
|
}
|
|
}
|
|
</style>
|