fix: merge conflict
This commit is contained in:
commit
a208a3c1ea
21 changed files with 164 additions and 2489 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -6,6 +6,7 @@ node_modules
|
|||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/.next
|
||||
/build
|
||||
|
||||
# OS
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
* @module adapters/base
|
||||
*/
|
||||
|
||||
import { transformResponse, transformRequest } from '../client'
|
||||
import { transformResponse, transformRequest } from '../schemas/transforms'
|
||||
import type { AdapterOptions, RequestOptions, AdapterError } from './types'
|
||||
import {
|
||||
createErrorFromStatus,
|
||||
|
|
|
|||
|
|
@ -1,732 +0,0 @@
|
|||
/**
|
||||
* Unified API Client for client-side use
|
||||
* All API calls go through our SvelteKit proxy endpoints
|
||||
* Automatically handles edit keys from localStorage
|
||||
* Automatically transforms data between API format and clean types
|
||||
*/
|
||||
|
||||
import { snakeToCamel, camelToSnake } from './schemas/transforms'
|
||||
|
||||
export interface PartyPayload {
|
||||
name?: string
|
||||
description?: string | null
|
||||
element?: number
|
||||
visibility?: number
|
||||
localId?: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface GridItemOptions {
|
||||
uncapLevel?: number
|
||||
transcendenceStep?: number
|
||||
element?: number
|
||||
mainhand?: boolean
|
||||
main?: boolean
|
||||
friend?: boolean
|
||||
quickSummon?: boolean
|
||||
perpetuity?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms API response data to match our clean type definitions
|
||||
* - Converts snake_case to camelCase
|
||||
* - Renames "object" to proper entity names (weapon, character, summon)
|
||||
*/
|
||||
export function transformResponse<T>(data: any): T {
|
||||
if (data === null || data === undefined) return data
|
||||
|
||||
// First convert snake_case to camelCase
|
||||
const camelCased = snakeToCamel(data)
|
||||
|
||||
// Then rename "object" fields to proper entity names
|
||||
return renameObjectFields(camelCased) as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms request data to match API expectations
|
||||
* - Converts camelCase to snake_case
|
||||
* - Renames entity names back to "object" for API
|
||||
*/
|
||||
export function transformRequest<T>(data: T): any {
|
||||
if (data === null || data === undefined) return data
|
||||
|
||||
// First rename entity fields back to "object"
|
||||
const withObjectFields = renameEntityFields(data)
|
||||
|
||||
// Then convert camelCase to snake_case
|
||||
return camelToSnake(withObjectFields)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames "object" fields to proper entity names in response data
|
||||
*/
|
||||
function renameObjectFields(obj: any): any {
|
||||
if (obj === null || obj === undefined) return obj
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(renameObjectFields)
|
||||
}
|
||||
|
||||
if (typeof obj === 'object') {
|
||||
const result: any = {}
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
// Handle weapons array
|
||||
if (key === 'weapons' && Array.isArray(value)) {
|
||||
result.weapons = value.map((item: any) => {
|
||||
if (item && typeof item === 'object' && 'object' in item) {
|
||||
const { object, ...rest } = item
|
||||
return { ...rest, weapon: renameObjectFields(object) }
|
||||
}
|
||||
return renameObjectFields(item)
|
||||
})
|
||||
}
|
||||
// Handle characters array
|
||||
else if (key === 'characters' && Array.isArray(value)) {
|
||||
result.characters = value.map((item: any) => {
|
||||
if (item && typeof item === 'object' && 'object' in item) {
|
||||
const { object, ...rest } = item
|
||||
return { ...rest, character: renameObjectFields(object) }
|
||||
}
|
||||
return renameObjectFields(item)
|
||||
})
|
||||
}
|
||||
// Handle summons array
|
||||
else if (key === 'summons' && Array.isArray(value)) {
|
||||
result.summons = value.map((item: any) => {
|
||||
if (item && typeof item === 'object' && 'object' in item) {
|
||||
const { object, ...rest } = item
|
||||
return { ...rest, summon: renameObjectFields(object) }
|
||||
}
|
||||
return renameObjectFields(item)
|
||||
})
|
||||
}
|
||||
// Recursively process other fields
|
||||
else {
|
||||
result[key] = renameObjectFields(value)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames entity fields back to "object" for API requests
|
||||
*/
|
||||
function renameEntityFields(obj: any): any {
|
||||
if (obj === null || obj === undefined) return obj
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(renameEntityFields)
|
||||
}
|
||||
|
||||
if (typeof obj === 'object') {
|
||||
const result: any = {}
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
// Handle weapons array
|
||||
if (key === 'weapons' && Array.isArray(value)) {
|
||||
result.weapons = value.map((item: any) => {
|
||||
if (item && typeof item === 'object' && 'weapon' in item) {
|
||||
const { weapon, ...rest } = item
|
||||
return { ...rest, object: renameEntityFields(weapon) }
|
||||
}
|
||||
return renameEntityFields(item)
|
||||
})
|
||||
}
|
||||
// Handle characters array
|
||||
else if (key === 'characters' && Array.isArray(value)) {
|
||||
result.characters = value.map((item: any) => {
|
||||
if (item && typeof item === 'object' && 'character' in item) {
|
||||
const { character, ...rest } = item
|
||||
return { ...rest, object: renameEntityFields(character) }
|
||||
}
|
||||
return renameEntityFields(item)
|
||||
})
|
||||
}
|
||||
// Handle summons array
|
||||
else if (key === 'summons' && Array.isArray(value)) {
|
||||
result.summons = value.map((item: any) => {
|
||||
if (item && typeof item === 'object' && 'summon' in item) {
|
||||
const { summon, ...rest } = item
|
||||
return { ...rest, object: renameEntityFields(summon) }
|
||||
}
|
||||
return renameEntityFields(item)
|
||||
})
|
||||
}
|
||||
// Recursively process other fields
|
||||
else {
|
||||
result[key] = renameEntityFields(value)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
export class APIClient {
|
||||
/**
|
||||
* Get edit key for a party from localStorage
|
||||
*/
|
||||
private getEditKey(partyIdOrShortcode: string): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
|
||||
// Try both formats - with party ID and shortcode
|
||||
const keyById = localStorage.getItem(`edit_key_${partyIdOrShortcode}`)
|
||||
if (keyById) return keyById
|
||||
|
||||
// Also check if it's stored by shortcode
|
||||
return localStorage.getItem(`edit_key_${partyIdOrShortcode}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Store edit key for a party in localStorage
|
||||
*/
|
||||
storeEditKey(partyShortcode: string, editKey: string): void {
|
||||
if (typeof window !== 'undefined' && editKey) {
|
||||
localStorage.setItem(`edit_key_${partyShortcode}`, editKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new party
|
||||
*/
|
||||
async createParty(payload: PartyPayload): Promise<{ party: any; editKey?: string }> {
|
||||
const response = await fetch('/api/parties', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || `Failed to create party: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Store edit key if present
|
||||
if (data.edit_key && data.party?.shortcode) {
|
||||
this.storeEditKey(data.party.shortcode, data.edit_key)
|
||||
}
|
||||
|
||||
return {
|
||||
party: data.party,
|
||||
editKey: data.edit_key
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a party
|
||||
*/
|
||||
async updateParty(partyId: string, payload: Partial<PartyPayload>): Promise<any> {
|
||||
const editKey = this.getEditKey(partyId)
|
||||
|
||||
const response = await fetch(`/api/parties/${partyId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(editKey ? { 'X-Edit-Key': editKey } : {})
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || `Failed to update party: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
// The API returns { party: { ... } }, extract the party object
|
||||
const party = data.party || data
|
||||
// Transform the response to match our clean types
|
||||
return transformResponse(party)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a party
|
||||
*/
|
||||
async deleteParty(partyId: string): Promise<void> {
|
||||
const editKey = this.getEditKey(partyId)
|
||||
|
||||
const response = await fetch(`/api/parties/${partyId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(editKey ? { 'X-Edit-Key': editKey } : {})
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || `Failed to delete party: ${response.statusText}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a weapon to a party
|
||||
*/
|
||||
async addWeapon(
|
||||
partyId: string,
|
||||
weaponId: string,
|
||||
position: number,
|
||||
options?: GridItemOptions
|
||||
): Promise<any> {
|
||||
const editKey = this.getEditKey(partyId)
|
||||
|
||||
const response = await fetch(`/api/parties/${partyId}/weapons`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(editKey ? { 'X-Edit-Key': editKey } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
weaponId,
|
||||
position,
|
||||
...options
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || `Failed to add weapon: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a weapon in a party
|
||||
*/
|
||||
async updateWeapon(
|
||||
partyId: string,
|
||||
gridWeaponId: string,
|
||||
updates: {
|
||||
position?: number
|
||||
uncapLevel?: number
|
||||
transcendenceStep?: number
|
||||
element?: number
|
||||
}
|
||||
): Promise<any> {
|
||||
const editKey = this.getEditKey(partyId)
|
||||
|
||||
const response = await fetch(`/api/parties/${partyId}/weapons/${gridWeaponId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(editKey ? { 'X-Edit-Key': editKey } : {})
|
||||
},
|
||||
body: JSON.stringify(updates)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || `Failed to update weapon: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a weapon from a party
|
||||
*/
|
||||
async removeWeapon(partyId: string, gridWeaponId: string): Promise<void> {
|
||||
const editKey = this.getEditKey(partyId)
|
||||
|
||||
console.log('Removing weapon:', { partyId, gridWeaponId, editKey })
|
||||
|
||||
const response = await fetch(`/api/parties/${partyId}/weapons`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(editKey ? { 'X-Edit-Key': editKey } : {})
|
||||
},
|
||||
body: JSON.stringify({ gridWeaponId })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Remove weapon failed:', response.status, response.statusText)
|
||||
// Try to get the response text to see what the server is returning
|
||||
const text = await response.text()
|
||||
console.error('Response body:', text)
|
||||
let error = { error: 'Failed to remove weapon' }
|
||||
try {
|
||||
error = JSON.parse(text)
|
||||
} catch (e) {
|
||||
// Not JSON, use the text as is
|
||||
}
|
||||
throw new Error(error.error || `Failed to remove weapon: ${response.statusText}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a summon to a party
|
||||
*/
|
||||
async addSummon(
|
||||
partyId: string,
|
||||
summonId: string,
|
||||
position: number,
|
||||
options?: GridItemOptions
|
||||
): Promise<any> {
|
||||
const editKey = this.getEditKey(partyId)
|
||||
|
||||
const response = await fetch(`/api/parties/${partyId}/summons`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(editKey ? { 'X-Edit-Key': editKey } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
summonId,
|
||||
position,
|
||||
...options
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || `Failed to add summon: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a summon in a party
|
||||
*/
|
||||
async updateSummon(
|
||||
partyId: string,
|
||||
gridSummonId: string,
|
||||
updates: {
|
||||
position?: number
|
||||
quickSummon?: boolean
|
||||
uncapLevel?: number
|
||||
transcendenceStep?: number
|
||||
}
|
||||
): Promise<any> {
|
||||
const editKey = this.getEditKey(partyId)
|
||||
|
||||
const response = await fetch(`/api/parties/${partyId}/summons/${gridSummonId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(editKey ? { 'X-Edit-Key': editKey } : {})
|
||||
},
|
||||
body: JSON.stringify(updates)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || `Failed to update summon: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a summon from a party
|
||||
*/
|
||||
async removeSummon(partyId: string, gridSummonId: string): Promise<void> {
|
||||
const editKey = this.getEditKey(partyId)
|
||||
|
||||
const response = await fetch(`/api/parties/${partyId}/summons`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(editKey ? { 'X-Edit-Key': editKey } : {})
|
||||
},
|
||||
body: JSON.stringify({ gridSummonId })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Failed to remove summon' }))
|
||||
throw new Error(error.error || `Failed to remove summon: ${response.statusText}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a character to a party
|
||||
*/
|
||||
async addCharacter(
|
||||
partyId: string,
|
||||
characterId: string,
|
||||
position: number,
|
||||
options?: GridItemOptions
|
||||
): Promise<any> {
|
||||
const editKey = this.getEditKey(partyId)
|
||||
|
||||
const response = await fetch(`/api/parties/${partyId}/characters`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(editKey ? { 'X-Edit-Key': editKey } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
characterId,
|
||||
position,
|
||||
...options
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || `Failed to add character: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a character in a party
|
||||
*/
|
||||
async updateCharacter(
|
||||
partyId: string,
|
||||
gridCharacterId: string,
|
||||
updates: {
|
||||
position?: number
|
||||
uncapLevel?: number
|
||||
transcendenceStep?: number
|
||||
perpetuity?: boolean
|
||||
}
|
||||
): Promise<any> {
|
||||
const editKey = this.getEditKey(partyId)
|
||||
|
||||
const response = await fetch(`/api/parties/${partyId}/characters/${gridCharacterId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(editKey ? { 'X-Edit-Key': editKey } : {})
|
||||
},
|
||||
body: JSON.stringify(updates)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || `Failed to update character: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a character from a party
|
||||
*/
|
||||
async removeCharacter(partyId: string, gridCharacterId: string): Promise<void> {
|
||||
const editKey = this.getEditKey(partyId)
|
||||
|
||||
const response = await fetch(`/api/parties/${partyId}/characters`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(editKey ? { 'X-Edit-Key': editKey } : {})
|
||||
},
|
||||
body: JSON.stringify({ gridCharacterId })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Failed to remove character' }))
|
||||
throw new Error(error.error || `Failed to remove character: ${response.statusText}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update weapon position (drag-drop)
|
||||
*/
|
||||
async updateWeaponPosition(
|
||||
partyId: string,
|
||||
weaponId: string,
|
||||
position: number,
|
||||
container?: string
|
||||
): Promise<any> {
|
||||
const editKey = this.getEditKey(partyId)
|
||||
|
||||
const response = await fetch(`/api/parties/${partyId}/grid_weapons/${weaponId}/position`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(editKey ? { 'X-Edit-Key': editKey } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
position,
|
||||
...(container ? { container } : {})
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || `Failed to update weapon position: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return transformResponse(data.party || data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap two weapons (drag-drop)
|
||||
*/
|
||||
async swapWeapons(partyId: string, sourceId: string, targetId: string): Promise<any> {
|
||||
const editKey = this.getEditKey(partyId)
|
||||
|
||||
const response = await fetch(`/api/parties/${partyId}/grid_weapons/swap`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(editKey ? { 'X-Edit-Key': editKey } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
source_id: sourceId,
|
||||
target_id: targetId
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || `Failed to swap weapons: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return transformResponse(data.party || data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update character position (drag-drop)
|
||||
*/
|
||||
async updateCharacterPosition(
|
||||
partyId: string,
|
||||
characterId: string,
|
||||
position: number,
|
||||
container?: string
|
||||
): Promise<any> {
|
||||
const editKey = this.getEditKey(partyId)
|
||||
|
||||
const response = await fetch(`/api/parties/${partyId}/grid_characters/${characterId}/position`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(editKey ? { 'X-Edit-Key': editKey } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
position,
|
||||
...(container ? { container } : {})
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || `Failed to update character position: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return transformResponse(data.party || data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap two characters (drag-drop)
|
||||
*/
|
||||
async swapCharacters(partyId: string, sourceId: string, targetId: string): Promise<any> {
|
||||
const editKey = this.getEditKey(partyId)
|
||||
|
||||
const response = await fetch(`/api/parties/${partyId}/grid_characters/swap`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(editKey ? { 'X-Edit-Key': editKey } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
source_id: sourceId,
|
||||
target_id: targetId
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || `Failed to swap characters: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return transformResponse(data.party || data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update summon position (drag-drop)
|
||||
*/
|
||||
async updateSummonPosition(
|
||||
partyId: string,
|
||||
summonId: string,
|
||||
position: number,
|
||||
container?: string
|
||||
): Promise<any> {
|
||||
const editKey = this.getEditKey(partyId)
|
||||
|
||||
const response = await fetch(`/api/parties/${partyId}/grid_summons/${summonId}/position`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(editKey ? { 'X-Edit-Key': editKey } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
position,
|
||||
...(container ? { container } : {})
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || `Failed to update summon position: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return transformResponse(data.party || data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap two summons (drag-drop)
|
||||
*/
|
||||
async swapSummons(partyId: string, sourceId: string, targetId: string): Promise<any> {
|
||||
const editKey = this.getEditKey(partyId)
|
||||
|
||||
const response = await fetch(`/api/parties/${partyId}/grid_summons/swap`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(editKey ? { 'X-Edit-Key': editKey } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
source_id: sourceId,
|
||||
target_id: targetId
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || `Failed to swap summons: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return transformResponse(data.party || data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get local ID for anonymous users
|
||||
*/
|
||||
getLocalId(): string {
|
||||
if (typeof window === 'undefined') return ''
|
||||
|
||||
let localId = localStorage.getItem('local_id')
|
||||
if (!localId) {
|
||||
localId = crypto.randomUUID()
|
||||
localStorage.setItem('local_id', localId)
|
||||
}
|
||||
return localId
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance for convenience
|
||||
export const apiClient = new APIClient()
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
import { PUBLIC_SIERO_API_URL } from '$env/static/public'
|
||||
|
||||
export type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
||||
export type Dict = Record<string, unknown>
|
||||
|
||||
// Compute a stable API base that always includes the versioned prefix.
|
||||
function computeApiBase(): string {
|
||||
const raw = (PUBLIC_SIERO_API_URL || 'http://localhost:3000') as string
|
||||
const u = new URL(raw, raw.startsWith('http') ? undefined : 'http://localhost')
|
||||
const origin = u.origin
|
||||
const path = u.pathname.replace(/\/$/, '')
|
||||
const hasVersion = /(\/api\/v1|\/v1)$/.test(path)
|
||||
const basePath = hasVersion ? path : `${path}/api/v1`
|
||||
return `${origin}${basePath}`
|
||||
}
|
||||
|
||||
export const API_BASE = computeApiBase()
|
||||
|
||||
export function buildUrl(path: string, params?: Dict) {
|
||||
const url = new URL(path.startsWith('http') ? path : `${API_BASE}${path}`, API_BASE)
|
||||
if (params) {
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined || value === null) continue
|
||||
if (Array.isArray(value)) value.forEach((x) => url.searchParams.append(key, String(x)))
|
||||
else url.searchParams.set(key, String(value))
|
||||
}
|
||||
}
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export async function json<T>(fetchFn: FetchLike, url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetchFn(url, {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) },
|
||||
...init
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} ${url}`)
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export const get = <T>(f: FetchLike, path: string, params?: Dict, init?: RequestInit) =>
|
||||
json<T>(f, buildUrl(path, params), init)
|
||||
|
||||
export const post = <T>(f: FetchLike, path: string, body?: unknown, init?: RequestInit) => {
|
||||
const extra = body !== undefined ? { body: JSON.stringify(body) } : {}
|
||||
return json<T>(f, buildUrl(path), { method: 'POST', ...extra, ...init })
|
||||
}
|
||||
|
||||
export const put = <T>(f: FetchLike, path: string, body?: unknown, init?: RequestInit) => {
|
||||
const extra = body !== undefined ? { body: JSON.stringify(body) } : {}
|
||||
return json<T>(f, buildUrl(path), { method: 'PUT', ...extra, ...init })
|
||||
}
|
||||
|
||||
export const del = <T>(f: FetchLike, path: string, init?: RequestInit) =>
|
||||
json<T>(f, buildUrl(path), { method: 'DELETE', ...init })
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
import type { FetchLike } from '../core'
|
||||
import { get } from '../core'
|
||||
|
||||
export interface CharacterEntity {
|
||||
id: string
|
||||
granblue_id: number | string
|
||||
name: { en?: string; ja?: string } | string
|
||||
element?: number
|
||||
rarity?: number
|
||||
uncap?: { flb?: boolean; ulb?: boolean }
|
||||
}
|
||||
|
||||
export const characters = {
|
||||
show: (f: FetchLike, id: string, init?: RequestInit) =>
|
||||
get<CharacterEntity>(f, `/characters/${encodeURIComponent(id)}`, undefined, init)
|
||||
}
|
||||
|
|
@ -1,171 +0,0 @@
|
|||
import type { FetchLike, Dict } from '../core'
|
||||
import { buildUrl, API_BASE } from '../core'
|
||||
|
||||
// Custom JSON fetch without credentials for search endpoints to avoid CORS issues
|
||||
async function searchJson<T>(fetchFn: FetchLike, url: string, body?: unknown): Promise<T> {
|
||||
const res = await fetchFn(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} ${url}`)
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export interface SearchParams {
|
||||
query?: string
|
||||
locale?: 'en' | 'ja'
|
||||
exclude?: string[]
|
||||
page?: number
|
||||
per?: number
|
||||
filters?: {
|
||||
element?: number[]
|
||||
rarity?: number[]
|
||||
proficiency1?: number[] // For weapons and characters
|
||||
proficiency2?: number[] // For characters only
|
||||
series?: number[]
|
||||
extra?: boolean
|
||||
subaura?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
id: string
|
||||
granblue_id: string
|
||||
name: { en?: string; ja?: string }
|
||||
element?: number
|
||||
rarity?: number
|
||||
proficiency?: number
|
||||
series?: number
|
||||
image_url?: string
|
||||
searchable_type: 'Weapon' | 'Character' | 'Summon'
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
results: SearchResult[]
|
||||
total?: number
|
||||
page?: number
|
||||
total_pages?: number
|
||||
meta?: {
|
||||
count: number
|
||||
page: number
|
||||
per_page: number
|
||||
total_pages: number
|
||||
}
|
||||
}
|
||||
|
||||
export function searchAll(
|
||||
params: SearchParams,
|
||||
init?: RequestInit,
|
||||
fetchFn: FetchLike = fetch
|
||||
): Promise<SearchResponse> {
|
||||
const body = {
|
||||
query: params.query || '',
|
||||
locale: params.locale || 'en',
|
||||
page: params.page || 1,
|
||||
exclude: params.exclude || [],
|
||||
filters: params.filters || {}
|
||||
}
|
||||
|
||||
const url = `${API_BASE}/search/all`
|
||||
return searchJson(fetchFn, url, body)
|
||||
}
|
||||
|
||||
export function searchWeapons(
|
||||
params: SearchParams,
|
||||
init?: RequestInit,
|
||||
fetchFn: FetchLike = fetch
|
||||
): Promise<SearchResponse> {
|
||||
const body: any = {
|
||||
locale: params.locale || 'en',
|
||||
page: params.page || 1,
|
||||
per: params.per || undefined
|
||||
}
|
||||
|
||||
// Only include query if it's provided and not empty
|
||||
if (params.query) {
|
||||
body.query = params.query
|
||||
}
|
||||
|
||||
// Only include filters if they have values
|
||||
const filters: any = {}
|
||||
if (params.filters?.element?.length) filters.element = params.filters.element
|
||||
if (params.filters?.rarity?.length) filters.rarity = params.filters.rarity
|
||||
if (params.filters?.proficiency1?.length) filters.proficiency1 = params.filters.proficiency1
|
||||
if (params.filters?.extra !== undefined) filters.extra = params.filters.extra
|
||||
|
||||
if (Object.keys(filters).length > 0) {
|
||||
body.filters = filters
|
||||
}
|
||||
|
||||
const url = `${API_BASE}/search/weapons`
|
||||
console.log('[searchWeapons] Making request to:', url)
|
||||
console.log('[searchWeapons] Request body:', body)
|
||||
|
||||
return searchJson(fetchFn, url, body).then(response => {
|
||||
console.log('[searchWeapons] Response received:', response)
|
||||
return response
|
||||
})
|
||||
}
|
||||
|
||||
export function searchCharacters(
|
||||
params: SearchParams,
|
||||
init?: RequestInit,
|
||||
fetchFn: FetchLike = fetch
|
||||
): Promise<SearchResponse> {
|
||||
const body: any = {
|
||||
locale: params.locale || 'en',
|
||||
page: params.page || 1,
|
||||
per: params.per || undefined
|
||||
}
|
||||
|
||||
// Only include query if it's provided and not empty
|
||||
if (params.query) {
|
||||
body.query = params.query
|
||||
}
|
||||
|
||||
// Only include filters if they have values
|
||||
const filters: any = {}
|
||||
if (params.filters?.element?.length) filters.element = params.filters.element
|
||||
if (params.filters?.rarity?.length) filters.rarity = params.filters.rarity
|
||||
if (params.filters?.proficiency1?.length) filters.proficiency1 = params.filters.proficiency1
|
||||
if (params.filters?.proficiency2?.length) filters.proficiency2 = params.filters.proficiency2
|
||||
|
||||
if (Object.keys(filters).length > 0) {
|
||||
body.filters = filters
|
||||
}
|
||||
|
||||
const url = `${API_BASE}/search/characters`
|
||||
return searchJson(fetchFn, url, body)
|
||||
}
|
||||
|
||||
export function searchSummons(
|
||||
params: SearchParams,
|
||||
init?: RequestInit,
|
||||
fetchFn: FetchLike = fetch
|
||||
): Promise<SearchResponse> {
|
||||
const body: any = {
|
||||
locale: params.locale || 'en',
|
||||
page: params.page || 1,
|
||||
per: params.per || undefined
|
||||
}
|
||||
|
||||
// Only include query if it's provided and not empty
|
||||
if (params.query) {
|
||||
body.query = params.query
|
||||
}
|
||||
|
||||
// Only include filters if they have values
|
||||
const filters: any = {}
|
||||
if (params.filters?.element?.length) filters.element = params.filters.element
|
||||
if (params.filters?.rarity?.length) filters.rarity = params.filters.rarity
|
||||
if (params.filters?.subaura !== undefined) filters.subaura = params.filters.subaura
|
||||
|
||||
if (Object.keys(filters).length > 0) {
|
||||
body.filters = filters
|
||||
}
|
||||
|
||||
const url = `${API_BASE}/search/summons`
|
||||
return searchJson(fetchFn, url, body)
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
import type { FetchLike } from '../core'
|
||||
import { get } from '../core'
|
||||
|
||||
export interface SummonEntity {
|
||||
id: string
|
||||
granblue_id: number
|
||||
name: { en?: string; ja?: string } | string
|
||||
element?: number
|
||||
rarity?: number
|
||||
uncap?: { flb?: boolean; ulb?: boolean; transcendence?: boolean }
|
||||
}
|
||||
|
||||
export const summons = {
|
||||
show: (f: FetchLike, id: string, init?: RequestInit) =>
|
||||
get<SummonEntity>(f, `/summons/${encodeURIComponent(id)}`, undefined, init)
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
import type { FetchLike } from '../core'
|
||||
import { get } from '../core'
|
||||
|
||||
export interface WeaponEntity {
|
||||
id: string
|
||||
granblue_id: number
|
||||
name: { en?: string; ja?: string } | string
|
||||
element?: number
|
||||
rarity?: number
|
||||
uncap?: { flb?: boolean; ulb?: boolean; transcendence?: boolean }
|
||||
}
|
||||
|
||||
export const weapons = {
|
||||
show: (f: FetchLike, id: string, init?: RequestInit) =>
|
||||
get<WeaponEntity>(f, `/weapons/${encodeURIComponent(id)}`, undefined, init)
|
||||
}
|
||||
|
|
@ -461,8 +461,8 @@ export type RaidGroup = CamelCasedKeysDeep<z.infer<typeof RaidGroupSchema>>
|
|||
export type User = CamelCasedKeysDeep<z.infer<typeof UserSchema>>
|
||||
export type Guidebook = CamelCasedKeysDeep<z.infer<typeof GuidebookSchema>>
|
||||
|
||||
// Import transformation from client
|
||||
import { transformResponse } from '../client'
|
||||
// Import transformation from transforms
|
||||
import { transformResponse } from './transforms'
|
||||
import type { Party as CleanParty } from '$lib/types/api/party'
|
||||
|
||||
// Helper: parse raw API party (snake_case) and convert to clean types
|
||||
|
|
|
|||
|
|
@ -40,4 +40,146 @@ export function camelToSnake<T>(obj: T): T {
|
|||
}
|
||||
|
||||
return obj
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames "object" fields to proper entity names in response data
|
||||
*/
|
||||
function renameObjectFields(obj: any): any {
|
||||
if (obj === null || obj === undefined) return obj
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(renameObjectFields)
|
||||
}
|
||||
|
||||
if (typeof obj === 'object') {
|
||||
const result: any = {}
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
// Handle weapons array
|
||||
if (key === 'weapons' && Array.isArray(value)) {
|
||||
result.weapons = value.map((item: any) => {
|
||||
if (item && typeof item === 'object' && 'object' in item) {
|
||||
const { object, ...rest } = item
|
||||
return { ...rest, weapon: renameObjectFields(object) }
|
||||
}
|
||||
return renameObjectFields(item)
|
||||
})
|
||||
}
|
||||
// Handle characters array
|
||||
else if (key === 'characters' && Array.isArray(value)) {
|
||||
result.characters = value.map((item: any) => {
|
||||
if (item && typeof item === 'object' && 'object' in item) {
|
||||
const { object, ...rest } = item
|
||||
return { ...rest, character: renameObjectFields(object) }
|
||||
}
|
||||
return renameObjectFields(item)
|
||||
})
|
||||
}
|
||||
// Handle summons array
|
||||
else if (key === 'summons' && Array.isArray(value)) {
|
||||
result.summons = value.map((item: any) => {
|
||||
if (item && typeof item === 'object' && 'object' in item) {
|
||||
const { object, ...rest } = item
|
||||
return { ...rest, summon: renameObjectFields(object) }
|
||||
}
|
||||
return renameObjectFields(item)
|
||||
})
|
||||
}
|
||||
// Recursively process other fields
|
||||
else {
|
||||
result[key] = renameObjectFields(value)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames entity fields back to "object" for API requests
|
||||
*/
|
||||
function renameEntityFields(obj: any): any {
|
||||
if (obj === null || obj === undefined) return obj
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(renameEntityFields)
|
||||
}
|
||||
|
||||
if (typeof obj === 'object') {
|
||||
const result: any = {}
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
// Handle weapons array
|
||||
if (key === 'weapons' && Array.isArray(value)) {
|
||||
result.weapons = value.map((item: any) => {
|
||||
if (item && typeof item === 'object' && 'weapon' in item) {
|
||||
const { weapon, ...rest } = item
|
||||
return { ...rest, object: renameEntityFields(weapon) }
|
||||
}
|
||||
return renameEntityFields(item)
|
||||
})
|
||||
}
|
||||
// Handle characters array
|
||||
else if (key === 'characters' && Array.isArray(value)) {
|
||||
result.characters = value.map((item: any) => {
|
||||
if (item && typeof item === 'object' && 'character' in item) {
|
||||
const { character, ...rest } = item
|
||||
return { ...rest, object: renameEntityFields(character) }
|
||||
}
|
||||
return renameEntityFields(item)
|
||||
})
|
||||
}
|
||||
// Handle summons array
|
||||
else if (key === 'summons' && Array.isArray(value)) {
|
||||
result.summons = value.map((item: any) => {
|
||||
if (item && typeof item === 'object' && 'summon' in item) {
|
||||
const { summon, ...rest } = item
|
||||
return { ...rest, object: renameEntityFields(summon) }
|
||||
}
|
||||
return renameEntityFields(item)
|
||||
})
|
||||
}
|
||||
// Recursively process other fields
|
||||
else {
|
||||
result[key] = renameEntityFields(value)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms API response data to match our clean type definitions
|
||||
* - Converts snake_case to camelCase
|
||||
* - Renames "object" to proper entity names (weapon, character, summon)
|
||||
*/
|
||||
export function transformResponse<T>(data: any): T {
|
||||
if (data === null || data === undefined) return data
|
||||
|
||||
// First convert snake_case to camelCase
|
||||
const camelCased = snakeToCamel(data)
|
||||
|
||||
// Then rename "object" fields to proper entity names
|
||||
return renameObjectFields(camelCased) as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms request data to match API expectations
|
||||
* - Converts camelCase to snake_case
|
||||
* - Renames entity names back to "object" for API
|
||||
*/
|
||||
export function transformRequest<T>(data: T): any {
|
||||
if (data === null || data === undefined) return data
|
||||
|
||||
// First rename entity fields back to "object"
|
||||
const withObjectFields = renameEntityFields(data)
|
||||
|
||||
// Then convert camelCase to snake_case
|
||||
return camelToSnake(withObjectFields)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type { OAuthLoginResponse } from './oauth'
|
||||
import type { UserInfoResponse } from '$lib/api/resources/users'
|
||||
import type { OAuthLoginResponse, UserInfoResponse } from './oauth'
|
||||
import type { AccountCookie } from '$lib/types/AccountCookie'
|
||||
import type { UserCookie } from '$lib/types/UserCookie'
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,20 @@ export interface OAuthLoginResponse {
|
|||
}
|
||||
}
|
||||
|
||||
// Response from user info endpoint used during auth flow
|
||||
export interface UserInfoResponse {
|
||||
id: string
|
||||
username: string
|
||||
role: number
|
||||
avatar: {
|
||||
picture: string | null
|
||||
element: string | null
|
||||
}
|
||||
language: string | null
|
||||
gender: number | null
|
||||
theme: string | null
|
||||
}
|
||||
|
||||
export async function passwordGrantLogin(
|
||||
fetchFn: typeof fetch,
|
||||
body: { email: string; password: string; grant_type: 'password' }
|
||||
|
|
|
|||
|
|
@ -1,430 +0,0 @@
|
|||
<!--
|
||||
Example Component: Search with Adapters
|
||||
|
||||
This component demonstrates how to use the SearchAdapter and SearchResource
|
||||
for reactive search functionality with Svelte 5 runes.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { createSearchResource } from '$lib/api/adapters/resources/search.resource.svelte'
|
||||
import type { SearchResult } from '$lib/api/adapters/search.adapter'
|
||||
|
||||
// Create a search resource with debouncing
|
||||
const search = createSearchResource({
|
||||
debounceMs: 300,
|
||||
initialParams: {
|
||||
locale: 'en',
|
||||
per: 20
|
||||
}
|
||||
})
|
||||
|
||||
// Reactive state for the search query
|
||||
let query = $state('')
|
||||
let selectedType = $state<'all' | 'weapons' | 'characters' | 'summons'>('all')
|
||||
let selectedElement = $state<number[]>([])
|
||||
let selectedRarity = $state<number[]>([])
|
||||
|
||||
// Element and rarity options
|
||||
const elements = [
|
||||
{ value: 1, label: '🔥 Fire' },
|
||||
{ value: 2, label: '💧 Water' },
|
||||
{ value: 3, label: '🌍 Earth' },
|
||||
{ value: 4, label: '🌪️ Wind' },
|
||||
{ value: 5, label: '⚡ Light' },
|
||||
{ value: 6, label: '🌙 Dark' }
|
||||
]
|
||||
|
||||
const rarities = [
|
||||
{ value: 3, label: 'SSR' },
|
||||
{ value: 2, label: 'SR' },
|
||||
{ value: 1, label: 'R' }
|
||||
]
|
||||
|
||||
// Reactive search effect
|
||||
$effect(() => {
|
||||
const params = {
|
||||
query,
|
||||
filters: {
|
||||
element: selectedElement.length > 0 ? selectedElement : undefined,
|
||||
rarity: selectedRarity.length > 0 ? selectedRarity : undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Perform search based on selected type
|
||||
switch (selectedType) {
|
||||
case 'all':
|
||||
search.searchAll(params)
|
||||
break
|
||||
case 'weapons':
|
||||
search.searchWeapons(params)
|
||||
break
|
||||
case 'characters':
|
||||
search.searchCharacters(params)
|
||||
break
|
||||
case 'summons':
|
||||
search.searchSummons(params)
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
// Get current search state based on selected type
|
||||
$derived.by(() => {
|
||||
switch (selectedType) {
|
||||
case 'all':
|
||||
return search.all
|
||||
case 'weapons':
|
||||
return search.weapons
|
||||
case 'characters':
|
||||
return search.characters
|
||||
case 'summons':
|
||||
return search.summons
|
||||
}
|
||||
}) as currentSearch
|
||||
|
||||
// Format result for display
|
||||
function getResultIcon(result: SearchResult): string {
|
||||
switch (result.searchableType) {
|
||||
case 'Weapon':
|
||||
return '⚔️'
|
||||
case 'Character':
|
||||
return '👤'
|
||||
case 'Summon':
|
||||
return '🐉'
|
||||
default:
|
||||
return '❓'
|
||||
}
|
||||
}
|
||||
|
||||
function getElementIcon(element?: number): string {
|
||||
return elements.find(e => e.value === element)?.label.split(' ')[0] || ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="search-example">
|
||||
<h2>Search Example with Adapters</h2>
|
||||
|
||||
<div class="search-controls">
|
||||
<div class="search-input-group">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={query}
|
||||
placeholder="Search for items..."
|
||||
class="search-input"
|
||||
/>
|
||||
|
||||
<select bind:value={selectedType} class="type-selector">
|
||||
<option value="all">All Types</option>
|
||||
<option value="weapons">Weapons Only</option>
|
||||
<option value="characters">Characters Only</option>
|
||||
<option value="summons">Summons Only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<div class="filter-group">
|
||||
<label>Elements:</label>
|
||||
<div class="checkbox-group">
|
||||
{#each elements as element}
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
value={element.value}
|
||||
on:change={(e) => {
|
||||
if (e.currentTarget.checked) {
|
||||
selectedElement = [...selectedElement, element.value]
|
||||
} else {
|
||||
selectedElement = selectedElement.filter(v => v !== element.value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>{element.label}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label>Rarity:</label>
|
||||
<div class="checkbox-group">
|
||||
{#each rarities as rarity}
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
value={rarity.value}
|
||||
on:change={(e) => {
|
||||
if (e.currentTarget.checked) {
|
||||
selectedRarity = [...selectedRarity, rarity.value]
|
||||
} else {
|
||||
selectedRarity = selectedRarity.filter(v => v !== rarity.value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>{rarity.label}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<button onclick={() => search.clearAll()}>Clear All Results</button>
|
||||
<button onclick={() => search.clearCache()}>Clear Cache</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="search-results">
|
||||
{#if currentSearch.loading}
|
||||
<div class="loading">
|
||||
<p>Searching...</p>
|
||||
</div>
|
||||
{:else if currentSearch.error}
|
||||
<div class="error">
|
||||
<p>❌ Error: {currentSearch.error.message}</p>
|
||||
<p class="error-code">Code: {currentSearch.error.code}</p>
|
||||
</div>
|
||||
{:else if currentSearch.data}
|
||||
{#if currentSearch.data.results.length === 0}
|
||||
<div class="no-results">
|
||||
<p>No results found</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="results-header">
|
||||
<p>Found {currentSearch.data.total || currentSearch.data.results.length} results</p>
|
||||
{#if currentSearch.data.totalPages && currentSearch.data.totalPages > 1}
|
||||
<p class="pagination-info">
|
||||
Page {currentSearch.data.page || 1} of {currentSearch.data.totalPages}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="results-grid">
|
||||
{#each currentSearch.data.results as result}
|
||||
<div class="result-card">
|
||||
<div class="result-header">
|
||||
<span class="result-icon">{getResultIcon(result)}</span>
|
||||
<span class="result-element">{getElementIcon(result.element)}</span>
|
||||
</div>
|
||||
<h3 class="result-name">{result.name.en || result.name.ja || 'Unknown'}</h3>
|
||||
<div class="result-meta">
|
||||
<span class="result-type">{result.searchableType}</span>
|
||||
{#if result.rarity}
|
||||
<span class="result-rarity rarity-{result.rarity}">
|
||||
{result.rarity === 3 ? 'SSR' : result.rarity === 2 ? 'SR' : 'R'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if result.imageUrl}
|
||||
<img src={result.imageUrl} alt={result.name.en} class="result-image" />
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="empty-state">
|
||||
<p>Enter a search term to begin</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.search-example {
|
||||
padding: 1rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 1.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.search-controls {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.search-input-group {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
font-size: 1rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.type-selector {
|
||||
padding: 0.75rem;
|
||||
font-size: 1rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
margin: 1rem 0;
|
||||
padding: 1rem;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.loading, .error, .no-results, .empty-state {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #fee;
|
||||
color: #c00;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.5rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.results-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem;
|
||||
background: #e9ecef;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.results-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.result-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.result-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.result-icon, .result-element {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.result-name {
|
||||
font-size: 1rem;
|
||||
margin: 0.5rem 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.result-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.result-type {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.result-rarity {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-weight: bold;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.rarity-3 {
|
||||
background: linear-gradient(135deg, #ffd700, #ffed4e);
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.rarity-2 {
|
||||
background: linear-gradient(135deg, #c0c0c0, #e8e8e8);
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.rarity-1 {
|
||||
background: linear-gradient(135deg, #cd7f32, #e4a05e);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.result-image {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin-top: 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { actions } from './+page.server'
|
||||
import { toEditData } from '$lib/features/database/characters/schema'
|
||||
|
||||
function makeEvent(edit: any, opts?: { status?: number }) {
|
||||
const form = new FormData()
|
||||
form.set('payload', JSON.stringify(edit))
|
||||
|
||||
const request = { formData: async () => form } as unknown as Request
|
||||
const status = opts?.status ?? 200
|
||||
const fetch = vi.fn(async () => new Response('{}', { status }))
|
||||
const params = { id: '3040109000' } as any
|
||||
return { request, fetch, params } as any
|
||||
}
|
||||
|
||||
describe('characters actions.save', () => {
|
||||
it('succeeds on valid payload', async () => {
|
||||
const edit = toEditData({ granblue_id: '3040109000' })
|
||||
const res: any = await actions.save!(makeEvent(edit))
|
||||
expect(res).toMatchObject({ success: true })
|
||||
})
|
||||
|
||||
it('fails validation for bad payload', async () => {
|
||||
const edit = { ...toEditData({ granblue_id: 'x' }), granblue_id: '' }
|
||||
const res: any = await actions.save!(makeEvent(edit))
|
||||
expect(res.status).toBe(422)
|
||||
expect(res.data.message).toBe('Validation error')
|
||||
})
|
||||
|
||||
it('handles backend error', async () => {
|
||||
const edit = toEditData({ granblue_id: '3040109000' })
|
||||
const res: any = await actions.save!(makeEvent(edit, { status: 500 }))
|
||||
expect(res.status).toBe(500)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { actions } from './+page.server'
|
||||
import { toEditData } from '$lib/features/database/summons/schema'
|
||||
|
||||
function makeEvent(edit: any, opts?: { status?: number }) {
|
||||
const form = new FormData()
|
||||
form.set('payload', JSON.stringify(edit))
|
||||
|
||||
const request = { formData: async () => form } as unknown as Request
|
||||
const status = opts?.status ?? 200
|
||||
const fetch = vi.fn(async () => new Response('{}', { status }))
|
||||
const params = { id: '2040004000' } as any
|
||||
return { request, fetch, params } as any
|
||||
}
|
||||
|
||||
describe('summons actions.save', () => {
|
||||
it('succeeds on valid payload', async () => {
|
||||
const edit = toEditData({ granblue_id: '2040004000' })
|
||||
const res: any = await actions.save!(makeEvent(edit))
|
||||
expect(res).toMatchObject({ success: true })
|
||||
})
|
||||
|
||||
it('fails validation for bad payload', async () => {
|
||||
const edit = { ...toEditData({ granblue_id: 'x' }), granblue_id: '' }
|
||||
const res: any = await actions.save!(makeEvent(edit))
|
||||
expect(res.status).toBe(422)
|
||||
expect(res.data.message).toBe('Validation error')
|
||||
})
|
||||
|
||||
it('handles backend error', async () => {
|
||||
const edit = toEditData({ granblue_id: '2040004000' })
|
||||
const res: any = await actions.save!(makeEvent(edit, { status: 500 }))
|
||||
expect(res.status).toBe(500)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { actions } from './+page.server'
|
||||
import { toEditData } from '$lib/features/database/weapons/schema'
|
||||
|
||||
function makeEvent(edit: any, opts?: { status?: number }) {
|
||||
const form = new FormData()
|
||||
form.set('payload', JSON.stringify(edit))
|
||||
|
||||
const request = { formData: async () => form } as unknown as Request
|
||||
const status = opts?.status ?? 200
|
||||
const fetch = vi.fn(async () => new Response('{}', { status }))
|
||||
const params = { id: '1040000000' } as any
|
||||
return { request, fetch, params } as any
|
||||
}
|
||||
|
||||
describe('weapons actions.save', () => {
|
||||
it('succeeds on valid payload', async () => {
|
||||
const edit = toEditData({ granblue_id: '1040000000' })
|
||||
const res: any = await actions.save!(makeEvent(edit))
|
||||
expect(res).toMatchObject({ success: true })
|
||||
})
|
||||
|
||||
it('fails validation for bad payload', async () => {
|
||||
const edit = { ...toEditData({ granblue_id: 'x' }), granblue_id: '' }
|
||||
const res: any = await actions.save!(makeEvent(edit))
|
||||
expect(res.status).toBe(422)
|
||||
expect(res.data.message).toBe('Validation error')
|
||||
})
|
||||
|
||||
it('handles backend error', async () => {
|
||||
const edit = toEditData({ granblue_id: '1040000000' })
|
||||
const res: any = await actions.save!(makeEvent(edit, { status: 500 }))
|
||||
expect(res.status).toBe(500)
|
||||
})
|
||||
})
|
||||
|
|
@ -6,7 +6,7 @@ export const load: PageServerLoad = async ({ params, fetch, locals }) => {
|
|||
const authUserId = locals.session?.account?.userId
|
||||
|
||||
// Try to fetch party data on the server
|
||||
const partyService = new PartyService(fetch)
|
||||
const partyService = new PartyService()
|
||||
|
||||
let partyFound = false
|
||||
let party = null
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export const load: PageServerLoad = async ({ url, depends }) => {
|
|||
page,
|
||||
total: response.total,
|
||||
totalPages: response.totalPages,
|
||||
perPage: response.perPage || response.per || 20
|
||||
perPage: response.perPage || 20
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('[explore/+page.server.ts] Failed to load teams:', {
|
||||
|
|
|
|||
|
|
@ -1,250 +0,0 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import { sidebar } from '$lib/stores/sidebar.svelte'
|
||||
import Button from '$lib/components/ui/Button.svelte'
|
||||
import { openSearchSidebar } from '$lib/features/search/openSearchSidebar.svelte'
|
||||
import type { SearchResult } from '$lib/api/adapters/search.adapter'
|
||||
|
||||
let selectedItems = $state<SearchResult[]>([])
|
||||
|
||||
function handleAddItems(items: SearchResult[]) {
|
||||
selectedItems = [...selectedItems, ...items]
|
||||
console.log('Added items:', items)
|
||||
}
|
||||
|
||||
function openWeaponSearch() {
|
||||
openSearchSidebar({
|
||||
type: 'weapon',
|
||||
onAddItems: handleAddItems,
|
||||
canAddMore: true
|
||||
})
|
||||
}
|
||||
|
||||
function openCharacterSearch() {
|
||||
openSearchSidebar({
|
||||
type: 'character',
|
||||
onAddItems: handleAddItems,
|
||||
canAddMore: true
|
||||
})
|
||||
}
|
||||
|
||||
function openSummonSearch() {
|
||||
openSearchSidebar({
|
||||
type: 'summon',
|
||||
onAddItems: handleAddItems,
|
||||
canAddMore: true
|
||||
})
|
||||
}
|
||||
|
||||
function openDetailsSidebar() {
|
||||
sidebar.open('Item Details', detailsContent)
|
||||
}
|
||||
|
||||
function openFilterSidebar() {
|
||||
sidebar.open('Filters', filterContent)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<h1>Sidebar Test Page</h1>
|
||||
<p>Click the buttons below to test different sidebar configurations:</p>
|
||||
|
||||
<div class="button-group">
|
||||
<Button variant="primary" onclick={openWeaponSearch}>
|
||||
Search Weapons
|
||||
</Button>
|
||||
<Button variant="primary" onclick={openCharacterSearch}>
|
||||
Search Characters
|
||||
</Button>
|
||||
<Button variant="primary" onclick={openSummonSearch}>
|
||||
Search Summons
|
||||
</Button>
|
||||
<Button variant="secondary" onclick={openDetailsSidebar}>
|
||||
Open Details Sidebar
|
||||
</Button>
|
||||
<Button variant="secondary" onclick={openFilterSidebar}>
|
||||
Open Filter Sidebar
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if selectedItems.length > 0}
|
||||
<div class="selected-items">
|
||||
<h3>Selected Items ({selectedItems.length})</h3>
|
||||
<ul>
|
||||
{#each selectedItems as item}
|
||||
<li>{item.name?.en || item.name?.ja || item.name || 'Unknown'}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="content">
|
||||
<h2>Main Content Area</h2>
|
||||
<p>This content will shrink when the sidebar opens, creating a two-pane layout.</p>
|
||||
<p>All sidebars have a standard width of 420px for consistency.</p>
|
||||
<p>On mobile devices, the sidebar will overlay the main content instead of shrinking it.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#snippet detailsContent()}
|
||||
<div class="sidebar-demo-content">
|
||||
<h3>Item Name</h3>
|
||||
<p>This is a detailed view of an item with lots of information.</p>
|
||||
<div class="detail-section">
|
||||
<h4>Statistics</h4>
|
||||
<ul>
|
||||
<li>Attack: 1000</li>
|
||||
<li>HP: 500</li>
|
||||
<li>Element: Fire</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<h4>Description</h4>
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet filterContent()}
|
||||
<div class="sidebar-demo-content">
|
||||
<div class="filter-group">
|
||||
<h4>Element</h4>
|
||||
<label><input type="checkbox" /> Fire</label>
|
||||
<label><input type="checkbox" /> Water</label>
|
||||
<label><input type="checkbox" /> Earth</label>
|
||||
<label><input type="checkbox" /> Wind</label>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<h4>Rarity</h4>
|
||||
<label><input type="checkbox" /> SSR</label>
|
||||
<label><input type="checkbox" /> SR</label>
|
||||
<label><input type="checkbox" /> R</label>
|
||||
</div>
|
||||
<Button variant="primary" fullWidth onclick={() => sidebar.close()}>
|
||||
Apply Filters
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/spacing' as *;
|
||||
@use '$src/themes/colors' as *;
|
||||
@use '$src/themes/typography' as *;
|
||||
|
||||
.container {
|
||||
padding: $unit-3x;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: $unit;
|
||||
margin: $unit-2x 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.selected-items {
|
||||
margin: $unit-2x 0;
|
||||
padding: $unit-2x;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 $unit 0;
|
||||
font-size: $font-medium;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: $unit-2x;
|
||||
|
||||
li {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: $unit-half;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
margin-top: $unit-3x;
|
||||
padding: $unit-2x;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
:global(.sidebar-demo-content) {
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: $unit;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: $font-regular;
|
||||
margin-bottom: $unit-2x;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
}
|
||||
|
||||
.search-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
padding: $unit;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin: $unit-2x 0;
|
||||
|
||||
h4 {
|
||||
margin-bottom: $unit;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
padding: $unit-half 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
margin-bottom: $unit-2x;
|
||||
|
||||
h4 {
|
||||
margin-bottom: $unit;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
padding: $unit-half 0;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
margin-right: $unit;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,688 +0,0 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import { setContext } from 'svelte'
|
||||
import { createDragDropContext, type DragOperation } from '$lib/composables/drag-drop.svelte'
|
||||
import type { GridCharacter, GridWeapon, GridSummon } from '$lib/types/api/party'
|
||||
import DraggableItem from '$lib/components/dnd/DraggableItem.svelte'
|
||||
import DropZone from '$lib/components/dnd/DropZone.svelte'
|
||||
|
||||
// Create mock data
|
||||
const mockCharacters = [
|
||||
{ id: '1', name: { en: 'Katalina', ja: 'カタリナ' }, granblueId: 3040001000 },
|
||||
{ id: '2', name: { en: 'Rosetta', ja: 'ロゼッタ' }, granblueId: 3040002000 },
|
||||
{ id: '3', name: { en: 'Io', ja: 'イオ' }, granblueId: 3040003000 },
|
||||
{ id: '4', name: { en: 'Rackam', ja: 'ラカム' }, granblueId: 3040004000 },
|
||||
{ id: '5', name: { en: 'Ferry', ja: 'フェリ' }, granblueId: 3040005000 }
|
||||
]
|
||||
|
||||
const mockWeapons = [
|
||||
{ id: 'w1', name: { en: 'Murgleis', ja: 'ミュルグレス' }, granblueId: 1040001000 },
|
||||
{ id: 'w2', name: { en: 'Love Eternal', ja: 'ラブ・エターナル' }, granblueId: 1040002000 },
|
||||
{ id: 'w3', name: { en: 'Certificus', ja: 'ケルティケウス' }, granblueId: 1040003000 },
|
||||
{ id: 'w4', name: { en: 'Blue Sphere', ja: 'ブルースフィア' }, granblueId: 1040004000 },
|
||||
{ id: 'w5', name: { en: 'Ichigo Hitofuri', ja: '一期一振' }, granblueId: 1040005000 }
|
||||
]
|
||||
|
||||
const mockSummons = [
|
||||
{ id: 's1', name: { en: 'Bahamut', ja: 'バハムート' }, granblueId: 2040001000 },
|
||||
{ id: 's2', name: { en: 'Lucifer', ja: 'ルシフェル' }, granblueId: 2040002000 },
|
||||
{ id: 's3', name: { en: 'Europa', ja: 'エウロペ' }, granblueId: 2040003000 },
|
||||
{ id: 's4', name: { en: 'Shiva', ja: 'シヴァ' }, granblueId: 2040004000 }
|
||||
]
|
||||
|
||||
// Grid states
|
||||
let characters = $state<(GridCharacter | undefined)[]>([
|
||||
{ id: 'gc1', position: 0, character: mockCharacters[0] },
|
||||
{ id: 'gc2', position: 1, character: mockCharacters[1] },
|
||||
{ id: 'gc3', position: 2, character: mockCharacters[2] },
|
||||
undefined,
|
||||
undefined
|
||||
])
|
||||
|
||||
let weapons = $state<(GridWeapon | undefined)[]>([
|
||||
{ id: 'gw1', position: -1, mainhand: true, weapon: mockWeapons[0] }, // Mainhand
|
||||
{ id: 'gw2', position: 0, weapon: mockWeapons[1] },
|
||||
undefined,
|
||||
{ id: 'gw3', position: 2, weapon: mockWeapons[2] },
|
||||
undefined,
|
||||
undefined,
|
||||
{ id: 'gw4', position: 5, weapon: mockWeapons[3] },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
])
|
||||
|
||||
let summons = $state<(GridSummon | undefined)[]>([
|
||||
{ id: 'gs1', position: -1, main: true, summon: mockSummons[0] }, // Main
|
||||
{ id: 'gs2', position: 0, summon: mockSummons[1] },
|
||||
undefined,
|
||||
{ id: 'gs3', position: 2, summon: mockSummons[2] },
|
||||
undefined,
|
||||
undefined, // positions 4-5 for subaura
|
||||
{ id: 'gs4', position: 6, friend: true, summon: mockSummons[3] } // Friend
|
||||
])
|
||||
|
||||
// Extra containers
|
||||
let extraCharacters = $state<(GridCharacter | undefined)[]>([
|
||||
{ id: 'egc1', position: 5, character: mockCharacters[3] },
|
||||
{ id: 'egc2', position: 6, character: mockCharacters[4] }
|
||||
])
|
||||
|
||||
let subauras = $state<(GridSummon | undefined)[]>([
|
||||
undefined,
|
||||
undefined
|
||||
])
|
||||
|
||||
let extraWeapons = $state<(GridWeapon | undefined)[]>([
|
||||
{ id: 'egw1', position: 9, weapon: mockWeapons[4] },
|
||||
undefined,
|
||||
undefined
|
||||
])
|
||||
|
||||
// Operation tracking
|
||||
let operations = $state<DragOperation[]>([])
|
||||
|
||||
// Create drag-drop context
|
||||
const dragContext = createDragDropContext({
|
||||
onLocalUpdate: (operation) => {
|
||||
console.log('📝 Local update:', operation)
|
||||
operations = [...operations, operation]
|
||||
handleOperation(operation)
|
||||
// Clear the operation from queue after processing
|
||||
setTimeout(() => {
|
||||
const processed = dragContext.getQueuedOperations()
|
||||
if (processed.length > 0) {
|
||||
// Mark as processed by clearing from context
|
||||
dragContext.clearQueue()
|
||||
}
|
||||
}, 100)
|
||||
},
|
||||
onValidate: (source, target) => {
|
||||
// Custom validation rules
|
||||
if (source.type !== target.type) return false
|
||||
|
||||
// Characters: Sequential filling
|
||||
if (source.type === 'character' && target.container === 'main-characters') {
|
||||
// Allow drops only in sequential order
|
||||
const filledCount = characters.filter(c => c).length
|
||||
if (target.position >= filledCount) return false
|
||||
}
|
||||
|
||||
// Weapons: Mainhand not draggable
|
||||
if (target.type === 'weapon' && target.position === -1) return false
|
||||
|
||||
// Summons: Main/Friend not draggable
|
||||
if (target.type === 'summon' && (target.position === -1 || target.position === 6)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
setContext('drag-drop', dragContext)
|
||||
|
||||
function handleOperation(operation: DragOperation) {
|
||||
const { source, target } = operation
|
||||
|
||||
if (operation.type === 'swap') {
|
||||
handleSwap(source, target)
|
||||
} else if (operation.type === 'move') {
|
||||
handleMove(source, target)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSwap(source: any, target: any) {
|
||||
console.log('🔄 Swapping:', source, target)
|
||||
|
||||
// Get container info with position mapping
|
||||
const sourceInfo = getContainerInfo(source.container, source.position)
|
||||
const targetInfo = getContainerInfo(target.container, target.position)
|
||||
|
||||
if (!sourceInfo || !targetInfo) {
|
||||
console.error('Invalid container', source.container, target.container)
|
||||
return
|
||||
}
|
||||
|
||||
// Find the items using mapped indices
|
||||
const sourceItem = sourceInfo.array[sourceInfo.index]
|
||||
const targetItem = targetInfo.array[targetInfo.index]
|
||||
|
||||
if (!sourceItem) {
|
||||
console.error('Source item not found')
|
||||
return
|
||||
}
|
||||
|
||||
// If there's no target item, this is actually a move, not a swap
|
||||
if (!targetItem) {
|
||||
handleMove(source, target)
|
||||
return
|
||||
}
|
||||
|
||||
// Perform the swap
|
||||
if (sourceInfo.container === target.container) {
|
||||
// Same container - update the appropriate array
|
||||
const updatedArray = getUpdatedArray(source.container, (arr) => {
|
||||
const temp = [...arr]
|
||||
temp[sourceInfo.index] = targetItem
|
||||
temp[targetInfo.index] = sourceItem
|
||||
|
||||
// Preserve the original position properties
|
||||
if (temp[sourceInfo.index]) temp[sourceInfo.index].position = source.position
|
||||
if (temp[targetInfo.index]) temp[targetInfo.index].position = target.position
|
||||
|
||||
return temp
|
||||
})
|
||||
|
||||
setArrayForContainer(source.container, updatedArray)
|
||||
} else {
|
||||
// Different containers - cross-container swap
|
||||
const updatedSource = getUpdatedArray(source.container, (arr) => {
|
||||
const temp = [...arr]
|
||||
temp[sourceInfo.index] = targetItem
|
||||
if (temp[sourceInfo.index]) temp[sourceInfo.index].position = source.position
|
||||
return temp
|
||||
})
|
||||
|
||||
const updatedTarget = getUpdatedArray(target.container, (arr) => {
|
||||
const temp = [...arr]
|
||||
temp[targetInfo.index] = sourceItem
|
||||
if (temp[targetInfo.index]) temp[targetInfo.index].position = target.position
|
||||
return temp
|
||||
})
|
||||
|
||||
setArrayForContainer(source.container, updatedSource)
|
||||
setArrayForContainer(target.container, updatedTarget)
|
||||
}
|
||||
}
|
||||
|
||||
function handleMove(source: any, target: any) {
|
||||
console.log('📦 Moving:', source, target)
|
||||
|
||||
const sourceInfo = getContainerInfo(source.container, source.position)
|
||||
const targetInfo = getContainerInfo(target.container, target.position)
|
||||
|
||||
if (!sourceInfo || !targetInfo) {
|
||||
console.error('Invalid container', source.container, target.container)
|
||||
return
|
||||
}
|
||||
|
||||
const sourceItem = sourceInfo.array[sourceInfo.index]
|
||||
if (!sourceItem) {
|
||||
console.error('Source item not found')
|
||||
return
|
||||
}
|
||||
|
||||
if (source.container === target.container) {
|
||||
// Same container - move within
|
||||
const updatedArray = getUpdatedArray(source.container, (arr) => {
|
||||
const temp = [...arr]
|
||||
temp[sourceInfo.index] = undefined
|
||||
temp[targetInfo.index] = sourceItem
|
||||
if (temp[targetInfo.index]) temp[targetInfo.index].position = target.position
|
||||
return temp
|
||||
})
|
||||
|
||||
setArrayForContainer(source.container, updatedArray)
|
||||
} else {
|
||||
// Different containers - move across
|
||||
const updatedSource = getUpdatedArray(source.container, (arr) => {
|
||||
const temp = [...arr]
|
||||
temp[sourceInfo.index] = undefined
|
||||
return temp
|
||||
})
|
||||
|
||||
const updatedTarget = getUpdatedArray(target.container, (arr) => {
|
||||
const temp = [...arr]
|
||||
temp[targetInfo.index] = sourceItem
|
||||
if (temp[targetInfo.index]) temp[targetInfo.index].position = target.position
|
||||
return temp
|
||||
})
|
||||
|
||||
setArrayForContainer(source.container, updatedSource)
|
||||
setArrayForContainer(target.container, updatedTarget)
|
||||
}
|
||||
}
|
||||
|
||||
function getContainerInfo(container: string, position: number) {
|
||||
switch (container) {
|
||||
case 'main-characters':
|
||||
return { array: characters, index: position, container }
|
||||
case 'extra-characters':
|
||||
// Extra characters have positions 5-6 but array indices 0-1
|
||||
return { array: extraCharacters, index: position - 5, container }
|
||||
case 'main-weapons':
|
||||
// Main weapons are positions 0-8, but need to account for mainhand
|
||||
// The actual weapons array has mainhand at index 0, sub-weapons at 1-9
|
||||
return { array: weapons.slice(1, 10), index: position, container }
|
||||
case 'extra-weapons':
|
||||
// Extra weapons have positions 9-11 but array indices 0-2
|
||||
return { array: extraWeapons, index: position - 9, container }
|
||||
case 'main-summons':
|
||||
// Main summons are positions 0-3, need to account for main summon
|
||||
// The actual summons array has main at 0, subs at 1-4
|
||||
return { array: summons.slice(1, 5), index: position, container }
|
||||
case 'subaura':
|
||||
// Subaura have positions 4-5 but array indices 0-1
|
||||
return { array: subauras, index: position - 4, container }
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getUpdatedArray(container: string, updateFn: (arr: any[]) => any[]) {
|
||||
switch (container) {
|
||||
case 'main-characters':
|
||||
return updateFn(characters)
|
||||
case 'extra-characters':
|
||||
return updateFn(extraCharacters)
|
||||
case 'main-weapons':
|
||||
// For weapons, we need to work with the sub-weapons only
|
||||
const subWeapons = weapons.slice(1, 10)
|
||||
return updateFn(subWeapons)
|
||||
case 'extra-weapons':
|
||||
return updateFn(extraWeapons)
|
||||
case 'main-summons':
|
||||
// For summons, we need to work with the sub-summons only
|
||||
const subSummons = summons.slice(1, 5)
|
||||
return updateFn(subSummons)
|
||||
case 'subaura':
|
||||
return updateFn(subauras)
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function getArrayForContainer(container: string) {
|
||||
switch (container) {
|
||||
case 'main-characters': return characters
|
||||
case 'extra-characters': return extraCharacters
|
||||
case 'main-weapons': return weapons.slice(1, 10) // Skip mainhand
|
||||
case 'extra-weapons': return extraWeapons
|
||||
case 'main-summons': return summons.slice(1, 5) // Skip main, get sub-summons
|
||||
case 'subaura': return subauras
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
function setArrayForContainer(container: string, newArray: any[]) {
|
||||
switch (container) {
|
||||
case 'main-characters':
|
||||
characters = newArray
|
||||
break
|
||||
case 'extra-characters':
|
||||
extraCharacters = newArray
|
||||
break
|
||||
case 'main-weapons':
|
||||
// Update weapons array (preserving mainhand)
|
||||
weapons = [weapons[0], ...newArray]
|
||||
break
|
||||
case 'extra-weapons':
|
||||
extraWeapons = newArray
|
||||
break
|
||||
case 'main-summons':
|
||||
// Update summons (preserving main and friend)
|
||||
summons = [summons[0], ...newArray, summons[5], summons[6]]
|
||||
break
|
||||
case 'subaura':
|
||||
subauras = newArray
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function handleCharacterDrop(fromPos: number, toPos: number) {
|
||||
const temp = [...characters]
|
||||
const item = temp[fromPos]
|
||||
|
||||
if (!item) return
|
||||
|
||||
// Remove from source
|
||||
temp[fromPos] = undefined
|
||||
|
||||
// Insert at target
|
||||
if (temp[toPos]) {
|
||||
// Swap
|
||||
temp[fromPos] = temp[toPos]
|
||||
}
|
||||
temp[toPos] = item
|
||||
|
||||
// Update positions
|
||||
temp.forEach((char, idx) => {
|
||||
if (char) char.position = idx
|
||||
})
|
||||
|
||||
// Ensure sequential filling
|
||||
characters = temp.filter(c => c).concat(temp.filter(c => !c))
|
||||
}
|
||||
|
||||
// Sync status
|
||||
let syncStatus = $derived(
|
||||
dragContext.getQueuedOperations().length > 0 ? 'pending' : 'idle'
|
||||
)
|
||||
</script>
|
||||
|
||||
<div class="test-container">
|
||||
<header>
|
||||
<h1>Drag & Drop Test</h1>
|
||||
<div class="status">
|
||||
{#if syncStatus === 'pending'}
|
||||
<span class="pending">⏳ {dragContext.getQueuedOperations().length} pending operations</span>
|
||||
{:else}
|
||||
<span class="idle">✅ All synced</span>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="grid-section">
|
||||
<h2>Character Grid</h2>
|
||||
<div class="character-grid">
|
||||
{#each characters as char, idx}
|
||||
<DropZone
|
||||
container="main-characters"
|
||||
position={idx}
|
||||
type="character"
|
||||
item={char}
|
||||
>
|
||||
<DraggableItem
|
||||
item={char}
|
||||
container="main-characters"
|
||||
position={idx}
|
||||
type="character"
|
||||
canDrag={!!char}
|
||||
>
|
||||
<div class="unit character-unit">
|
||||
{#if char}
|
||||
<div class="image">👤</div>
|
||||
<div class="name">{char.character.name?.en}</div>
|
||||
{:else}
|
||||
<div class="empty-slot">Empty</div>
|
||||
{/if}
|
||||
</div>
|
||||
</DraggableItem>
|
||||
</DropZone>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<h3>Extra Characters</h3>
|
||||
<div class="extra-grid">
|
||||
{#each extraCharacters as char, idx}
|
||||
<DropZone
|
||||
container="extra-characters"
|
||||
position={idx + 5}
|
||||
type="character"
|
||||
item={char}
|
||||
>
|
||||
<DraggableItem
|
||||
item={char}
|
||||
container="extra-characters"
|
||||
position={idx + 5}
|
||||
type="character"
|
||||
canDrag={!!char}
|
||||
>
|
||||
<div class="unit character-unit">
|
||||
{#if char}
|
||||
<div class="image">👤</div>
|
||||
<div class="name">{char.character.name?.en}</div>
|
||||
{:else}
|
||||
<div class="empty-slot">Empty</div>
|
||||
{/if}
|
||||
</div>
|
||||
</DraggableItem>
|
||||
</DropZone>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid-section">
|
||||
<h2>Weapon Grid</h2>
|
||||
<div class="weapon-grid">
|
||||
<div class="mainhand">
|
||||
<h4>Mainhand</h4>
|
||||
<div class="unit weapon-unit mainhand-unit">
|
||||
{#if weapons[0]}
|
||||
<div class="image">⚔️</div>
|
||||
<div class="name">{weapons[0].weapon.name?.en}</div>
|
||||
{:else}
|
||||
<div class="empty-slot">Empty</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="subweapons">
|
||||
{#each weapons.slice(1, 10) as weapon, idx}
|
||||
<DropZone
|
||||
container="main-weapons"
|
||||
position={idx}
|
||||
type="weapon"
|
||||
item={weapon}
|
||||
>
|
||||
<DraggableItem
|
||||
item={weapon}
|
||||
container="main-weapons"
|
||||
position={idx}
|
||||
type="weapon"
|
||||
canDrag={!!weapon}
|
||||
>
|
||||
<div class="unit weapon-unit">
|
||||
{#if weapon}
|
||||
<div class="image">⚔️</div>
|
||||
<div class="name">{weapon.weapon.name?.en}</div>
|
||||
{:else}
|
||||
<div class="empty-slot">Empty</div>
|
||||
{/if}
|
||||
</div>
|
||||
</DraggableItem>
|
||||
</DropZone>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid-section">
|
||||
<h2>Summon Grid</h2>
|
||||
<div class="summon-grid">
|
||||
<div class="main-summon">
|
||||
<h4>Main</h4>
|
||||
<div class="unit summon-unit">
|
||||
{#if summons[0]}
|
||||
<div class="image">🐉</div>
|
||||
<div class="name">{summons[0].summon.name?.en}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="subsummons">
|
||||
{#each summons.slice(1, 5) as summon, idx}
|
||||
<DropZone
|
||||
container="main-summons"
|
||||
position={idx}
|
||||
type="summon"
|
||||
item={summon}
|
||||
>
|
||||
<DraggableItem
|
||||
item={summon}
|
||||
container="main-summons"
|
||||
position={idx}
|
||||
type="summon"
|
||||
canDrag={!!summon}
|
||||
>
|
||||
<div class="unit summon-unit">
|
||||
{#if summon}
|
||||
<div class="image">🐉</div>
|
||||
<div class="name">{summon.summon.name?.en}</div>
|
||||
{:else}
|
||||
<div class="empty-slot">Empty</div>
|
||||
{/if}
|
||||
</div>
|
||||
</DraggableItem>
|
||||
</DropZone>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="friend-summon">
|
||||
<h4>Friend</h4>
|
||||
<div class="unit summon-unit">
|
||||
{#if summons[6]}
|
||||
<div class="image">🐉</div>
|
||||
<div class="name">{summons[6].summon.name?.en}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Subaura</h3>
|
||||
<div class="subaura-grid">
|
||||
{#each subauras as summon, idx}
|
||||
<DropZone
|
||||
container="subaura"
|
||||
position={idx + 4}
|
||||
type="summon"
|
||||
item={summon}
|
||||
>
|
||||
<DraggableItem
|
||||
item={summon}
|
||||
container="subaura"
|
||||
position={idx + 4}
|
||||
type="summon"
|
||||
canDrag={!!summon}
|
||||
>
|
||||
<div class="unit summon-unit">
|
||||
{#if summon}
|
||||
<div class="image">🐉</div>
|
||||
<div class="name">{summon.summon.name?.en}</div>
|
||||
{:else}
|
||||
<div class="empty-slot">Empty</div>
|
||||
{/if}
|
||||
</div>
|
||||
</DraggableItem>
|
||||
</DropZone>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="operations">
|
||||
<h2>Operations Log</h2>
|
||||
<pre>{JSON.stringify(operations, null, 2)}</pre>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.test-container {
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
|
||||
.status {
|
||||
.pending {
|
||||
color: orange;
|
||||
}
|
||||
.idle {
|
||||
color: green;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.grid-section {
|
||||
margin-bottom: 40px;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
|
||||
h2 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.character-grid, .extra-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.weapon-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 3fr;
|
||||
gap: 20px;
|
||||
|
||||
.subweapons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.summon-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr 1fr;
|
||||
gap: 20px;
|
||||
|
||||
.subsummons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.subaura-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.unit {
|
||||
padding: 15px;
|
||||
background: white;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
min-height: 100px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.image {
|
||||
font-size: 32px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.empty-slot {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.mainhand-unit, .main-summon .unit, .friend-summon .unit {
|
||||
background: #e3f2fd;
|
||||
border-color: #2196f3;
|
||||
}
|
||||
|
||||
.operations {
|
||||
margin-top: 40px;
|
||||
padding: 20px;
|
||||
background: #263238;
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
|
||||
pre {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in a new issue