components: update party and character components

This commit is contained in:
Justin Edmund 2025-11-30 20:06:44 -08:00
parent c3ed9b2885
commit af659b9760
5 changed files with 62 additions and 216 deletions

View file

@ -2,7 +2,6 @@
<script lang="ts"> <script lang="ts">
import type { GridCharacter } from '$lib/types/api/party' import type { GridCharacter } from '$lib/types/api/party'
import type { Job } from '$lib/types/api/entities'
import { getContext } from 'svelte' import { getContext } from 'svelte'
import type { PartyContext } from '$lib/types/party-context' import type { PartyContext } from '$lib/types/party-context'
import type { DragDropContext } from '$lib/composables/drag-drop.svelte' import type { DragDropContext } from '$lib/composables/drag-drop.svelte'
@ -13,7 +12,6 @@
characters?: GridCharacter[] | undefined characters?: GridCharacter[] | undefined
mainWeaponElement?: number | null | undefined mainWeaponElement?: number | null | undefined
partyElement?: number | null | undefined partyElement?: number | null | undefined
job?: Job | undefined
container?: string | undefined container?: string | undefined
} }
@ -21,7 +19,6 @@
characters = [], characters = [],
mainWeaponElement = undefined, mainWeaponElement = undefined,
partyElement = undefined, partyElement = undefined,
job = undefined,
container = 'main-characters' container = 'main-characters'
}: Props = $props() }: Props = $props()
@ -50,7 +47,6 @@
{#each characterSlots as character, i} {#each characterSlots as character, i}
<li <li
aria-label={`Character slot ${i}`} aria-label={`Character slot ${i}`}
class:main-character={i === 0}
class:Empty={!character} class:Empty={!character}
> >
{#if dragContext} {#if dragContext}
@ -73,7 +69,6 @@
position={i} position={i}
{mainWeaponElement} {mainWeaponElement}
{partyElement} {partyElement}
job={i === 0 ? job : undefined}
/> />
</DraggableItem> </DraggableItem>
</DropZone> </DropZone>
@ -83,7 +78,6 @@
position={i} position={i}
{mainWeaponElement} {mainWeaponElement}
{partyElement} {partyElement}
job={i === 0 ? job : undefined}
/> />
{/if} {/if}
</li> </li>

View file

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount, getContext, setContext } from 'svelte' import { onMount, getContext, setContext, onDestroy } from 'svelte'
import type { Party, GridCharacter, GridWeapon, GridSummon } from '$lib/types/api/party' import type { Party, GridCharacter, GridWeapon, GridSummon } from '$lib/types/api/party'
import { partyStore } from '$lib/stores/partyStore.svelte'
// TanStack Query mutations - Grid // TanStack Query mutations - Grid
import { import {
@ -84,6 +85,16 @@
initial?.id && initial?.id !== 'new' && Array.isArray(initial?.weapons) ? initial : defaultParty initial?.id && initial?.id !== 'new' && Array.isArray(initial?.weapons) ? initial : defaultParty
) )
// Sync party to global store for components outside the party context (like DetailsSidebar)
$effect(() => {
partyStore.setParty(party)
})
// Clear store on unmount
onDestroy(() => {
partyStore.clear()
})
let activeTab = $state<GridType>(GridType.Weapon) let activeTab = $state<GridType>(GridType.Weapon)
let loading = $state(false) let loading = $state(false)
let error = $state<string | null>(null) let error = $state<string | null>(null)
@ -249,14 +260,12 @@
function handleTabChange(tab: GridType) { function handleTabChange(tab: GridType) {
activeTab = tab activeTab = tab
// Update selectedSlot to the first valid empty slot for this tab // Update selectedSlot to the first valid empty slot for this tab
// Characters tab: position 0 is protagonist (not selectable), so start at 1
// Weapons/Summons: start at first empty slot
const nextEmpty = findNextEmptySlot(party, tab) const nextEmpty = findNextEmptySlot(party, tab)
if (nextEmpty !== SLOT_NOT_FOUND) { if (nextEmpty !== SLOT_NOT_FOUND) {
selectedSlot = nextEmpty selectedSlot = nextEmpty
} else { } else {
// Fallback: Characters start at 1 (skip protagonist), others at 0 // Fallback: all grid types start at 0
selectedSlot = tab === GridType.Character ? 1 : 0 selectedSlot = 0
} }
} }
@ -902,7 +911,6 @@
characters={party.characters} characters={party.characters}
{mainWeaponElement} {mainWeaponElement}
{partyElement} {partyElement}
job={party.job}
/> />
</div> </div>
{/if} {/if}

View file

@ -2,7 +2,6 @@
<svelte:options runes={true} /> <svelte:options runes={true} />
<script lang="ts"> <script lang="ts">
import { getContext } from 'svelte'
import type { Party, GridCharacter, GridWeapon, GridSummon } from '$lib/types/api/party' import type { Party, GridCharacter, GridWeapon, GridSummon } from '$lib/types/api/party'
import { GridType } from '$lib/types/enums' import { GridType } from '$lib/types/enums'
import SegmentedControl from '$lib/components/ui/segmented-control/SegmentedControl.svelte' import SegmentedControl from '$lib/components/ui/segmented-control/SegmentedControl.svelte'
@ -26,8 +25,6 @@
const weapons = $derived(party.weapons) const weapons = $derived(party.weapons)
const summons = $derived(party.summons) const summons = $derived(party.summons)
const characters = $derived(party.characters) const characters = $derived(party.characters)
const job = $derived(party.job)
const element = $derived(party.element)
// Handle value changes // Handle value changes
let value = $state(selectedTab) let value = $state(selectedTab)
@ -41,10 +38,6 @@
onTabChange?.(newValue as GridType) onTabChange?.(newValue as GridType)
} }
// Get user gender from context if available
// This would typically come from auth/account state
const accountContext = getContext<any>('account')
const userGender = $derived(accountContext?.user?.gender ?? 0)
</script> </script>
<nav class={className}> <nav class={className}>
@ -54,12 +47,7 @@
label={m.party_segmented_control_characters()} label={m.party_segmented_control_characters()}
selected={value === GridType.Character} selected={value === GridType.Character}
> >
<CharacterRep <CharacterRep characters={characters} />
job={job}
element={element}
gender={userGender}
characters={characters}
/>
</RepSegment> </RepSegment>
<RepSegment <RepSegment

