From 1b6da60aa30ca67052c0e584c63ca458e3bc36cc Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Mon, 15 Sep 2025 04:04:09 -0700 Subject: [PATCH] Add API client and resource modules --- src/lib/api/client.ts | 444 +++++++++++++ src/lib/api/resources/grid.ts | 197 ++++++ src/lib/api/resources/parties.ts | 622 +++++++++--------- src/lib/api/resources/search.ts | 155 +++++ src/lib/api/schemas/party.ts | 83 +-- src/routes/api/parties/+server.ts | 43 ++ src/routes/api/parties/[id]/+server.ts | 66 ++ .../api/parties/[id]/characters/+server.ts | 79 +++ .../api/parties/[id]/summons/+server.ts | 81 +++ .../api/parties/[id]/weapons/+server.ts | 80 +++ 10 files changed, 1454 insertions(+), 396 deletions(-) create mode 100644 src/lib/api/client.ts create mode 100644 src/lib/api/resources/grid.ts create mode 100644 src/lib/api/resources/search.ts create mode 100644 src/routes/api/parties/+server.ts create mode 100644 src/routes/api/parties/[id]/+server.ts create mode 100644 src/routes/api/parties/[id]/characters/+server.ts create mode 100644 src/routes/api/parties/[id]/summons/+server.ts create mode 100644 src/routes/api/parties/[id]/weapons/+server.ts diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts new file mode 100644 index 00000000..afb4cc37 --- /dev/null +++ b/src/lib/api/client.ts @@ -0,0 +1,444 @@ +/** + * 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(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(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): Promise { + 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}`) + } + + return response.json() + } + + /** + * Delete a party + */ + async deleteParty(partyId: string): Promise { + 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 { + 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() + } + + /** + * Remove a weapon from a party + */ + async removeWeapon(partyId: string, gridWeaponId: string): Promise { + const editKey = this.getEditKey(partyId) + + 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) { + const error = await response.json() + 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 { + 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() + } + + /** + * Remove a summon from a party + */ + async removeSummon(partyId: string, gridSummonId: string): Promise { + 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() + 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 { + 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() + } + + /** + * Remove a character from a party + */ + async removeCharacter(partyId: string, gridCharacterId: string): Promise { + 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() + throw new Error(error.error || `Failed to remove character: ${response.statusText}`) + } + } + + /** + * 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() \ No newline at end of file diff --git a/src/lib/api/resources/grid.ts b/src/lib/api/resources/grid.ts new file mode 100644 index 00000000..14954085 --- /dev/null +++ b/src/lib/api/resources/grid.ts @@ -0,0 +1,197 @@ +import { buildUrl, type FetchLike } from '$lib/api/core' + +/** + * Grid API resource functions for managing party items + */ + +// Weapon grid operations +export async function addWeapon( + fetch: FetchLike, + partyId: string, + weaponId: string, // Granblue ID + position: number, + options?: { + mainhand?: boolean + uncapLevel?: number + transcendenceStep?: number + element?: number + }, + headers?: Record +): Promise { + const body = { + weapon: { + party_id: partyId, + weapon_id: weaponId, + position, + mainhand: position === -1 || options?.mainhand, + uncap_level: options?.uncapLevel ?? 3, + transcendence_step: options?.transcendenceStep ?? 0, + element: options?.element + } + } + + const res = await fetch(buildUrl('/weapons'), { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...headers + }, + body: JSON.stringify(body) + }) + + if (!res.ok) { + throw new Error(`Failed to add weapon: ${res.statusText}`) + } + + return res.json() +} + +export async function removeWeapon( + fetch: FetchLike, + partyId: string, + gridWeaponId: string, + headers?: Record +): Promise { + const res = await fetch(buildUrl('/weapons'), { + method: 'DELETE', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...headers + }, + body: JSON.stringify({ grid_weapon_id: gridWeaponId }) + }) + + if (!res.ok) { + throw new Error(`Failed to remove weapon: ${res.statusText}`) + } +} + +// Summon grid operations +export async function addSummon( + fetch: FetchLike, + partyId: string, + summonId: string, // Granblue ID + position: number, + options?: { + main?: boolean + friend?: boolean + quickSummon?: boolean + uncapLevel?: number + transcendenceStep?: number + }, + headers?: Record +): Promise { + const body = { + summon: { + party_id: partyId, + summon_id: summonId, + position, + main: position === -1 || options?.main, + friend: position === 6 || options?.friend, + quick_summon: options?.quickSummon ?? false, + uncap_level: options?.uncapLevel ?? 3, + transcendence_step: options?.transcendenceStep ?? 0 + } + } + + const res = await fetch(buildUrl('/summons'), { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...headers + }, + body: JSON.stringify(body) + }) + + if (!res.ok) { + throw new Error(`Failed to add summon: ${res.statusText}`) + } + + return res.json() +} + +export async function removeSummon( + fetch: FetchLike, + partyId: string, + gridSummonId: string, + headers?: Record +): Promise { + const res = await fetch(buildUrl('/summons'), { + method: 'DELETE', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...headers + }, + body: JSON.stringify({ grid_summon_id: gridSummonId }) + }) + + if (!res.ok) { + throw new Error(`Failed to remove summon: ${res.statusText}`) + } +} + +// Character grid operations +export async function addCharacter( + fetch: FetchLike, + partyId: string, + characterId: string, // Granblue ID + position: number, + options?: { + uncapLevel?: number + transcendenceStep?: number + perpetuity?: boolean + }, + headers?: Record +): Promise { + const body = { + character: { + party_id: partyId, + character_id: characterId, + position, + uncap_level: options?.uncapLevel ?? 3, + transcendence_step: options?.transcendenceStep ?? 0, + perpetuity: options?.perpetuity ?? false + } + } + + const res = await fetch(buildUrl('/characters'), { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...headers + }, + body: JSON.stringify(body) + }) + + if (!res.ok) { + throw new Error(`Failed to add character: ${res.statusText}`) + } + + return res.json() +} + +export async function removeCharacter( + fetch: FetchLike, + partyId: string, + gridCharacterId: string, + headers?: Record +): Promise { + const res = await fetch(buildUrl('/characters'), { + method: 'DELETE', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...headers + }, + body: JSON.stringify({ grid_character_id: gridCharacterId }) + }) + + if (!res.ok) { + throw new Error(`Failed to remove character: ${res.statusText}`) + } +} \ No newline at end of file diff --git a/src/lib/api/resources/parties.ts b/src/lib/api/resources/parties.ts index c43a56d9..7af08ec5 100644 --- a/src/lib/api/resources/parties.ts +++ b/src/lib/api/resources/parties.ts @@ -1,5 +1,6 @@ import { buildUrl, get, post, put, del, type FetchLike } from '$lib/api/core' -import { parseParty, type Party } from '$lib/api/schemas/party' +import { parseParty } from '$lib/api/schemas/party' +import type { Party } from '$lib/types/api/party' import { camelToSnake } from '$lib/api/schemas/transforms' import { z } from 'zod' @@ -11,372 +12,347 @@ import { z } from 'zod' // Note: The API returns snake_case; we validate with raw schemas and // convert to camelCase via parseParty() at the edge. const PartyResponseSchema = z.object({ - party: z.any() // We'll validate after extracting + party: z.any() // We'll validate after extracting }) const PartiesResponseSchema = z.object({ - parties: z.array(z.any()), - total: z.number().optional() + parties: z.array(z.any()), + total: z.number().optional() }) const ConflictResponseSchema = z.object({ - conflicts: z.array(z.string()), - incoming: z.string(), - position: z.number() + conflicts: z.array(z.string()), + incoming: z.string(), + position: z.number() }) const PaginatedPartiesSchema = z.object({ - results: z.array(z.any()), - meta: z - .object({ - count: z.number().optional(), - total_pages: z.number().optional(), - per_page: z.number().optional() - }) - .optional() + results: z.array(z.any()), + meta: z + .object({ + count: z.number().optional(), + total_pages: z.number().optional(), + per_page: z.number().optional() + }) + .optional() }) // API functions export async function getByShortcode(fetch: FetchLike, shortcode: string): Promise { - const url = buildUrl(`/parties/${encodeURIComponent(shortcode)}`) - const res = await fetch(url, { credentials: 'include' }) - - if (!res.ok) { - const error = await parseError(res) - throw error - } - - const json = await res.json() - // Extract the party object (API returns { party: {...} }) - const partyData = json?.party || json - - // Validate and transform snake_case to camelCase - const parsed = parseParty(partyData) - return parsed + const url = buildUrl(`/parties/${encodeURIComponent(shortcode)}`) + const res = await fetch(url, { credentials: 'include' }) + + if (!res.ok) { + const error = await parseError(res) + throw error + } + + const json = await res.json() + // Extract the party object (API returns { party: {...} }) + const partyData = json?.party || json + + // Validate and transform snake_case to camelCase + const parsed = parseParty(partyData) + + return parsed } export async function create( - fetch: FetchLike, - payload: Partial, - headers?: Record -): Promise { - const body = camelToSnake(payload) - const res = await fetch(buildUrl('/parties'), { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - ...headers - }, - body: JSON.stringify(body) - }) - - if (!res.ok) { - const error = await parseError(res) - throw error - } - - const json = await res.json() - const parsed = PartyResponseSchema.parse(json) - return parseParty(parsed.party) -} + fetch: FetchLike, + payload: Partial, + headers?: Record +): Promise<{ party: Party; editKey?: string }> { + const url = buildUrl('/parties') + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...headers + }, + body: JSON.stringify(camelToSnake(payload)), + credentials: 'include' + }) -export async function list( - fetch: FetchLike, - params?: { page?: number } -): Promise<{ items: Party[]; total?: number; totalPages?: number; perPage?: number; page: number }> { - const page = params?.page && params.page > 0 ? params.page : 1 - const url = buildUrl('/parties', { page }) - const res = await fetch(url, { credentials: 'include' }) + if (!res.ok) { + const error = await parseError(res) + throw error + } - if (!res.ok) { - const error = await parseError(res) - throw error - } + const json = await res.json() + const party = parseParty(json.party) - const json = await res.json() - // Controller returns { results: [...], meta: { count, total_pages, per_page } } - const parsed = PaginatedPartiesSchema.parse(json) - const items = (parsed.results || []).map(parseParty) - return { - items, - total: parsed.meta?.count, - totalPages: parsed.meta?.total_pages, - perPage: parsed.meta?.per_page, - page - } -} - -export async function favorites( - fetch: FetchLike, - params?: { page?: number } -): Promise<{ items: Party[]; total?: number; totalPages?: number; perPage?: number; page: number }> { - const page = params?.page && params.page > 0 ? params.page : 1 - const url = buildUrl('/parties/favorites', { page }) - const res = await fetch(url, { credentials: 'include' }) - - if (!res.ok) { - const error = await parseError(res) - throw error - } - - const json = await res.json() - const parsed = PaginatedPartiesSchema.parse(json) - const items = (parsed.results || []).map(parseParty) - return { - items, - total: parsed.meta?.count, - totalPages: parsed.meta?.total_pages, - perPage: parsed.meta?.per_page, - page - } + return { + party, + editKey: json.edit_key + } } export async function update( - fetch: FetchLike, - id: string, - payload: Partial, - headers?: Record + fetch: FetchLike, + id: string, + payload: Partial, + headers?: Record ): Promise { - const body = camelToSnake(payload) - const res = await fetch(buildUrl(`/parties/${id}`), { - method: 'PUT', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - ...headers - }, - body: JSON.stringify(body) - }) - - if (!res.ok) { - const error = await parseError(res) - throw error - } - - const json = await res.json() - - // Handle conflict response - if ('conflicts' in json) { - const conflict = ConflictResponseSchema.parse(json) - throw { - type: 'conflict', - ...conflict - } - } - - const parsed = PartyResponseSchema.parse(json) - return parseParty(parsed.party) + const url = buildUrl(`/parties/${encodeURIComponent(id)}`) + const res = await fetch(url, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...headers + }, + body: JSON.stringify(camelToSnake(payload)), + credentials: 'include' + }) + + if (!res.ok) { + const error = await parseError(res) + throw error + } + + const json = await res.json() + return parseParty(json.party || json) } export async function remix( - fetch: FetchLike, - shortcode: string, - localId?: string, - headers?: Record + fetch: FetchLike, + shortcode: string, + localId?: string, + headers?: Record ): Promise<{ party: Party; editKey?: string }> { - const body = localId ? { local_id: localId } : {} - const res = await fetch(buildUrl(`/parties/${encodeURIComponent(shortcode)}/remix`), { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - ...headers - }, - body: JSON.stringify(body) - }) - - if (!res.ok) { - const error = await parseError(res) - throw error - } - - const json = await res.json() - const parsed = PartyResponseSchema.parse(json) - - // Check for edit_key in response - const editKey = (json as any).edit_key - - return { - party: parseParty(parsed.party), - editKey - } -} + const url = buildUrl(`/parties/${encodeURIComponent(shortcode)}/remix`) + const payload = localId ? { local_id: localId } : {} -export async function favorite( - fetch: FetchLike, - id: string, - headers?: Record -): Promise { - const res = await fetch(buildUrl(`/parties/${id}/favorite`), { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - ...headers - } - }) - - if (!res.ok) { - const error = await parseError(res) - throw error - } -} + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...headers + }, + body: JSON.stringify(payload), + credentials: 'include' + }) -export async function unfavorite( - fetch: FetchLike, - id: string, - headers?: Record -): Promise { - const res = await fetch(buildUrl(`/parties/${id}/unfavorite`), { - method: 'DELETE', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - ...headers - } - }) - - if (!res.ok) { - const error = await parseError(res) - throw error - } + if (!res.ok) { + const error = await parseError(res) + throw error + } + + const json = await res.json() + const party = parseParty(json.party) + + return { + party, + editKey: json.edit_key + } } export async function deleteParty( - fetch: FetchLike, - id: string, - headers?: Record + fetch: FetchLike, + id: string, + headers?: Record ): Promise { - const res = await fetch(buildUrl(`/parties/${id}`), { - method: 'DELETE', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - ...headers - } - }) - - if (!res.ok) { - const error = await parseError(res) - throw error - } + const url = buildUrl(`/parties/${encodeURIComponent(id)}`) + const res = await fetch(url, { + method: 'DELETE', + headers: { + ...headers + }, + credentials: 'include' + }) + + if (!res.ok) { + const error = await parseError(res) + throw error + } } -// Grid update functions +export async function getUserParties( + fetch: FetchLike, + username: string, + filters?: { + raid?: string + element?: number + recency?: number + page?: number + } +): Promise<{ + parties: Party[] + meta?: { + count?: number + totalPages?: number + perPage?: number + } +}> { + const params = new URLSearchParams() + if (filters?.raid) params.set('raid', filters.raid) + if (filters?.element !== undefined) params.set('element', filters.element.toString()) + if (filters?.recency !== undefined) params.set('recency', filters.recency.toString()) + if (filters?.page !== undefined) params.set('page', filters.page.toString()) + + const queryString = params.toString() + const url = buildUrl(`/users/${encodeURIComponent(username)}/parties${queryString ? `?${queryString}` : ''}`) + + const res = await fetch(url, { credentials: 'include' }) + + if (!res.ok) { + const error = await parseError(res) + throw error + } + + const json = await res.json() + const parsed = PaginatedPartiesSchema.safeParse(json) + + if (!parsed.success) { + // Fallback for different response formats + const fallback = PartiesResponseSchema.safeParse(json) + if (fallback.success) { + return { + parties: fallback.data.parties.map(parseParty) + } + } + throw new Error('Invalid response format') + } + + return { + parties: parsed.data.results.map(parseParty), + meta: parsed.data.meta + ? { + count: parsed.data.meta.count, + totalPages: parsed.data.meta.total_pages, + perPage: parsed.data.meta.per_page + } + : undefined + } +} + +// Grid operations export async function updateWeaponGrid( - fetch: FetchLike, - partyId: string, - payload: any, - headers?: Record + fetch: FetchLike, + partyId: string, + payload: any, + headers?: Record ): Promise { - const body = camelToSnake(payload) - const res = await fetch(buildUrl(`/parties/${partyId}/grid_weapons`), { - method: 'PUT', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - ...headers - }, - body: JSON.stringify(body) - }) - - if (!res.ok) { - const error = await parseError(res) - throw error - } - - const json = await res.json() - - // Handle conflict response - if ('conflicts' in json) { - const conflict = ConflictResponseSchema.parse(json) - throw { - type: 'conflict', - ...conflict - } - } - - const parsed = PartyResponseSchema.parse(json) - return parseParty(parsed.party) + const url = buildUrl(`/parties/${encodeURIComponent(partyId)}/grid_weapons`) + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...headers + }, + body: JSON.stringify(camelToSnake(payload)), + credentials: 'include' + }) + + if (!res.ok) { + const error = await parseError(res) + throw error + } + + const json = await res.json() + + // Check for conflicts + if (json.conflicts) { + const error = new Error('Weapon conflict') as any + error.conflicts = json + throw error + } + + return parseParty(json.party || json) } export async function updateSummonGrid( - fetch: FetchLike, - partyId: string, - payload: any, - headers?: Record + fetch: FetchLike, + partyId: string, + payload: any, + headers?: Record ): Promise { - const body = camelToSnake(payload) - const res = await fetch(buildUrl(`/parties/${partyId}/grid_summons`), { - method: 'PUT', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - ...headers - }, - body: JSON.stringify(body) - }) - - if (!res.ok) { - const error = await parseError(res) - throw error - } - - const json = await res.json() - const parsed = PartyResponseSchema.parse(json) - return parseParty(parsed.party) + const url = buildUrl(`/parties/${encodeURIComponent(partyId)}/grid_summons`) + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...headers + }, + body: JSON.stringify(camelToSnake(payload)), + credentials: 'include' + }) + + if (!res.ok) { + const error = await parseError(res) + throw error + } + + const json = await res.json() + return parseParty(json.party || json) } export async function updateCharacterGrid( - fetch: FetchLike, - partyId: string, - payload: any, - headers?: Record + fetch: FetchLike, + partyId: string, + payload: any, + headers?: Record ): Promise { - const body = camelToSnake(payload) - const res = await fetch(buildUrl(`/parties/${partyId}/grid_characters`), { - method: 'PUT', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - ...headers - }, - body: JSON.stringify(body) - }) - - if (!res.ok) { - const error = await parseError(res) - throw error - } - - const json = await res.json() - - // Handle conflict response - if ('conflicts' in json) { - const conflict = ConflictResponseSchema.parse(json) - throw { - type: 'conflict', - ...conflict - } - } - - const parsed = PartyResponseSchema.parse(json) - return parseParty(parsed.party) + const url = buildUrl(`/parties/${encodeURIComponent(partyId)}/grid_characters`) + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...headers + }, + body: JSON.stringify(camelToSnake(payload)), + credentials: 'include' + }) + + if (!res.ok) { + const error = await parseError(res) + throw error + } + + const json = await res.json() + + // Check for conflicts + if (json.conflicts) { + const error = new Error('Character conflict') as any + error.conflicts = json + throw error + } + + return parseParty(json.party || json) } -// Helper to parse API errors -async function parseError(res: Response): Promise { - let message = res.statusText || 'Request failed' - - try { - const data = await res.clone().json() - if (typeof data?.error === 'string') message = data.error - else if (typeof data?.message === 'string') message = data.message - else if (Array.isArray(data?.errors)) message = data.errors.join(', ') - } catch {} - - const error = new Error(message) as any - error.status = res.status - return error -} +// Error parsing +async function parseError(res: Response): Promise { + let message = 'Request failed' + let details: any[] = [] + + try { + const errorData = await res.json() + if (errorData.error) { + message = errorData.error + } else if (errorData.errors) { + if (Array.isArray(errorData.errors)) { + message = errorData.errors.join(', ') + details = errorData.errors + } else if (typeof errorData.errors === 'object') { + const messages: string[] = [] + for (const [field, errors] of Object.entries(errorData.errors)) { + if (Array.isArray(errors)) { + messages.push(`${field}: ${errors.join(', ')}`) + } + } + message = messages.join('; ') + details = Object.entries(errorData.errors) + } + } + } catch { + // If JSON parsing fails, use status text + message = res.statusText || message + } + + const error = new Error(message) as Error & { status: number; details?: any[] } + error.status = res.status + if (details.length > 0) { + error.details = details + } + return error +} \ No newline at end of file diff --git a/src/lib/api/resources/search.ts b/src/lib/api/resources/search.ts new file mode 100644 index 00000000..bc0fe803 --- /dev/null +++ b/src/lib/api/resources/search.ts @@ -0,0 +1,155 @@ +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(fetchFn: FetchLike, url: string, body?: unknown): Promise { + 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 +} + +export interface SearchParams { + query?: string + locale?: 'en' | 'ja' + exclude?: string[] + page?: 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 +} + +export function searchAll( + params: SearchParams, + init?: RequestInit, + fetchFn: FetchLike = fetch +): Promise { + 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 { + const body: any = { + locale: params.locale || 'en', + page: params.page || 1 + } + + // 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` + return searchJson(fetchFn, url, body) +} + +export function searchCharacters( + params: SearchParams, + init?: RequestInit, + fetchFn: FetchLike = fetch +): Promise { + const body: any = { + locale: params.locale || 'en', + page: params.page || 1 + } + + // 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 { + const body: any = { + locale: params.locale || 'en', + page: params.page || 1 + } + + // 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) +} \ No newline at end of file diff --git a/src/lib/api/schemas/party.ts b/src/lib/api/schemas/party.ts index fdf117ef..b4467e54 100644 --- a/src/lib/api/schemas/party.ts +++ b/src/lib/api/schemas/party.ts @@ -14,37 +14,8 @@ const MinimalCamelPartySchema = z }) .passthrough() -// Minimal TS types for UI (grid view) -export type LocalizedName = string | { en?: string | null; ja?: string | null } -export interface NamedObject { name?: LocalizedName | null; [k: string]: unknown } -export interface GridWeaponItemView { position: number; mainhand?: boolean | null; object?: NamedObject | null; [k: string]: unknown } -export interface GridSummonItemView { position: number; main?: boolean | null; friend?: boolean | null; quickSummon?: boolean | null; object?: NamedObject | null; [k: string]: unknown } -export interface GridCharacterItemView { position: number; perpetuity?: boolean | null; transcendenceStep?: number | null; object?: NamedObject | null; [k: string]: unknown } - -export interface PartyView { - id: string - shortcode: string - name?: string | null - description?: string | null - user?: { id?: string | null } | null - localId?: string | null - favorited?: boolean - fullAuto?: boolean - autoGuard?: boolean - autoSummon?: boolean - chargeAttack?: boolean - clearTime?: number - buttonCount?: number - chainCount?: number - turnCount?: number - visibility?: number - raid?: { name?: LocalizedName | null; group?: { difficulty?: number | null; extra?: boolean | null; guidebooks?: boolean | null; [k: string]: unknown } | null; [k: string]: unknown } | null - job?: { name?: LocalizedName | null; [k: string]: unknown } | null - weapons: GridWeaponItemView[] - summons: GridSummonItemView[] - characters: GridCharacterItemView[] - [k: string]: unknown -} +// NOTE: These old types are deprecated - use types from $lib/types/api/party instead +// Keeping minimal exports for backward compatibility during migration // Helper for localized names const LocalizedNameSchema = z.union([ @@ -493,47 +464,13 @@ export type RaidGroup = CamelCasedKeysDeep> export type User = CamelCasedKeysDeep> export type Guidebook = CamelCasedKeysDeep> -// Helper: parse raw API party (snake_case) and convert to camelCase -export function parseParty(input: unknown): Party { - // Step 1: convert server snake_case to client camelCase - const camel = snakeToCamel(input) as Record - // Step 2: validate only minimal, core fields and pass through the rest - const parsed = MinimalCamelPartySchema.parse(camel) as any +// Import transformation from client +import { transformResponse } from '../client' +import type { Party as CleanParty } from '$lib/types/api/party' - // Step 3: minimally validate grids and coerce to arrays for UI safety - const grids = MinimalGridsSchema.safeParse(camel) - if (grids.success) { - parsed.weapons = grids.data.weapons ?? [] - parsed.summons = grids.data.summons ?? [] - parsed.characters = grids.data.characters ?? [] - } else { - parsed.weapons = Array.isArray(parsed.weapons) ? parsed.weapons : [] - parsed.summons = Array.isArray(parsed.summons) ? parsed.summons : [] - parsed.characters = Array.isArray(parsed.characters) ? parsed.characters : [] - } - - // Step 4: minimally validate header associations (raid/job) - const hdr = MinimalHeaderSchema.safeParse(camel) - if (hdr.success) { - if (hdr.data.raid !== undefined) parsed.raid = hdr.data.raid - if (hdr.data.job !== undefined) parsed.job = hdr.data.job - } - - // Step 5: core scalars with safe defaults for UI - const scal = MinimalScalarsSchema.safeParse(camel) - const pick = (v: T | null | undefined, d: T) => (v ?? d) - if (scal.success) { - parsed.favorited = pick(scal.data.favorited ?? (parsed as any).favorited, false) - parsed.fullAuto = pick(scal.data.fullAuto ?? (parsed as any).fullAuto, false) - parsed.autoGuard = pick(scal.data.autoGuard ?? (parsed as any).autoGuard, false) - parsed.autoSummon = pick(scal.data.autoSummon ?? (parsed as any).autoSummon, false) - parsed.chargeAttack = pick(scal.data.chargeAttack ?? (parsed as any).chargeAttack, true) - parsed.clearTime = pick(scal.data.clearTime ?? (parsed as any).clearTime, 0) - parsed.buttonCount = pick(scal.data.buttonCount ?? (parsed as any).buttonCount, 0) - parsed.chainCount = pick(scal.data.chainCount ?? (parsed as any).chainCount, 0) - parsed.turnCount = pick(scal.data.turnCount ?? (parsed as any).turnCount, 0) - parsed.visibility = pick(scal.data.visibility ?? (parsed as any).visibility, 1) - } - - return parsed as Party +// Helper: parse raw API party (snake_case) and convert to clean types +export function parseParty(input: unknown): CleanParty { + // Use the unified transformation from the API client + // This handles both snake_case → camelCase and object → entity name mapping + return transformResponse(input) } diff --git a/src/routes/api/parties/+server.ts b/src/routes/api/parties/+server.ts new file mode 100644 index 00000000..f64c85ec --- /dev/null +++ b/src/routes/api/parties/+server.ts @@ -0,0 +1,43 @@ +import { json, type RequestHandler } from '@sveltejs/kit' +import { buildUrl } from '$lib/api/core' +import { PUBLIC_SIERO_API_URL } from '$env/static/public' + +const API_BASE = new URL(PUBLIC_SIERO_API_URL || 'http://localhost:3000').href + +/** + * POST /api/parties - Create a new party + * Proxies to Rails API with proper authentication + */ +export const POST: RequestHandler = async ({ request, fetch }) => { + try { + const body = await request.json() + const editKey = request.headers.get('X-Edit-Key') + + // Forward to Rails API + // The server-side fetch will automatically add Bearer token if user is authenticated + const response = await fetch(buildUrl('/parties'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(editKey ? { 'X-Edit-Key': editKey } : {}) + }, + body: JSON.stringify(body) + }) + + const data = await response.json() + + // If creation was successful and returned an edit key, include it in response + if (response.ok) { + return json(data, { status: response.status }) + } + + // Forward error response + return json(data, { status: response.status }) + } catch (error) { + console.error('Error creating party:', error) + return json( + { error: 'Failed to create party' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/src/routes/api/parties/[id]/+server.ts b/src/routes/api/parties/[id]/+server.ts new file mode 100644 index 00000000..b1c8bc06 --- /dev/null +++ b/src/routes/api/parties/[id]/+server.ts @@ -0,0 +1,66 @@ +import { json, type RequestHandler } from '@sveltejs/kit' +import { buildUrl } from '$lib/api/core' + +/** + * PUT /api/parties/[id] - Update a party + * DELETE /api/parties/[id] - Delete a party + * Proxies to Rails API with proper authentication + */ + +export const PUT: RequestHandler = async ({ request, params, fetch }) => { + try { + const { id } = params + const body = await request.json() + const editKey = request.headers.get('X-Edit-Key') + + // Forward to Rails API + const response = await fetch(buildUrl(`/parties/${id}`), { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...(editKey ? { 'X-Edit-Key': editKey } : {}) + }, + body: JSON.stringify(body) + }) + + const data = await response.json() + return json(data, { status: response.status }) + } catch (error) { + console.error('Error updating party:', error) + return json( + { error: 'Failed to update party' }, + { status: 500 } + ) + } +} + +export const DELETE: RequestHandler = async ({ request, params, fetch }) => { + try { + const { id } = params + const editKey = request.headers.get('X-Edit-Key') + + // Forward to Rails API + const response = await fetch(buildUrl(`/parties/${id}`), { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + ...(editKey ? { 'X-Edit-Key': editKey } : {}) + } + }) + + if (response.ok) { + const data = await response.json() + return json(data, { status: response.status }) + } + + // Handle error responses + const errorData = await response.json().catch(() => ({})) + return json(errorData, { status: response.status }) + } catch (error) { + console.error('Error deleting party:', error) + return json( + { error: 'Failed to delete party' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/src/routes/api/parties/[id]/characters/+server.ts b/src/routes/api/parties/[id]/characters/+server.ts new file mode 100644 index 00000000..0a28b552 --- /dev/null +++ b/src/routes/api/parties/[id]/characters/+server.ts @@ -0,0 +1,79 @@ +import { json, type RequestHandler } from '@sveltejs/kit' +import { buildUrl } from '$lib/api/core' + +/** + * POST /api/parties/[id]/characters - Add character to party + * DELETE /api/parties/[id]/characters - Remove character from party + * Proxies to Rails API with proper authentication + */ + +export const POST: RequestHandler = async ({ request, params, fetch }) => { + try { + const body = await request.json() + const editKey = request.headers.get('X-Edit-Key') + + // Transform to Rails API format + const railsBody = { + character: { + party_id: params.id, + character_id: body.characterId, + position: body.position, + uncap_level: body.uncapLevel ?? 3, + transcendence_step: body.transcendenceStep ?? 0, + perpetuity: body.perpetuity ?? false + } + } + + // Forward to Rails API + const response = await fetch(buildUrl('/characters'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(editKey ? { 'X-Edit-Key': editKey } : {}) + }, + body: JSON.stringify(railsBody) + }) + + const data = await response.json() + return json(data, { status: response.status }) + } catch (error) { + console.error('Error adding character:', error) + return json( + { error: 'Failed to add character' }, + { status: 500 } + ) + } +} + +export const DELETE: RequestHandler = async ({ request, params, fetch }) => { + try { + const body = await request.json() + const editKey = request.headers.get('X-Edit-Key') + + // Forward to Rails API + const response = await fetch(buildUrl('/characters'), { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + ...(editKey ? { 'X-Edit-Key': editKey } : {}) + }, + body: JSON.stringify({ grid_character_id: body.gridCharacterId }) + }) + + if (response.ok) { + // DELETE might not return a body + const text = await response.text() + const data = text ? JSON.parse(text) : {} + return json(data, { status: response.status }) + } + + const errorData = await response.json().catch(() => ({})) + return json(errorData, { status: response.status }) + } catch (error) { + console.error('Error removing character:', error) + return json( + { error: 'Failed to remove character' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/src/routes/api/parties/[id]/summons/+server.ts b/src/routes/api/parties/[id]/summons/+server.ts new file mode 100644 index 00000000..527af39a --- /dev/null +++ b/src/routes/api/parties/[id]/summons/+server.ts @@ -0,0 +1,81 @@ +import { json, type RequestHandler } from '@sveltejs/kit' +import { buildUrl } from '$lib/api/core' + +/** + * POST /api/parties/[id]/summons - Add summon to party + * DELETE /api/parties/[id]/summons - Remove summon from party + * Proxies to Rails API with proper authentication + */ + +export const POST: RequestHandler = async ({ request, params, fetch }) => { + try { + const body = await request.json() + const editKey = request.headers.get('X-Edit-Key') + + // Transform to Rails API format + const railsBody = { + summon: { + party_id: params.id, + summon_id: body.summonId, + position: body.position, + main: body.position === -1 || body.main, + friend: body.position === 6 || body.friend, + quick_summon: body.quickSummon ?? false, + uncap_level: body.uncapLevel ?? 3, + transcendence_step: body.transcendenceStep ?? 0 + } + } + + // Forward to Rails API + const response = await fetch(buildUrl('/summons'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(editKey ? { 'X-Edit-Key': editKey } : {}) + }, + body: JSON.stringify(railsBody) + }) + + const data = await response.json() + return json(data, { status: response.status }) + } catch (error) { + console.error('Error adding summon:', error) + return json( + { error: 'Failed to add summon' }, + { status: 500 } + ) + } +} + +export const DELETE: RequestHandler = async ({ request, params, fetch }) => { + try { + const body = await request.json() + const editKey = request.headers.get('X-Edit-Key') + + // Forward to Rails API + const response = await fetch(buildUrl('/summons'), { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + ...(editKey ? { 'X-Edit-Key': editKey } : {}) + }, + body: JSON.stringify({ grid_summon_id: body.gridSummonId }) + }) + + if (response.ok) { + // DELETE might not return a body + const text = await response.text() + const data = text ? JSON.parse(text) : {} + return json(data, { status: response.status }) + } + + const errorData = await response.json().catch(() => ({})) + return json(errorData, { status: response.status }) + } catch (error) { + console.error('Error removing summon:', error) + return json( + { error: 'Failed to remove summon' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/src/routes/api/parties/[id]/weapons/+server.ts b/src/routes/api/parties/[id]/weapons/+server.ts new file mode 100644 index 00000000..80386835 --- /dev/null +++ b/src/routes/api/parties/[id]/weapons/+server.ts @@ -0,0 +1,80 @@ +import { json, type RequestHandler } from '@sveltejs/kit' +import { buildUrl } from '$lib/api/core' + +/** + * POST /api/parties/[id]/weapons - Add weapon to party + * DELETE /api/parties/[id]/weapons - Remove weapon from party + * Proxies to Rails API with proper authentication + */ + +export const POST: RequestHandler = async ({ request, params, fetch }) => { + try { + const body = await request.json() + const editKey = request.headers.get('X-Edit-Key') + + // Transform to Rails API format + const railsBody = { + weapon: { + party_id: params.id, + weapon_id: body.weaponId, + position: body.position, + mainhand: body.position === -1 || body.mainhand, + uncap_level: body.uncapLevel ?? 3, + transcendence_step: body.transcendenceStep ?? 0, + element: body.element + } + } + + // Forward to Rails API + const response = await fetch(buildUrl('/weapons'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(editKey ? { 'X-Edit-Key': editKey } : {}) + }, + body: JSON.stringify(railsBody) + }) + + const data = await response.json() + return json(data, { status: response.status }) + } catch (error) { + console.error('Error adding weapon:', error) + return json( + { error: 'Failed to add weapon' }, + { status: 500 } + ) + } +} + +export const DELETE: RequestHandler = async ({ request, params, fetch }) => { + try { + const body = await request.json() + const editKey = request.headers.get('X-Edit-Key') + + // Forward to Rails API + const response = await fetch(buildUrl('/weapons'), { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + ...(editKey ? { 'X-Edit-Key': editKey } : {}) + }, + body: JSON.stringify({ grid_weapon_id: body.gridWeaponId }) + }) + + if (response.ok) { + // DELETE might not return a body + const text = await response.text() + const data = text ? JSON.parse(text) : {} + return json(data, { status: response.status }) + } + + const errorData = await response.json().catch(() => ({})) + return json(errorData, { status: response.status }) + } catch (error) { + console.error('Error removing weapon:', error) + return json( + { error: 'Failed to remove weapon' }, + { status: 500 } + ) + } +} \ No newline at end of file