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

View file

@ -1,6 +1,7 @@
<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 { partyStore } from '$lib/stores/partyStore.svelte'
// TanStack Query mutations - Grid
import {
@ -84,6 +85,16 @@
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 loading = $state(false)
let error = $state<string | null>(null)
@ -249,14 +260,12 @@
function handleTabChange(tab: GridType) {
activeTab = 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)
if (nextEmpty !== SLOT_NOT_FOUND) {
selectedSlot = nextEmpty
} else {
// Fallback: Characters start at 1 (skip protagonist), others at 0
selectedSlot = tab === GridType.Character ? 1 : 0
// Fallback: all grid types start at 0
selectedSlot = 0
}
}
@ -902,7 +911,6 @@
characters={party.characters}
{mainWeaponElement}
{partyElement}
job={party.job}
/>
</div>
{/if}

View file

@ -2,7 +2,6 @@
<svelte:options runes={true} />
<script lang="ts">
import { getContext } from 'svelte'
import type { Party, GridCharacter, GridWeapon, GridSummon } from '$lib/types/api/party'
import { GridType } from '$lib/types/enums'
import SegmentedControl from '$lib/components/ui/segmented-control/SegmentedControl.svelte'
@ -26,8 +25,6 @@
const weapons = $derived(party.weapons)
const summons = $derived(party.summons)
const characters = $derived(party.characters)
const job = $derived(party.job)
const element = $derived(party.element)
// Handle value changes
let value = $state(selectedTab)
@ -41,10 +38,6 @@
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>
<nav class={className}>
@ -54,12 +47,7 @@
label={m.party_segmented_control_characters()}
selected={value === GridType.Character}
>
<CharacterRep
job={job}
element={element}
gender={userGender}
characters={characters}
/>
<CharacterRep characters={characters} />
</RepSegment>
<RepSegment

View file

@ -1,49 +1,21 @@
<script lang="ts">
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 { getJobPortraitUrl, Gender } from '$lib/utils/jobUtils'
interface Props {
party?: Party
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
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)
))
// 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 {
const id = c?.character?.granblueId
if (!id) return ''
@ -70,16 +42,6 @@
<div class="rep">
<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}
<li class="character" class:empty={!c}>
{#if c}<img
@ -96,7 +58,6 @@
<style lang="scss">
@use '$src/themes/layout' as *;
@use '$src/themes/spacing' as *;
@use '$src/themes/rep' as rep;
.rep {
width: 100%;
@ -106,14 +67,13 @@
.characters {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(5, 1fr);
gap: $unit-half;
margin: 0;
padding: 0;
list-style: none;
.character,
.protagonist {
.character {
aspect-ratio: 16/33;
background: var(--placeholder-bg);
border-radius: 4px;
@ -125,62 +85,13 @@
background: var(--placeholder-bg);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06);
}
}
.character img {
display: block;
width: 100%;
height: 100%;
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);
img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
}

View file

@ -1,7 +1,6 @@
<script lang="ts">
import type { GridCharacter } 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 Icon from '$lib/components/Icon.svelte'
import UnitMenuContainer from '$lib/components/ui/menu/UnitMenuContainer.svelte'
@ -9,7 +8,6 @@
import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte'
import { getCharacterImageWithPose } from '$lib/utils/images'
import { openDetailsSidebar } from '$lib/features/details/openDetailsSidebar.svelte'
import { getJobPortraitUrl, Gender } from '$lib/utils/jobUtils'
import { sidebar } from '$lib/stores/sidebar.svelte'
import { GridType } from '$lib/types/enums'
import perpetuityFilled from '$src/assets/icons/perpetuity/filled.svg'
@ -21,10 +19,9 @@
position: number
mainWeaponElement?: 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 = {
getParty: () => Party
@ -51,16 +48,6 @@
}
// Use $derived to ensure consistent computation between server and client
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 (!item || !item.character?.granblueId) {
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
let isActive = $derived(item?.id && sidebar.activeItemId === String(item.id))
// Check if this empty slot is currently selected for adding an item
let isEmptySelected = $derived(
!item &&
!isProtagonist &&
ctx?.getSelectedSlot?.() === position &&
ctx?.getActiveTab?.() === GridType.Character
)
@ -194,48 +177,44 @@
{#key item?.id ?? position}
<div
class="frame character cell {elementClass}"
class:protagonist={position === 0}
class:editable={ctx?.canEdit()}
class:is-active={isActive}
onclick={() => viewDetails()}
>
{#if position !== 0}
{#if ctx?.canEdit()}
<button
class="perpetuity"
class:active={item.perpetuity}
onclick={togglePerpetuity}
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}
{#if ctx?.canEdit()}
<button
class="perpetuity"
class:active={item.perpetuity}
onclick={togglePerpetuity}
title={item.perpetuity ? 'Remove Perpetuity Ring' : 'Add Perpetuity Ring'}
>
<img
class="perpetuity static"
class="perpetuity-icon filled"
src={perpetuityFilled}
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 imageUrl}
<img
class="image {elementClass}"
class:placeholder={!item?.character?.granblueId && !isProtagonist}
class:protagonist={isProtagonist}
alt={isProtagonist && job ? job.name.en : displayName(item?.character)}
src={imageUrl}
/>
{/if}
<img
class="image {elementClass}"
class:placeholder={!item?.character?.granblueId}
alt={displayName(item?.character)}
src={imageUrl}
/>
{/if}
</div>
{/key}
</div>
@ -271,30 +250,19 @@
{#key `empty-${position}`}
<div
class="frame character cell"
class:editable={ctx?.canEdit() && !isProtagonist}
class:protagonist={isProtagonist}
class:empty-protagonist={isProtagonist && !job}
class:editable={ctx?.canEdit()}
class:is-selected={isEmptySelected}
onclick={() =>
!isProtagonist &&
ctx?.canEdit() &&
ctx?.openPicker &&
ctx.openPicker({ type: 'character', position, item })}
>
{#if !isProtagonist}
<img
class="image placeholder"
alt=""
src="/images/placeholders/placeholder-weapon-grid.png"
/>
{:else if job && imageUrl}
<img
class="image protagonist"
alt={job.name.en}
src={imageUrl}
/>
{/if}
{#if ctx?.canEdit() && !isProtagonist}
<img
class="image placeholder"
alt=""
src="/images/placeholders/placeholder-weapon-grid.png"
/>
{#if ctx?.canEdit()}
<span class="icon">
<Icon name="plus" size={24} />
</span>
@ -428,17 +396,6 @@
.frame.character.cell {
@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 {
@ -453,18 +410,6 @@
&.placeholder {
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 {