fix: adapter initialization and image loading

- Add centralized adapter config with proper baseURL
- Fix API response transformation (object -> weapon/character/summon)
- Update components to use image service and correct property names
- Fix Svelte 5 $derived syntax and missing PartyAdapter.list method

Images now display correctly in grids.
This commit is contained in:
Justin Edmund 2025-09-20 01:54:40 -07:00
parent 842321efd2
commit 1a6a112efd
15 changed files with 101 additions and 86 deletions

View file

@ -9,7 +9,7 @@
*/
import { snakeToCamel, camelToSnake } from '../schemas/transforms'
import { API_BASE } from '../core'
import { transformResponse, transformRequest } from '../client'
import type { AdapterOptions, RequestOptions, AdapterError } from './types'
import {
createErrorFromStatus,
@ -57,8 +57,10 @@ export abstract class BaseAdapter {
* @param options.onError - Global error handler callback
*/
constructor(options: AdapterOptions = {}) {
// Default to localhost if no baseURL provided
const baseURL = options.baseURL ?? 'http://localhost:3000/api/v1'
this.options = {
baseURL: options.baseURL ?? API_BASE,
baseURL,
timeout: options.timeout ?? 30000,
retries: options.retries ?? 3,
cacheTime: options.cacheTime ?? 0,
@ -174,34 +176,34 @@ export abstract class BaseAdapter {
}
/**
* Transforms response data from snake_case to camelCase
* Transforms response data from snake_case to camelCase and object->entity
*
* @template T - The expected response type
* @param data - Raw response data from the API
* @returns Transformed data with camelCase property names
* @returns Transformed data with camelCase property names and proper entity fields
*/
protected transformResponse<T>(data: any): T {
if (data === null || data === undefined) {
return data
}
// Apply snake_case to camelCase transformation
return snakeToCamel(data) as T
// Apply full transformation: snake_case->camelCase and object->entity
return transformResponse<T>(data)
}
/**
* Transforms request data from camelCase to snake_case
* Transforms request data from camelCase to snake_case and entity->object
*
* @param data - Request data with camelCase property names
* @returns Transformed data with snake_case property names
* @param data - Request data with camelCase property names and entity fields
* @returns Transformed data with snake_case property names and object fields
*/
protected transformRequest(data: any): any {
if (data === null || data === undefined) {
return data
}
// Apply camelCase to snake_case transformation
return camelToSnake(data)
// Apply full transformation: entity->object and camelCase->snake_case
return transformRequest(data)
}
/**

View file

@ -0,0 +1,24 @@
/**
* Configuration for API adapters
*/
import { PUBLIC_SIERO_API_URL } from '$env/static/public'
/**
* Get the base URL for API requests
* Handles both server and client environments
*/
export function getApiBaseUrl(): string {
// Use environment variable if available, otherwise default to localhost
const base = PUBLIC_SIERO_API_URL || 'http://localhost:3000'
return `${base}/api/v1`
}
/**
* Default configuration for all adapters
*/
export const DEFAULT_ADAPTER_CONFIG = {
baseURL: getApiBaseUrl(),
timeout: 30000,
retries: 3,
cacheTime: 0
}

View file

@ -10,6 +10,7 @@
import { BaseAdapter } from './base.adapter'
import type { AdapterOptions } from './types'
import { DEFAULT_ADAPTER_CONFIG } from './config'
/**
* Canonical weapon data from the game
@ -108,14 +109,6 @@ export interface Summon {
* Entity adapter for accessing canonical game data
*/
export class EntityAdapter extends BaseAdapter {
constructor(options?: AdapterOptions) {
super({
...options,
baseURL: options?.baseURL || '/api/v1',
// Cache entity data for longer since it rarely changes
cacheTime: options?.cacheTime || 300000 // 5 minutes default
})
}
/**
* Gets canonical weapon data by ID
@ -190,4 +183,4 @@ export class EntityAdapter extends BaseAdapter {
/**
* Default entity adapter instance
*/
export const entityAdapter = new EntityAdapter()
export const entityAdapter = new EntityAdapter(DEFAULT_ADAPTER_CONFIG)

View file

@ -9,6 +9,7 @@
import { BaseAdapter } from './base.adapter'
import type { AdapterOptions } from './types'
import { DEFAULT_ADAPTER_CONFIG } from './config'
/**
* Common grid item structure
@ -143,12 +144,6 @@ export interface ResolveConflictParams {
* Grid adapter for managing user's grid item instances
*/
export class GridAdapter extends BaseAdapter {
constructor(options?: AdapterOptions) {
super({
...options,
baseURL: options?.baseURL || '/api/v1'
})
}
// Weapon operations
@ -401,4 +396,4 @@ export class GridAdapter extends BaseAdapter {
/**
* Default grid adapter instance
*/
export const gridAdapter = new GridAdapter()
export const gridAdapter = new GridAdapter(DEFAULT_ADAPTER_CONFIG)

View file

@ -10,6 +10,7 @@
import { BaseAdapter } from './base.adapter'
import type { RequestOptions, AdapterOptions, PaginatedResponse } from './types'
import { DEFAULT_ADAPTER_CONFIG } from './config'
/**
* Party data structure
@ -252,6 +253,17 @@ export class PartyAdapter extends BaseAdapter {
})
}
/**
* Lists all public parties (explore page)
*/
async list(params: { page?: number; per?: number } = {}): Promise<PaginatedResponse<Party>> {
return this.request<PaginatedResponse<Party>>('/parties', {
method: 'GET',
query: params,
cacheTTL: 30000 // Cache for 30 seconds
})
}
/**
* Lists parties for a specific user
*/
@ -401,4 +413,4 @@ export class PartyAdapter extends BaseAdapter {
/**
* Default party adapter instance
*/
export const partyAdapter = new PartyAdapter()
export const partyAdapter = new PartyAdapter(DEFAULT_ADAPTER_CONFIG)

View file

@ -7,7 +7,7 @@
* @module adapters/resources/party
*/
import { PartyAdapter, type Party, type CreatePartyParams, type UpdatePartyParams } from '../party.adapter'
import { PartyAdapter, partyAdapter, type Party, type CreatePartyParams, type UpdatePartyParams } from '../party.adapter'
import type { AdapterError } from '../types'
/**
@ -88,7 +88,7 @@ export class PartyResource {
private activeRequests = new Map<string, AbortController>()
constructor(options: PartyResourceOptions = {}) {
this.adapter = options.adapter || new PartyAdapter()
this.adapter = options.adapter || partyAdapter
this.optimistic = options.optimistic ?? true
}

View file

@ -8,7 +8,7 @@
*/
import { debounced } from 'runed'
import { SearchAdapter, type SearchParams, type SearchResponse } from '../search.adapter'
import { SearchAdapter, searchAdapter, type SearchParams, type SearchResponse } from '../search.adapter'
import type { AdapterError } from '../types'
/**
@ -93,7 +93,7 @@ export class SearchResource {
private activeRequests = new Map<string, AbortController>()
constructor(options: SearchResourceOptions = {}) {
this.adapter = options.adapter || new SearchAdapter()
this.adapter = options.adapter || searchAdapter
this.debounceMs = options.debounceMs || 300
this.baseParams = options.initialParams || {}
}

View file

@ -9,6 +9,7 @@
import { BaseAdapter } from './base.adapter'
import type { AdapterOptions, SearchFilters } from './types'
import { DEFAULT_ADAPTER_CONFIG } from './config'
/**
* Search parameters for entity queries
@ -293,4 +294,4 @@ export class SearchAdapter extends BaseAdapter {
* Default singleton instance for search operations
* Use this for most search needs unless you need custom configuration
*/
export const searchAdapter = new SearchAdapter()
export const searchAdapter = new SearchAdapter(DEFAULT_ADAPTER_CONFIG)

View file

@ -1,5 +1,6 @@
import { BaseAdapter } from './base.adapter'
import type { Party } from '$lib/types/api/party'
import { DEFAULT_ADAPTER_CONFIG } from './config'
export interface UserInfo {
id: string
@ -134,4 +135,4 @@ export class UserAdapter extends BaseAdapter {
}
}
export const userAdapter = new UserAdapter()
export const userAdapter = new UserAdapter(DEFAULT_ADAPTER_CONFIG)

View file

@ -1,6 +1,7 @@
<script lang="ts">
import type { Party, GridWeapon, GridCharacter } from '$lib/types/api/party'
import { getElementClass } from '$lib/types/enums'
import { getCharacterImage } from '$lib/features/database/detail/image'
interface Props {
party?: Party
@ -12,8 +13,8 @@
let { party, characters: directCharacters, jobId, element, gender }: Props = $props()
// Use direct characters if provided, otherwise get from party
const characters = $derived(directCharacters || party?.characters || [])
// Use direct characters if provided, otherwise get from party (note: API returns gridCharacters)
const characters = $derived(directCharacters || party?.gridCharacters || [])
const grid = $derived(Array.from({ length: 3 }, (_, i) =>
characters.find((c: GridCharacter) => c?.position === i)
))
@ -23,7 +24,7 @@
element ? getElementClass(element) :
// Otherwise try to get from party's mainhand weapon
party ? (() => {
const main: GridWeapon | undefined = (party.weapons || []).find(
const main: GridWeapon | undefined = (party.gridWeapons || []).find(
(w: GridWeapon) => w?.mainhand || w?.position === -1
)
const el = main?.element ?? main?.weapon?.element

View file

@ -1,5 +1,6 @@
<script lang="ts">
import type { Party, GridSummon } from '$lib/types/api/party'
import { getSummonImage } from '$lib/features/database/detail/image'
interface Props {
party?: Party
@ -9,8 +10,8 @@
let { party, summons: directSummons, extendedView = false }: Props = $props()
// Use direct summons if provided, otherwise get from party
const summons = $derived(directSummons || party?.summons || [])
// Use direct summons if provided, otherwise get from party (note: API returns gridSummons)
const summons = $derived(directSummons || party?.gridSummons || [])
const main = $derived(summons.find((s: GridSummon) => s?.main || s?.position === -1))
const friend = $derived(
extendedView ? summons.find((s: GridSummon) => s?.friend || s?.position === -2) : undefined

View file

@ -1,5 +1,6 @@
<script lang="ts">
import type { Party, GridWeapon } from '$lib/types/api/party'
import { getWeaponImage } from '$lib/features/database/detail/image'
interface Props {
party?: Party
@ -8,21 +9,19 @@
let { party, weapons: directWeapons }: Props = $props()
// Use direct weapons if provided, otherwise get from party
// Use direct weapons if provided, otherwise get from party (note: API returns weapons after transformation)
const weapons = $derived(directWeapons || party?.weapons || [])
const mainhand = $derived(weapons.find((w: GridWeapon) => w?.mainhand || w?.position === -1))
const grid = $derived(
Array.from({ length: 9 }, (_, i) => weapons.find((w: GridWeapon) => w?.position === i))
)
function weaponImageUrl(w?: GridWeapon, isMain = false): string {
const id = w?.weapon?.granblueId
if (!id) return ''
const folder = isMain ? 'weapon-main' : 'weapon-grid'
const objElement = w?.weapon?.element
const instElement = w?.element
if (objElement === 0 && instElement) return `/images/${folder}/${id}_${instElement}.jpg`
return `/images/${folder}/${id}.jpg`
const variant = isMain ? 'main' : 'grid'
// For weapons with null element that have an instance element, use it
const element = (w?.weapon?.element === 0 && w?.element) ? w.element : undefined
return getWeaponImage(w?.weapon?.granblueId, variant, element)
}
</script>

View file

@ -6,6 +6,7 @@
import ContextMenu from '$lib/components/ui/ContextMenu.svelte'
import { ContextMenu as ContextMenuBase } from 'bits-ui'
import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte'
import { getCharacterImage } from '$lib/features/database/detail/image'
interface Props {
item?: GridCharacter
@ -33,24 +34,27 @@
return '—'
}
// Use $derived to ensure consistent computation between server and client
let imageUrl = $derived(() => {
let imageUrl = $derived.by(() => {
// If no item or no character with granblueId, return placeholder
if (!item || !item.character?.granblueId) {
return '/images/placeholders/placeholder-weapon-grid.png'
return getCharacterImage(null, undefined, 'main')
}
const id = item.character.granblueId
const uncap = item?.uncapLevel ?? 0
const transStep = item?.transcendenceStep ?? 0
let suffix = '01'
if (transStep > 0) suffix = '04'
else if (uncap >= 5) suffix = '03'
else if (uncap > 2) suffix = '02'
let pose = '01'
if (transStep > 0) pose = '04'
else if (uncap >= 5) pose = '03'
else if (uncap > 2) pose = '02'
// Special handling for Gran/Djeeta (3030182000) - element-specific poses
if (String(id) === '3030182000') {
let element = mainWeaponElement || partyElement || 1
suffix = `${suffix}_0${element}`
pose = `${pose}_0${element}`
}
return `/images/character-main/${id}_${suffix}.jpg`
return getCharacterImage(id, pose, 'main')
})
async function remove() {
@ -95,7 +99,7 @@
class="image"
class:placeholder={!item?.character?.granblueId}
alt={displayName(item?.character)}
src={imageUrl()}
src={imageUrl}
/>
{#if ctx?.canEdit() && item?.id}
<div class="actions">

View file

@ -6,6 +6,7 @@
import ContextMenu from '$lib/components/ui/ContextMenu.svelte'
import { ContextMenu as ContextMenuBase } from 'bits-ui'
import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte'
import { getSummonImage } from '$lib/features/database/detail/image'
interface Props {
item?: GridSummon
@ -31,20 +32,12 @@
return '—'
}
// Use $derived to ensure consistent computation between server and client
let imageUrl = $derived(() => {
let imageUrl = $derived.by(() => {
// Check position first for main/friend summon determination
const isMain = position === -1 || position === 6 || item?.main || item?.friend
const variant = isMain ? 'main' : 'grid'
// 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 = item.summon.granblueId
const folder = isMain ? 'summon-main' : 'summon-grid'
return `/images/${folder}/${id}.jpg`
return getSummonImage(item?.summon?.granblueId, variant)
})
async function remove() {
@ -92,7 +85,7 @@
class="image"
class:placeholder={!item?.summon?.granblueId}
alt={displayName(item?.summon)}
src={imageUrl()}
src={imageUrl}
/>
{#if ctx?.canEdit() && item?.id}
<div class="actions">

View file

@ -6,6 +6,7 @@
import ContextMenu from '$lib/components/ui/ContextMenu.svelte'
import { ContextMenu as ContextMenuBase } from 'bits-ui'
import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte'
import { getWeaponImage } from '$lib/features/database/detail/image'
interface Props {
item?: GridWeapon
@ -33,26 +34,14 @@
}
// Use $derived to ensure consistent computation between server and client
let imageUrl = $derived(() => {
// Check position first for main weapon determination
let imageUrl = $derived.by(() => {
const isMain = position === -1 || item?.mainhand
const variant = isMain ? 'main' : 'grid'
// 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'
}
// For weapons with null element that have an instance element, use it
const element = (item?.weapon?.element === 0 && item?.element) ? item.element : undefined
const id = item.weapon.granblueId
const folder = isMain ? 'weapon-main' : 'weapon-grid'
const objElement = item.weapon?.element
const instElement = item?.element
if (objElement === 0 && instElement) {
return `/images/${folder}/${id}_${instElement}.jpg`
}
return `/images/${folder}/${id}.jpg`
return getWeaponImage(item?.weapon?.granblueId, variant, element)
})
async function remove() {
@ -98,7 +87,7 @@
class="image"
class:placeholder={!item?.weapon?.granblueId}
alt={displayName(item?.weapon)}
src={imageUrl()}
src={imageUrl}
/>
{#if ctx?.canEdit() && item?.id}
<div class="actions">