feat: add uncap indicators to units and database

This commit is contained in:
Justin Edmund 2025-09-17 10:43:32 -07:00
parent 9764c80771
commit ff711331d4
15 changed files with 1264 additions and 31 deletions

View file

@ -0,0 +1,60 @@
<svelte:options runes={true} />
<script lang="ts">
import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte'
interface Props {
row: any
type: 'character' | 'weapon' | 'summon'
}
let { row, type }: Props = $props()
// Extract uncap data from row
const uncapData = $derived(() => {
// Direct properties on the row
const uncapLevel = row.uncap_level ?? row.max_level ?? 3
const transcendenceStage = row.transcendence_step ?? 0
// Check for uncap object if it exists
const uncap = row.uncap ?? {}
const flb = uncap.flb ?? false
const ulb = uncap.ulb ?? false
const transcendence = uncap.transcendence ?? false
// Special flag for characters
const special = type === 'character' && row.special
return {
uncapLevel,
transcendenceStage,
flb,
ulb,
transcendence,
special
}
})
</script>
<div class="uncap-cell">
<UncapIndicator
{type}
uncapLevel={uncapData().uncapLevel}
transcendenceStage={uncapData().transcendenceStage}
flb={uncapData().flb}
ulb={uncapData().ulb}
transcendence={uncapData().transcendence}
special={uncapData().special}
editable={false}
/>
</div>
<style lang="scss">
.uncap-cell {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: 4px;
}
</style>

View file

@ -0,0 +1,68 @@
<svelte:options runes={true} />
<script lang="ts">
import type { IRow } from 'wx-svelte-grid'
import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte'
interface Props {
row: IRow
}
let { row }: Props = $props()
// For database view, show maximum possible uncap level
// Not the user's current uncap level
const uncap = $derived(row.uncap ?? {})
const flb = $derived(uncap.flb ?? false)
const ulb = $derived(uncap.ulb ?? false)
const transcendence = $derived(uncap.transcendence ?? false)
const special = $derived(row.special ?? false)
// Calculate maximum uncap level based on available uncaps
const getMaxUncapLevel = () => {
if (special) {
// Special characters: 3 base + FLB + ULB
return ulb ? 5 : flb ? 4 : 3
} else {
// Regular characters: 4 base + FLB + ULB/transcendence
return ulb ? 6 : flb ? 5 : 4
}
}
const uncapLevel = $derived(getMaxUncapLevel())
// For database view, show maximum transcendence stage when available
// Only regular (non-special) characters have transcendence
const transcendenceStage = $derived(
// Special characters don't have transcendence
special ? 0 :
// First check if API provides direct transcendence_step field on the row
row.transcendence_step ? row.transcendence_step :
// Check if API provides specific max transcendence step in uncap object
uncap.max_transcendence_step ? uncap.max_transcendence_step :
// Otherwise, show maximum stage (5) when transcendence is available for regular characters
transcendence ? 5 : 0
)
</script>
<div class="uncap-cell">
<UncapIndicator
type="character"
{uncapLevel}
{transcendenceStage}
{flb}
{ulb}
{transcendence}
{special}
editable={false}
/>
</div>
<style lang="scss">
.uncap-cell {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: 4px;
}
</style>

View file