View file

@ -1,49 +1,21 @@
<script lang="ts"> <script lang="ts">
import type { Party, GridWeapon, GridCharacter } from '$lib/types/api/party' import type { Party, GridWeapon, GridCharacter } from '$lib/types/api/party'
import type { Job } from '$lib/types/api/entities'
import { getElementClass } from '$lib/utils/element'
import { getCharacterImageWithPose } from '$lib/utils/images' import { getCharacterImageWithPose } from '$lib/utils/images'
import { getJobPortraitUrl, Gender } from '$lib/utils/jobUtils'
interface Props { interface Props {
party?: Party party?: Party
characters?: GridCharacter[] characters?: GridCharacter[]
job?: Job
jobId?: string
element?: number
gender?: number
} }
let { party, characters: directCharacters, job, jobId, element, gender }: Props = $props() let { party, characters: directCharacters }: Props = $props()
// Use direct characters if provided, otherwise get from party // Use direct characters if provided, otherwise get from party
const characters = $derived(directCharacters || party?.characters || []) const characters = $derived(directCharacters || party?.characters || [])
const grid = $derived(Array.from({ length: 3 }, (_, i) => // Show 5 characters at positions 0-4
const grid = $derived(Array.from({ length: 5 }, (_, i) =>
characters.find((c: GridCharacter) => c?.position === i) characters.find((c: GridCharacter) => c?.position === i)
)) ))
// Get job from party if not directly provided
const currentJob = $derived(job || party?.job)
const genderValue = $derived(gender !== undefined ? gender : 0) // Default to Gran if not specified
const protagonistClass = $derived(
// If element is directly provided, use it
element ? getElementClass(element) :
// Otherwise try to get from party's mainhand weapon
party ? (() => {
const main: GridWeapon | undefined = (party.weapons || []).find(
(w: GridWeapon) => w?.mainhand || w?.position === -1
)
const el = main?.element ?? main?.weapon?.element
return getElementClass(el) || ''
})() : ''
)
// Get job portrait URL if job is available
const jobPortraitUrl = $derived(
currentJob ? getJobPortraitUrl(currentJob, genderValue as Gender) : ''
)
function characterImageUrl(c?: GridCharacter): string { function characterImageUrl(c?: GridCharacter): string {
const id = c?.character?.granblueId const id = c?.character?.granblueId
if (!id) return '' if (!id) return ''
@ -70,16 +42,6 @@
<div class="rep"> <div class="rep">
<ul class="characters"> <ul class="characters">
<li class={`protagonist ${protagonistClass}`} class:empty={!currentJob}>
{#if currentJob && jobPortraitUrl}
<img
alt="{currentJob.name.en} job"
src={jobPortraitUrl}
loading="lazy"
decoding="async"
/>
{/if}
</li>
{#each grid as c, i} {#each grid as c, i}
<li class="character" class:empty={!c}> <li class="character" class:empty={!c}>
{#if c}<img {#if c}<img
@ -96,7 +58,6 @@
<style lang="scss"> <style lang="scss">
@use '$src/themes/layout' as *; @use '$src/themes/layout' as *;
@use '$src/themes/spacing' as *; @use '$src/themes/spacing' as *;
@use '$src/themes/rep' as rep;
.rep { .rep {
width: 100%; width: 100%;
@ -106,14 +67,13 @@
.characters { .characters {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(5, 1fr);
gap: $unit-half; gap: $unit-half;
margin: 0; margin: 0;
padding: 0; padding: 0;
list-style: none; list-style: none;
.character, .character {
.protagonist {
aspect-ratio: 16/33; aspect-ratio: 16/33;
background: var(--placeholder-bg); background: var(--placeholder-bg);
border-radius: 4px; border-radius: 4px;
@ -125,62 +85,13 @@
background: var(--placeholder-bg); background: var(--placeholder-bg);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06);
} }
}
.character img { img {
display: block; display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
} }
}
.protagonist {
border-color: transparent;
border-width: 1px;
border-style: solid;
@include rep.aspect(32, 66);
img {
position: relative;
width: 100%;
height: 100%;
object-fit: cover;
}
&.wind {
background: var(--wind-portrait-bg);
border-color: var(--wind-bg);
}
&.fire {
background: var(--fire-portrait-bg);
border-color: var(--fire-bg);
}
&.water {
background: var(--water-portrait-bg);
border-color: var(--water-bg);
}
&.earth {
background: var(--earth-portrait-bg);
border-color: var(--earth-bg);
}
&.light {
background: var(--light-portrait-bg);
border-color: var(--light-bg);
}
&.dark {
background: var(--dark-portrait-bg);
border-color: var(--dark-bg);
}
&.empty {
background: var(--placeholder-bg);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06);
} }
} }
} }

View file

@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { GridCharacter } from '$lib/types/api/party' import type { GridCharacter } from '$lib/types/api/party'
import type { Party } from '$lib/types/api/party' import type { Party } from '$lib/types/api/party'
import type { Job } from '$lib/types/api/entities'
import { getContext } from 'svelte' import { getContext } from 'svelte'
import Icon from '$lib/components/Icon.svelte' import Icon from '$lib/components/Icon.svelte'
import UnitMenuContainer from '$lib/components/ui/menu/UnitMenuContainer.svelte' import UnitMenuContainer from '$lib/components/ui/menu/UnitMenuContainer.svelte'
@ -9,7 +8,6 @@
import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte' import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte'
import { getCharacterImageWithPose } from '$lib/utils/images' import { getCharacterImageWithPose } from '$lib/utils/images'
import { openDetailsSidebar } from '$lib/features/details/openDetailsSidebar.svelte' import { openDetailsSidebar } from '$lib/features/details/openDetailsSidebar.svelte'
import { getJobPortraitUrl, Gender } from '$lib/utils/jobUtils'
import { sidebar } from '$lib/stores/sidebar.svelte' import { sidebar } from '$lib/stores/sidebar.svelte'
import { GridType } from '$lib/types/enums' import { GridType } from '$lib/types/enums'
import perpetuityFilled from '$src/assets/icons/perpetuity/filled.svg' import perpetuityFilled from '$src/assets/icons/perpetuity/filled.svg'
@ -21,10 +19,9 @@
position: number position: number
mainWeaponElement?: number | null | undefined mainWeaponElement?: number | null | undefined
partyElement?: number | null | undefined partyElement?: number | null | undefined
job?: Job | undefined
} }
let { item, position, mainWeaponElement, partyElement, job }: Props = $props() let { item, position, mainWeaponElement, partyElement }: Props = $props()
type PartyCtx = { type PartyCtx = {
getParty: () => Party getParty: () => Party
@ -51,16 +48,6 @@
} }
// Use $derived to ensure consistent computation between server and client // Use $derived to ensure consistent computation between server and client
let imageUrl = $derived.by(() => { let imageUrl = $derived.by(() => {
// For protagonist slot (position 0) with a job, show job portrait
if (position === 0 && job) {
return getJobPortraitUrl(job, Gender.Gran) // TODO: Get gender from user preferences
}
// For protagonist slot without a job, show nothing (relief.png background shows through)
if (position === 0 && !job) {
return ''
}
// If no item or no character with granblueId, return placeholder // If no item or no character with granblueId, return placeholder
if (!item || !item.character?.granblueId) { if (!item || !item.character?.granblueId) {
return getCharacterImageWithPose(null, 'main', 0, 0) return getCharacterImageWithPose(null, 'main', 0, 0)
@ -76,16 +63,12 @@
) )
}) })
// Check if this is the protagonist slot
const isProtagonist = $derived(position === 0)
// Check if this item is currently active in the sidebar // Check if this item is currently active in the sidebar
let isActive = $derived(item?.id && sidebar.activeItemId === String(item.id)) let isActive = $derived(item?.id && sidebar.activeItemId === String(item.id))
// Check if this empty slot is currently selected for adding an item // Check if this empty slot is currently selected for adding an item
let isEmptySelected = $derived( let isEmptySelected = $derived(
!item && !item &&
!isProtagonist &&
ctx?.getSelectedSlot?.() === position && ctx?.getSelectedSlot?.() === position &&
ctx?.getActiveTab?.() === GridType.Character ctx?.getActiveTab?.() === GridType.Character
) )
@ -194,48 +177,44 @@
{#key item?.id ?? position} {#key item?.id ?? position}
<div <div
class="frame character cell {elementClass}" class="frame character cell {elementClass}"
class:protagonist={position === 0}
class:editable={ctx?.canEdit()} class:editable={ctx?.canEdit()}
class:is-active={isActive} class:is-active={isActive}
onclick={() => viewDetails()} onclick={() => viewDetails()}
> >
{#if position !== 0} {#if ctx?.canEdit()}
{#if ctx?.canEdit()} <button
<button class="perpetuity"
class="perpetuity" class:active={item.perpetuity}
class:active={item.perpetuity} onclick={togglePerpetuity}
onclick={togglePerpetuity} title={item.perpetuity ? 'Remove Perpetuity Ring' : 'Add Perpetuity Ring'}
title={item.perpetuity ? 'Remove Perpetuity Ring' : 'Add Perpetuity Ring'} >
>
<img
class="perpetuity-icon filled"
src={perpetuityFilled}
alt="Perpetuity Ring"
/>
<img
class="perpetuity-icon empty"
src={perpetuityEmpty}
alt="Add Perpetuity Ring"
/>
</button>
{:else if item.perpetuity}
<img <img
class="perpetuity static" class="perpetuity-icon filled"
src={perpetuityFilled} src={perpetuityFilled}
alt="Perpetuity Ring" alt="Perpetuity Ring"
title="Perpetuity Ring"
/> />
{/if} <img
class="perpetuity-icon empty"
src={perpetuityEmpty}
alt="Add Perpetuity Ring"
/>
</button>
{:else if item.perpetuity}
<img
class="perpetuity static"
src={perpetuityFilled}
alt="Perpetuity Ring"
title="Perpetuity Ring"
/>
{/if} {/if}
{#if imageUrl} {#if imageUrl}
<img <img
class="image {elementClass}" class="image {elementClass}"
class:placeholder={!item?.character?.granblueId && !isProtagonist} class:placeholder={!item?.character?.granblueId}
class:protagonist={isProtagonist} alt={displayName(item?.character)}
alt={isProtagonist && job ? job.name.en : displayName(item?.character)} src={imageUrl}
src={imageUrl} />
/> {/if}
{/if}
</div> </div>
{/key} {/key}
</div> </div>
@ -271,30 +250,19 @@
{#key `empty-${position}`} {#key `empty-${position}`}
<div <div
class="frame character cell" class="frame character cell"
class:editable={ctx?.canEdit() && !isProtagonist} class:editable={ctx?.canEdit()}
class:protagonist={isProtagonist}
class:empty-protagonist={isProtagonist && !job}
class:is-selected={isEmptySelected} class:is-selected={isEmptySelected}
onclick={() => onclick={() =>
!isProtagonist &&
ctx?.canEdit() && ctx?.canEdit() &&
ctx?.openPicker && ctx?.openPicker &&
ctx.openPicker({ type: 'character', position, item })} ctx.openPicker({ type: 'character', position, item })}
> >
{#if !isProtagonist} <img
<img class="image placeholder"
class="image placeholder" alt=""
alt="" src="/images/placeholders/placeholder-weapon-grid.png"
src="/images/placeholders/placeholder-weapon-grid.png" />
/> {#if ctx?.canEdit()}
{:else if job && imageUrl}
<img
class="image protagonist"
alt={job.name.en}
src={imageUrl}
/>
{/if}
{#if ctx?.canEdit() && !isProtagonist}
<span class="icon"> <span class="icon">
<Icon name="plus" size={24} /> <Icon name="plus" size={24} />
</span> </span>
@ -428,17 +396,6 @@
.frame.character.cell { .frame.character.cell {
@include rep.aspect(rep.$char-cell-w, rep.$char-cell-h); @include rep.aspect(rep.$char-cell-w, rep.$char-cell-h);
&.protagonist {
background-image: url('/images/relief.png'), linear-gradient(to right, #000, #484440, #000);
background-size: cover;
background-position: center -20px;
background-repeat: no-repeat;
&.empty-protagonist {
background-position: center 0;
}
}
} }
.image { .image {
@ -453,18 +410,6 @@
&.placeholder { &.placeholder {
opacity: 0; opacity: 0;
} }
&.protagonist {
object-fit: cover;
opacity: 1;
}
}
.frame.protagonist {
// Protagonist slot may have different aspect ratio for job portraits
.image {
object-fit: cover;
}
} }
.icon { .icon {