@ -0,0 +1,144 @@
<svelte:options runes={true} />
<script lang="ts">
import type { ComponentProps } from 'svelte'
import type { Cell } from 'wx-svelte-grid'
type Props = ComponentProps<Cell> & {
row: any
}
const { row }: Props = $props()
// Get the most recent date from various date fields
const getLastUpdated = (item: any): Date | null => {
if (!item) return null
const dates: Date[] = []
// Check for date fields in the API response
if (item.release_date) {
const date = new Date(item.release_date)
if (!isNaN(date.getTime())) dates.push(date)
}
if (item.flb_date) {
const date = new Date(item.flb_date)
if (!isNaN(date.getTime())) dates.push(date)
}
if (item.ulb_date) {
const date = new Date(item.ulb_date)
if (!isNaN(date.getTime())) dates.push(date)
}
if (item.transcendence_date) {
const date = new Date(item.transcendence_date)
if (!isNaN(date.getTime())) dates.push(date)
}
// Return the most recent date
if (dates.length === 0) return null
return dates.reduce((latest, current) => (current > latest ? current : latest))
}
const formatDate = (date: Date | null): string => {
if (!date) return '—'
// Format as YYYY-MM-DD
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const lastUpdated = $derived(getLastUpdated(row))
const formattedDate = $derived(formatDate(lastUpdated))
// Determine what type of update this was
const getUpdateType = (item: any): string => {
const lastDate = getLastUpdated(item)
if (!lastDate) return ''
const lastTime = lastDate.getTime()
// Compare timestamps to determine which date field matches
if (item.transcendence_date) {
const transcendDate = new Date(item.transcendence_date)
if (!isNaN(transcendDate.getTime()) && transcendDate.getTime() === lastTime) {
return 'Transcendence'
}
}
if (item.ulb_date) {
const ulbDate = new Date(item.ulb_date)
if (!isNaN(ulbDate.getTime()) && ulbDate.getTime() === lastTime) {
// Characters with transcendence have their "ULB" date but it's actually transcendence
// Check if this is a character by looking for character-specific fields
// Characters have 'race' and 'proficiency' arrays, weapons/summons don't have 'race'
const isCharacter = Array.isArray(item.race) && Array.isArray(item.proficiency)
return isCharacter ? 'Transcendence' : 'ULB'
}
}
if (item.flb_date) {
const flbDate = new Date(item.flb_date)
if (!isNaN(flbDate.getTime()) && flbDate.getTime() === lastTime) {
return 'FLB'
}
}
if (item.release_date) {
const releaseDate = new Date(item.release_date)
if (!isNaN(releaseDate.getTime()) && releaseDate.getTime() === lastTime) {
return 'Release'
}
}
return ''
}
const updateType = $derived(getUpdateType(row))
</script>
<div class="last-updated-cell">
<span class="date">{formattedDate}</span>
{#if updateType}
<span class="type" data-type={updateType.toLowerCase()}>{updateType}</span>
{/if}
</div>
<style lang="scss">
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
.last-updated-cell {
display: flex;
flex-direction: column;
gap: spacing.$unit * 0.25;
padding: spacing.$unit * 0.5 0;
.date {
font-size: typography.$font-small;
color: var(--text-primary);
}
.type {
font-size: typography.$font-tiny;
font-weight: typography.$medium;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
&[data-type='transcendence'] {
color: #9b59b6;
}
&[data-type='ulb'] {
color: #3498db;
}
&[data-type='flb'] {
color: #f39c12;
}
&[data-type='release'] {
color: #95a5a6;
}
}
}
</style>

View file

@ -0,0 +1,57 @@
<svelte:options runes={true} />
<script lang="ts">
import type { IRow } from 'wx-svelte-grid'
import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte'
interface Props {
row: IRow
}
let { row }: Props = $props()
// For database view, show maximum possible uncap level
const uncap = $derived(row.uncap ?? {})
const flb = $derived(uncap.flb ?? false)
const ulb = $derived(uncap.ulb ?? false)
const transcendence = $derived(uncap.transcendence ?? false)
// Calculate maximum uncap level based on available uncaps
// Summons: 3 base + FLB + ULB + transcendence
const getMaxUncapLevel = () => {
return transcendence ? 6 : ulb ? 5 : flb ? 4 : 3
}
const uncapLevel = $derived(getMaxUncapLevel())
// For database view, show maximum transcendence stage when available
const transcendenceStage = $derived(
// First check if API provides direct transcendence_step field on the row
row.transcendence_step ? row.transcendence_step :
// Check if API provides specific max transcendence step in uncap object
uncap.max_transcendence_step ? uncap.max_transcendence_step :
// Otherwise, show maximum stage (5) when transcendence is available
transcendence ? 5 : 0
)
</script>
<div class="uncap-cell">
<UncapIndicator
type="summon"
{uncapLevel}
{transcendenceStage}
{flb}
{ulb}
{transcendence}
editable={false}
/>
</div>
<style lang="scss">
.uncap-cell {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: 4px;
}
</style>

View file

@ -0,0 +1,57 @@
<svelte:options runes={true} />
<script lang="ts">
import type { IRow } from 'wx-svelte-grid'
import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte'
interface Props {
row: IRow
}
let { row }: Props = $props()
// For database view, show maximum possible uncap level
const uncap = $derived(row.uncap ?? {})
const flb = $derived(uncap.flb ?? false)
const ulb = $derived(uncap.ulb ?? false)
const transcendence = $derived(uncap.transcendence ?? false)
// Calculate maximum uncap level based on available uncaps
// Weapons: 3 base + FLB + ULB + transcendence
const getMaxUncapLevel = () => {
return transcendence ? 6 : ulb ? 5 : flb ? 4 : 3
}
const uncapLevel = $derived(getMaxUncapLevel())
// For database view, show maximum transcendence stage when available
const transcendenceStage = $derived(
// First check if API provides direct transcendence_step field on the row
row.transcendence_step ? row.transcendence_step :
// Check if API provides specific max transcendence step in uncap object
uncap.max_transcendence_step ? uncap.max_transcendence_step :
// Otherwise, show maximum stage (5) when transcendence is available
transcendence ? 5 : 0
)
</script>
<div class="uncap-cell">
<UncapIndicator
type="weapon"
{uncapLevel}
{transcendenceStage}
{flb}
{ulb}
{transcendence}
editable={false}
/>
</div>
<style lang="scss">
.uncap-cell {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: 4px;
}
</style>

View file

@ -0,0 +1,116 @@
<svelte:options runes={true} />
<script lang="ts">
interface Props {
stage: number
interactive?: boolean
visible?: boolean
onClick?: (index: number) => void
onHover?: (index: number) => void
}
let {
stage,
interactive = false,
visible = false,
onClick,
onHover
}: Props = $props()
function handleClick() {
if (interactive && onClick) {
onClick(stage)
}
}
function handleHover() {
if (interactive && onHover) {
onHover(stage)
}
}
</script>
<i
class="fragment"
class:visible
class:stage1={stage === 1}
class:stage2={stage === 2}
class:stage3={stage === 3}
class:stage4={stage === 4}
class:stage5={stage === 5}
onclick={handleClick}
onmouseover={handleHover}
role={interactive ? 'button' : undefined}
aria-label={interactive ? `Transcendence fragment ${stage}` : undefined}
/>
<style lang="scss">
@use '$src/themes/spacing' as spacing;
.fragment {
--degrees: 72deg;
--orig-width: 29px;
--orig-height: 54px;
--scaled-width: 12px;
--scaled-height: calc((var(--scaled-width) / var(--orig-width)) * var(--orig-height));
--scale: 1.2;
background-image: url('/icons/transcendence/interactive/interactive-piece.png');
background-size: var(--scaled-width) var(--scaled-height);
background-repeat: no-repeat;
position: absolute;
z-index: 32;
aspect-ratio: 29 / 54;
height: var(--scaled-height);
width: var(--scaled-width);
opacity: 0;
&:hover {
cursor: pointer;
}
&.visible {
opacity: 1;
}
&.stage1 {
top: 3px;
left: 18px;
}
&.stage2 {
top: 10px;
left: 27px;
transform: rotate(var(--degrees));
}
&.stage3 {
top: 21px;
left: 24px;
transform: rotate(calc(var(--degrees) * 2));
}
&.stage4 {
top: 21px;
left: 12px;
transform: rotate(calc(var(--degrees) * 3));
}
&.stage5 {
top: 10px;
left: 8px;
transform: rotate(calc(var(--degrees) * 4));
}
/* High DPI support */
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
background-image: url('/icons/transcendence/interactive/interactive-piece@2x.png');
}
@media (-webkit-min-device-pixel-ratio: 3), (min-resolution: 288dpi) {
background-image: url('/icons/transcendence/interactive/interactive-piece@3x.png');
}
}
</style>

View file

@ -0,0 +1,237 @@
<svelte:options runes={true} />
<script lang="ts">
import TranscendenceFragment from './TranscendenceFragment.svelte'
interface Props {
className?: string
stage?: number
editable?: boolean
interactive?: boolean
tabIndex?: number
onStarClick?: () => void
onFragmentClick?: (newStage: number) => void
onFragmentHover?: (newStage: number) => void
}
let {
className,
stage = 0,
editable = false,
interactive = false,
tabIndex,
onStarClick,
onFragmentClick,
onFragmentHover
}: Props = $props()
const NUM_FRAGMENTS = 5
let visibleStage = $state(stage)
let currentStage = $state(stage)
let immutable = $state(false)
let starElement: HTMLDivElement
$effect(() => {
visibleStage = stage
currentStage = stage
})
function handleClick() {
if (editable && onStarClick) {
onStarClick()
}
}
function handleFragmentClick(index: number) {
let newStage = index
if (index === currentStage) {
newStage = 0
}
visibleStage = newStage
currentStage = newStage
if (onFragmentClick) {
onFragmentClick(newStage)
}
}
function handleFragmentHover(index: number) {
visibleStage = index
if (onFragmentHover) {
onFragmentHover(index)
}
}
function handleMouseLeave() {
visibleStage = currentStage
if (onFragmentHover) {
onFragmentHover(currentStage)
}
}
</script>
<div
class="star TranscendenceStar"
class:immutable
class:empty={stage === 0}
class:stage1={stage === 1}
class:stage2={stage === 2}
class:stage3={stage === 3}
class:stage4={stage === 4}
class:stage5={stage === 5}
onclick={handleClick}
onmouseleave={interactive ? handleMouseLeave : undefined}
bind:this={starElement}
{tabIndex}
role={editable ? 'button' : undefined}
aria-label={editable ? 'Transcendence star' : undefined}
>
<div class="fragments">
{#if interactive}
{#each Array(NUM_FRAGMENTS) as _, i}
{@const loopStage = i + 1}
<TranscendenceFragment
stage={loopStage}
visible={loopStage <= visibleStage}
{interactive}
onClick={handleFragmentClick}
onHover={handleFragmentHover}
/>
{/each}
{/if}
</div>
<i class="figure {className || ''}" class:interactive class:base={className?.includes('base')} />
</div>
<style lang="scss">
@use '$src/themes/spacing' as spacing;
.star {
--size: 18px;
position: relative;
cursor: pointer;
&:hover {
transform: scale(1.2);
}
&.immutable {
pointer-events: none;
}
&.empty {
background-image: url('/icons/transcendence/0/stage-0.png');
background-size: var(--size) var(--size);
background-repeat: no-repeat;
}
&.stage1 {
background-image: url('/icons/transcendence/1/stage-1.png');
background-size: var(--size) var(--size);
background-repeat: no-repeat;
}
&.stage2 {
background-image: url('/icons/transcendence/2/stage-2.png');
background-size: var(--size) var(--size);
background-repeat: no-repeat;
}
&.stage3 {
background-image: url('/icons/transcendence/3/stage-3.png');
background-size: var(--size) var(--size);
background-repeat: no-repeat;
}
&.stage4 {
background-image: url('/icons/transcendence/4/stage-4.png');
background-size: var(--size) var(--size);
background-repeat: no-repeat;
}
&.stage5 {
background-image: url('/icons/transcendence/5/stage-5.png');
background-size: var(--size) var(--size);
background-repeat: no-repeat;
}
/* High DPI support */
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
&.empty {
background-image: url('/icons/transcendence/0/stage-0@2x.png');
}
&.stage1 {
background-image: url('/icons/transcendence/1/stage-1@2x.png');
}
&.stage2 {
background-image: url('/icons/transcendence/2/stage-2@2x.png');
}
&.stage3 {
background-image: url('/icons/transcendence/3/stage-3@2x.png');
}
&.stage4 {
background-image: url('/icons/transcendence/4/stage-4@2x.png');
}
&.stage5 {
background-image: url('/icons/transcendence/5/stage-5@2x.png');
}
}
@media (-webkit-min-device-pixel-ratio: 3), (min-resolution: 288dpi) {
&.empty {
background-image: url('/icons/transcendence/0/stage-0@3x.png');
}
&.stage1 {
background-image: url('/icons/transcendence/1/stage-1@3x.png');
}
&.stage2 {
background-image: url('/icons/transcendence/2/stage-2@3x.png');
}
&.stage3 {
background-image: url('/icons/transcendence/3/stage-3@3x.png');
}
&.stage4 {
background-image: url('/icons/transcendence/4/stage-4@3x.png');
}
&.stage5 {
background-image: url('/icons/transcendence/5/stage-5@3x.png');
}
}
.figure {
--size: 18px;
background-repeat: no-repeat;
background-size: 54px 54px;
display: block;
height: var(--size);
width: var(--size);
&.interactive.base {
--size: calc(spacing.$unit * 6);
background-image: url('/icons/transcendence/interactive/interactive-base.png');
background-size: var(--size) var(--size);
height: var(--size);
width: var(--size);
&:hover {
cursor: pointer;
transform: none;
}
/* High DPI support */
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
background-image: url('/icons/transcendence/interactive/interactive-base@2x.png');
}
@media (-webkit-min-device-pixel-ratio: 3), (min-resolution: 288dpi) {
background-image: url('/icons/transcendence/interactive/interactive-base@3x.png');
}
}
&:hover {
transform: scale(1.2);
}
}
}
</style>

View file

@ -0,0 +1,184 @@
<svelte:options runes={true} />
<script lang="ts">
import UncapStar from './UncapStar.svelte'
import TranscendenceStar from './TranscendenceStar.svelte'
interface Props {
type: 'character' | 'weapon' | 'summon'
rarity?: number
uncapLevel?: number
transcendenceStage?: number
flb?: boolean
ulb?: boolean
transcendence?: boolean
special?: boolean
className?: string
editable?: boolean
updateUncap?: (index: number) => void
updateTranscendence?: (index: number) => void
}
interface StarRender {
type: 'uncap' | 'transcendence'
props: Record<string, any>
}
let {
type,
rarity,
uncapLevel = 0,
transcendenceStage = 0,
flb = false,
ulb = false,
transcendence = false,
special = false,
className,
editable = false,
updateUncap,
updateTranscendence
}: Props = $props()
// Calculate the total number of stars to display
const getNumStars = () => {
if (type === 'character') {
if (special) {
// Special characters: 3 base + FLB + ULB
return ulb ? 5 : flb ? 4 : 3
} else {
// Regular characters: 4 base + FLB + transcendence (ulb flag = transcendence for regular chars)
return ulb ? 6 : flb ? 5 : 4
}
} else {
// Weapons and summons: 3 base + FLB + ULB + transcendence
return transcendence ? 6 : ulb ? 5 : flb ? 4 : 3
}
}
const numStars = $derived(getNumStars())
function toggleStar(index: number, empty: boolean) {
if (updateUncap && editable) {
if (empty && index > 0) {
updateUncap(index + 1)
} else {
updateUncap(index)
}
}
}
function handleTranscendenceUpdate(stage: number) {
if (updateTranscendence && editable) {
updateTranscendence(stage)
}
}
// Helper function to create star props
const createStarProps = (starType: 'uncap' | 'transcendence', options: any = {}): StarRender => {
if (starType === 'transcendence') {
return {
type: 'transcendence',
props: {
stage: transcendenceStage,
editable,
interactive: editable,
onFragmentClick: editable ? handleTranscendenceUpdate : undefined
}
}
}
return {
type: 'uncap',
props: {
empty: options.index >= uncapLevel,
index: options.index,
flb: options.flb,
ulb: options.ulb,
special: options.special,
tabIndex: editable ? 0 : undefined,
onStarClick: editable ? toggleStar : undefined
}
}
}
// Determine what type of star to render at each position
function renderStar(index: number): StarRender | null {
// Handle transcendence star (always at position 5)
if (index === 5) {
if (type === 'character' && !special && transcendence) {
// Regular character with transcendence (note: uses transcendence flag, not ulb)
return createStarProps('transcendence')
}
if ((type === 'weapon' || type === 'summon') && transcendence) {
// Weapon/summon with transcendence
return createStarProps('transcendence')
}
return null
}
// Handle ULB star
if (index === 4) {
if (type === 'character' && special && ulb) {
// Special character ULB at position 4
return createStarProps('uncap', { index, ulb: true })
}
if (type === 'weapon' && ulb) {
// Weapon ULB at position 4 (blue, not purple)
return createStarProps('uncap', { index, flb: true })
}
if (type === 'summon' && ulb) {
// Summon ULB at position 4 (blue, not purple)
return createStarProps('uncap', { index, flb: true })
}
}
// Handle FLB star
if (index === 3) {
if ((type === 'weapon' || type === 'summon' || (type === 'character' && special)) && flb) {
// Weapon/summon/special character FLB at position 3
return createStarProps('uncap', { index, flb: true })
}
}
if (index === 4 && type === 'character' && !special && flb) {
// Regular character FLB at position 4
return createStarProps('uncap', { index, flb: true })
}
// Handle regular MLB stars (positions 0-2 for weapons/summons/special chars, 0-3 for regular chars)
if (index < 3 || (type === 'character' && !special && index === 3)) {
return createStarProps('uncap', { index })
}
return null
}
</script>
<div class="uncap-indicator {className || ''}">
<ul class="stars">
{#each Array(numStars) as _, i}
{@const star = renderStar(i)}
{#if star}
{#if star.type === 'transcendence'}
<TranscendenceStar {...star.props} />
{:else}
<UncapStar {...star.props} />
{/if}
{/if}
{/each}
</ul>
</div>
<style lang="scss">
.uncap-indicator {
display: inline-block;
}
.stars {
display: flex;
gap: 2px;
list-style: none;
margin: 0;
padding: 0;
align-items: center;
}
</style>

View file

@ -0,0 +1,109 @@
<svelte:options runes={true} />
<script lang="ts">
interface Props {
empty?: boolean
special?: boolean
flb?: boolean
ulb?: boolean
index: number
tabIndex?: number
onStarClick: (index: number, empty: boolean) => void
}
let {
empty = false,
special = false,
flb = false,
ulb = false,
index,
tabIndex,
onStarClick
}: Props = $props()
function handleClick() {
onStarClick(index, empty)
}
</script>
<li
class="star"
class:empty
class:special
class:mlb={!special}
class:flb
class:ulb
{tabIndex}
onclick={handleClick}
role="button"
aria-label="Uncap star"
></li>
<style lang="scss">
.star {
--size: 18px;
background-repeat: no-repeat;
background-size: var(--size);
display: block;
height: var(--size);
width: var(--size);
cursor: pointer;
&:hover {
transform: scale(1.2);
}
&.empty,
&.empty.mlb,
&.empty.flb,
&.empty.ulb,
&.empty.special {
background-image: url('/icons/uncap/empty@3x.png');
&:hover {
background-image: url('/icons/uncap/empty-hover@3x.png');
}
}
&.mlb {
background-image: url('/icons/uncap/yellow@3x.png');
&:hover {
background-image: url('/icons/uncap/yellow-hover@3x.png');
}
}
&.special {
background-image: url('/icons/uncap/red@3x.png');
&:hover {
background-image: url('/icons/uncap/red-hover@3x.png');
}
}
&.flb {
background-image: url('/icons/uncap/blue@3x.png');
&:hover {
background-image: url('/icons/uncap/blue-hover@3x.png');
}
}
&.ulb {
background-image: url('/icons/uncap/purple@3x.png');
&:hover {
background-image: url('/icons/uncap/purple-hover@3x.png');
}
}
@media (max-width: 768px) {
--size: 14px;
background-size: cover;
&:hover {
transform: scale(1);
}
}
}
</style>

View file

@ -5,6 +5,7 @@
import Icon from '$lib/components/Icon.svelte'
import ContextMenu from '$lib/components/ui/ContextMenu.svelte'
import { ContextMenu as ContextMenuBase } from 'bits-ui'
import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte'
interface Props {
item?: GridCharacter
@ -33,15 +34,12 @@
}
// 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) {
// If no item or no character with granblueId, return placeholder
if (!item || !item.character?.granblueId) {
return '/images/placeholders/placeholder-weapon-grid.png'
}
const id = characterData.granblueId
const id = item.character.granblueId
const uncap = item?.uncapLevel ?? 0
const transStep = item?.transcendenceStep ?? 0
let suffix = '01'
@ -87,7 +85,7 @@
{#if item}
<ContextMenu>
{#snippet children()}
{#key (item as any).id ?? position}
{#key item?.id ?? position}
<div
class="frame character cell"
class:editable={ctx?.canEdit()}
@ -95,8 +93,8 @@
>
<img
class="image"
class:placeholder={!(item?.character?.granblueId || (item as any)?.object?.granblueId)}
alt={displayName(item?.character || (item as any)?.object)}
class:placeholder={!item?.character?.granblueId}
alt={displayName(item?.character)}
src={imageUrl()}
/>
{#if ctx?.canEdit() && item?.id}
@ -143,7 +141,46 @@
</div>
{/key}
{/if}
<div class="name">{item ? displayName(item?.character || (item as any)?.object) : ''}</div>
{#if item}
<UncapIndicator
type="character"
uncapLevel={item.uncapLevel}
transcendenceStage={item.transcendenceStep}
special={item.character?.special}
flb={item.character?.uncap?.flb}
ulb={item.character?.uncap?.ulb}
editable={ctx?.canEdit()}
updateUncap={async (level) => {
if (!item?.id || !ctx) return
try {
const editKey = ctx.getEditKey()
const updated = await ctx.services.gridService.updateCharacterUncap(item.id, level, undefined, editKey || undefined)
if (updated) {
ctx.updateParty(updated)
}
} catch (err) {
console.error('Failed to update character 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.updateCharacterUncap(item.id, maxUncap, stage, editKey || undefined)
if (updated) {
ctx.updateParty(updated)
}
} catch (err) {
console.error('Failed to update character transcendence:', err)
// TODO: Show user-friendly error notification
}
}}
/>
{/if}
<div class="name">{item ? displayName(item?.character) : ''}</div>
</div>
<style lang="scss">

View file

@ -5,6 +5,7 @@
import Icon from '$lib/components/Icon.svelte'
import ContextMenu from '$lib/components/ui/ContextMenu.svelte'
import { ContextMenu as ContextMenuBase } from 'bits-ui'
import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte'
interface Props {
item?: GridSummon
@ -34,17 +35,14 @@
// 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) {
// If no item or no summon with granblueId, return placeholder
if (!item || !item.summon?.granblueId) {
return isMain
? '/images/placeholders/placeholder-summon-main.png'
: '/images/placeholders/placeholder-summon-grid.png'
}
const id = summonData.granblueId
const id = item.summon.granblueId
const folder = isMain ? 'summon-main' : 'summon-grid'
return `/images/${folder}/${id}.jpg`
})
@ -81,7 +79,7 @@
{#if item}
<ContextMenu>
{#snippet children()}
{#key (item as any).id ?? position}
{#key item?.id ?? position}
<div
class="frame summon"
class:main={item?.main || position === -1}
@ -92,8 +90,8 @@
>
<img
class="image"
class:placeholder={!(item?.summon?.granblueId || (item as any)?.object?.granblueId)}
alt={displayName(item?.summon || (item as any)?.object)}
class:placeholder={!item?.summon?.granblueId}
alt={displayName(item?.summon)}
src={imageUrl()}
/>
{#if ctx?.canEdit() && item?.id}
@ -149,7 +147,46 @@
</div>
{/key}
{/if}
<div class="name">{item ? displayName(item?.summon || (item as any)?.object) : ''}</div>
{#if item}
<UncapIndicator
type="summon"
uncapLevel={item.uncapLevel}
transcendenceStage={item.transcendenceStep}
flb={item.summon?.uncap?.flb}
ulb={item.summon?.uncap?.ulb}
transcendence={item.summon?.uncap?.transcendence}
editable={ctx?.canEdit()}
updateUncap={async (level) => {
if (!item?.id || !ctx) return
try {
const editKey = ctx.getEditKey()
const updated = await ctx.services.gridService.updateSummonUncap(item.id, level, undefined, editKey || undefined)
if (updated) {
ctx.updateParty(updated)
}
} catch (err) {
console.error('Failed to update summon 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.updateSummonUncap(item.id, maxUncap, stage, editKey || undefined)
if (updated) {
ctx.updateParty(updated)
}
} catch (err) {
console.error('Failed to update summon transcendence:', err)
// TODO: Show user-friendly error notification
}
}}
/>
{/if}
<div class="name">{item ? displayName(item?.summon) : ''}</div>
</div>
<style lang="scss">

View file

@ -5,6 +5,7 @@
import Icon from '$lib/components/Icon.svelte'
import ContextMenu from '$lib/components/ui/ContextMenu.svelte'
import { ContextMenu as ContextMenuBase } from 'bits-ui'
import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte'
interface Props {
item?: GridWeapon
@ -36,19 +37,16 @@
// 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) {
// If no item or no weapon with granblueId, return placeholder
if (!item || !item.weapon?.granblueId) {
return isMain
? '/images/placeholders/placeholder-weapon-main.png'
: '/images/placeholders/placeholder-weapon-grid.png'
}
const id = weaponData.granblueId
const id = item.weapon.granblueId
const folder = isMain ? 'weapon-main' : 'weapon-grid'
const objElement = weaponData?.element
const objElement = item.weapon?.element
const instElement = item?.element
if (objElement === 0 && instElement) {
@ -89,7 +87,7 @@
{#if item}
<ContextMenu>
{#snippet children()}
{#key (item as any).id ?? position}
{#key item?.id ?? position}
<div
class="frame weapon"
class:main={item?.mainhand || position === -1}
@ -98,8 +96,8 @@
>
<img
class="image"
class:placeholder={!(item?.weapon?.granblueId || (item as any)?.object?.granblueId)}
alt={displayName(item?.weapon || (item as any)?.object)}
class:placeholder={!item?.weapon?.granblueId}
alt={displayName(item?.weapon)}
src={imageUrl()}
/>
{#if ctx?.canEdit() && item?.id}
@ -151,7 +149,46 @@
</div>
{/key}
{/if}
<div class="name">{item ? displayName(item?.weapon || (item as any)?.object) : ''}</div>
{#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">

View file

@ -0,0 +1,30 @@
import { json, type RequestHandler } from '@sveltejs/kit'
import { buildUrl } from '$lib/api/core'
export const POST: RequestHandler = async ({ request, fetch }) => {
try {
const body = await request.json()
const editKey = request.headers.get('X-Edit-Key')
// Forward to Rails API with automatic auth via handleFetch
const response = await fetch(buildUrl('/characters/update_uncap'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(editKey ? { 'X-Edit-Key': editKey } : {})
},
body: JSON.stringify(body)
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }))
return json(error, { status: response.status })
}
const data = await response.json()
return json(data)
} catch (error) {
console.error('Error updating character uncap:', error)
return json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -0,0 +1,30 @@
import { json, type RequestHandler } from '@sveltejs/kit'
import { buildUrl } from '$lib/api/core'
export const POST: RequestHandler = async ({ request, fetch }) => {
try {
const body = await request.json()
const editKey = request.headers.get('X-Edit-Key')
// Forward to Rails API with automatic auth via handleFetch
const response = await fetch(buildUrl('/summons/update_uncap'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(editKey ? { 'X-Edit-Key': editKey } : {})
},
body: JSON.stringify(body)
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }))
return json(error, { status: response.status })
}
const data = await response.json()
return json(data)
} catch (error) {
console.error('Error updating summon uncap:', error)
return json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -0,0 +1,30 @@
import { json, type RequestHandler } from '@sveltejs/kit'
import { buildUrl } from '$lib/api/core'
export const POST: RequestHandler = async ({ request, fetch }) => {
try {
const body = await request.json()
const editKey = request.headers.get('X-Edit-Key')
// Forward to Rails API with automatic auth via handleFetch
const response = await fetch(buildUrl('/weapons/update_uncap'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(editKey ? { 'X-Edit-Key': editKey } : {})
},
body: JSON.stringify(body)
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }))
return json(error, { status: response.status })
}
const data = await response.json()
return json(data)
} catch (error) {
console.error('Error updating weapon uncap:', error)
return json({ error: 'Internal server error' }, { status: 500 })
}
}