From b4608846f5ae50c400b9678cc66a06b61d224fd8 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Wed, 7 Jan 2026 23:58:59 -0800 Subject: [PATCH] Add game_id to raid pages (#457) Adds game_id field to raid database pages. Changes: - Updated TypeScript types for Raid - Added game_id display on raid detail page - Added game_id input on raid edit page - Added game_id input on raid create page --- src/lib/api/adapters/raid.adapter.ts | 86 ++++++ .../database/cells/RaidImageCell.svelte | 51 ++++ .../party/edit/YouTubeUrlInput.svelte | 140 ++-------- .../party/info/DescriptionTile.svelte | 18 +- src/lib/components/reps/SummonRep.svelte | 35 ++- .../sidebar/PartyEditSidebar.svelte | 126 ++++++--- src/lib/components/ui/DetailsHeader.svelte | 5 +- .../database/detail/DetailScaffold.svelte | 2 +- src/lib/stores/sidebar.svelte.ts | 24 +- src/lib/types/api/entities.ts | 3 + src/lib/types/api/raid.ts | 9 + src/routes/(app)/database/raids/+page.svelte | 12 +- .../(app)/database/raids/[slug]/+page.svelte | 262 ++++++++++++++---- .../database/raids/[slug]/edit/+page.svelte | 33 ++- .../(app)/database/raids/new/+page.svelte | 28 +- src/routes/(app)/teams/new/+page.svelte | 18 +- 16 files changed, 596 insertions(+), 256 deletions(-) create mode 100644 src/lib/components/database/cells/RaidImageCell.svelte diff --git a/src/lib/api/adapters/raid.adapter.ts b/src/lib/api/adapters/raid.adapter.ts index 1cbed49f..9e3d581f 100644 --- a/src/lib/api/adapters/raid.adapter.ts +++ b/src/lib/api/adapters/raid.adapter.ts @@ -13,6 +13,21 @@ import type { } from '$lib/types/api/raid' import type { Raid, RaidGroup } from '$lib/types/api/entities' +/** + * Response from raid image download status + */ +export interface RaidDownloadStatus { + status: 'queued' | 'processing' | 'completed' | 'failed' | 'not_found' + progress?: number + imagesDownloaded?: number + imagesTotal?: number + error?: string + raidId?: string + slug?: string + images?: Record + updatedAt?: string +} + /** * Adapter for Raid and RaidGroup API operations */ @@ -88,6 +103,77 @@ export class RaidAdapter extends BaseAdapter { this.clearCache(`/raids/${slug}`) } + // ==================== Image Download Operations ==================== + + /** + * Downloads a single image for a raid (synchronous) + * Requires editor role (>= 7) + * @param slug - Raid slug + * @param size - Image size variant ('icon', 'thumbnail', 'lobby', or 'background') + * @param force - Force re-download even if image exists + */ + async downloadRaidImage( + slug: string, + size: 'icon' | 'thumbnail' | 'lobby' | 'background', + force?: boolean, + options?: RequestOptions + ): Promise<{ success: boolean; error?: string }> { + return this.request(`/raids/${slug}/download_image`, { + ...options, + method: 'POST', + body: JSON.stringify({ size, force }) + }) + } + + /** + * Triggers async image download for a raid + * Requires editor role (>= 7) + */ + async downloadRaidImages( + slug: string, + downloadOptions?: { force?: boolean; size?: 'all' | 'icon' | 'thumbnail' | 'lobby' | 'background' }, + requestOptions?: RequestOptions + ): Promise<{ status: string; raidId: string; message: string }> { + return this.request(`/raids/${slug}/download_images`, { + ...requestOptions, + method: 'POST', + body: JSON.stringify({ options: downloadOptions }) + }) + } + + /** + * Gets the status of an ongoing raid image download + * Requires editor role (>= 7) + */ + async getRaidDownloadStatus(slug: string, options?: RequestOptions): Promise { + const response = await this.request<{ + status: string + progress?: number + images_downloaded?: number + images_total?: number + error?: string + raid_id?: string + slug?: string + images?: Record + updated_at?: string + }>(`/raids/${slug}/download_status`, { + ...options, + method: 'GET' + }) + + return { + status: response.status as RaidDownloadStatus['status'], + progress: response.progress, + imagesDownloaded: response.images_downloaded, + imagesTotal: response.images_total, + error: response.error, + raidId: response.raid_id, + slug: response.slug, + images: response.images, + updatedAt: response.updated_at + } + } + // ==================== RaidGroup Operations ==================== /** diff --git a/src/lib/components/database/cells/RaidImageCell.svelte b/src/lib/components/database/cells/RaidImageCell.svelte new file mode 100644 index 00000000..ff5d84da --- /dev/null +++ b/src/lib/components/database/cells/RaidImageCell.svelte @@ -0,0 +1,51 @@ + + + + +
+ {#if raid.enemy_id} + + {:else} +
+ {/if} +
+ + diff --git a/src/lib/components/party/edit/YouTubeUrlInput.svelte b/src/lib/components/party/edit/YouTubeUrlInput.svelte index 7301d3bc..aeff77fc 100644 --- a/src/lib/components/party/edit/YouTubeUrlInput.svelte +++ b/src/lib/components/party/edit/YouTubeUrlInput.svelte @@ -5,6 +5,7 @@ * Validates YouTube URLs and shows a thumbnail preview when valid. */ import { untrack } from 'svelte' + import Input from '$lib/components/ui/Input.svelte' interface Props { /** YouTube URL value */ @@ -96,9 +97,8 @@ }) }) - function handleInput(e: Event) { - const target = e.target as HTMLInputElement - inputValue = target.value + function handleInput() { + // inputValue is already updated via bind:value // Update bound value immediately if valid (so Save captures it) if (isValidYouTubeUrl(inputValue)) { const newValue = inputValue.trim() || null @@ -132,33 +132,20 @@
- {#if label} - - {/if} -
- - {#if inputValue && !disabled} - - {/if} -
- - {#if showError} -

Please enter a valid YouTube URL

- {/if} + {#if showPreview && thumbnailUrl}
@@ -194,99 +181,6 @@ gap: $unit; } - .input-label { - font-size: $font-small; - font-weight: $medium; - color: var(--text-secondary); - } - - .input-container { - display: flex; - align-items: center; - background-color: var(--input-bg); - border-radius: $input-corner; - padding: 0; - @include smooth-transition($duration-quick, background-color, outline); - - &:hover:not(.disabled) { - background-color: var(--input-bg-hover); - } - - &:focus-within:not(.disabled) { - outline: 2px solid $water-text-20; - outline-offset: -2px; - } - - &.error { - outline: 2px solid $fire-text-20; - outline-offset: -2px; - } - - &.disabled { - opacity: 0.5; - cursor: not-allowed; - } - - &.contained { - background-color: var(--input-bound-bg); - - &:hover:not(.disabled) { - background-color: var(--input-bound-bg-hover); - } - } - } - - .url-input { - flex: 1; - background: transparent; - border: none; - color: var(--text-primary); - font-size: $font-regular; - font-family: inherit; - padding: calc($unit * 1.75) $unit-2x; - outline: none; - - &::placeholder { - color: var(--text-tertiary); - } - - &:disabled { - cursor: not-allowed; - } - } - - .clear-button { - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - margin-right: $unit; - padding: 0; - border: none; - background: transparent; - color: var(--text-secondary); - cursor: pointer; - border-radius: $unit-half; - @include smooth-transition($duration-quick, background-color, color); - - &:hover { - background-color: $grey-80; - color: var(--text-primary); - } - - svg { - width: 14px; - height: 14px; - } - } - - .error-message { - margin: 0; - font-size: $font-small; - color: $fire-text-20; - } - .preview-card { background-color: var(--input-bound-bg); border-radius: $page-corner; diff --git a/src/lib/components/party/info/DescriptionTile.svelte b/src/lib/components/party/info/DescriptionTile.svelte index a793c2ae..43ed775b 100644 --- a/src/lib/components/party/info/DescriptionTile.svelte +++ b/src/lib/components/party/info/DescriptionTile.svelte @@ -34,6 +34,18 @@ const avatarSrc = $derived(getAvatarSrc(user?.avatar?.picture)) const avatarSrcSet = $derived(getAvatarSrcSet(user?.avatar?.picture)) + // Measure content height to determine if fade gradient is needed + let contentEl = $state(undefined) + let needsFade = $state(false) + + $effect(() => { + if (contentEl) { + // Show fade if content is taller than ~2 lines + // Based on line-height 1.5 × font-size ~16px × 2 lines ≈ 48px + needsFade = contentEl.scrollHeight > 48 + } + }) + /** Extract plain text from first two non-empty paragraphs of TipTap JSON content */ function getPreviewParagraphs(content?: string): string[] { if (!content) return [] @@ -78,7 +90,7 @@ const previewParagraphs = $derived(getPreviewParagraphs(description)) -
+
@@ -118,7 +130,7 @@ +
+
+
+ + + +
+ {#snippet children()} @@ -314,36 +348,7 @@ {/if} - - - - - {#snippet children()} - - {/snippet} - - +
+
+ {#snippet children()} @@ -392,11 +399,50 @@ padding-bottom: $unit-2x; } + .top-section { + display: flex; + flex-direction: column; + gap: $unit-2x; + padding: 0 $unit; + + h3 { + margin: 0; + font-size: $font-name; + font-weight: $medium; + color: var(--text-primary); + padding: 0 $unit; + } + } + .top-fields { display: flex; flex-direction: column; gap: $unit-2x; - padding: 0 $unit-2x; + padding: 0 $unit; + + // Override Input label styling to match DetailRow + :global(.label) { + color: var(--text-secondary) !important; + font-size: $font-regular !important; + font-weight: normal !important; + } + } + + .divider { + border: none; + border-top: 1px solid var(--border-color, rgba(255, 255, 255, 0.04)); + margin: 0; + } + + .raid-field { + display: flex; + flex-direction: column; + gap: $unit-half; + } + + .raid-label { + font-size: $font-regular; + color: var(--text-secondary); } .raid-select-button { diff --git a/src/lib/components/ui/DetailsHeader.svelte b/src/lib/components/ui/DetailsHeader.svelte index 0c30aea1..2d144e5a 100644 --- a/src/lib/components/ui/DetailsHeader.svelte +++ b/src/lib/components/ui/DetailsHeader.svelte @@ -9,7 +9,7 @@ // Props interface Props { - type: 'character' | 'summon' | 'weapon' | 'job' + type: 'character' | 'summon' | 'weapon' | 'job' | 'raid' item: any // The character/summon/weapon/job object image: string editUrl?: string // URL to navigate to for editing (view mode) @@ -74,7 +74,8 @@ character: '/images/placeholders/placeholder-character-main.png', summon: '/images/placeholders/placeholder-summon-main.png', weapon: '/images/placeholders/placeholder-weapon-main.png', - job: '/images/placeholders/placeholder-job.png' + job: '/images/placeholders/placeholder-job.png', + raid: '/images/placeholders/placeholder-summon-main.png' // Fallback to summon placeholder } as const ;(e.currentTarget as HTMLImageElement).src = placeholders[type] }} diff --git a/src/lib/features/database/detail/DetailScaffold.svelte b/src/lib/features/database/detail/DetailScaffold.svelte index f44b554f..fadffd95 100644 --- a/src/lib/features/database/detail/DetailScaffold.svelte +++ b/src/lib/features/database/detail/DetailScaffold.svelte @@ -11,7 +11,7 @@ export type DetailTab = 'info' | 'images' | 'raw' interface Props { - type: 'character' | 'summon' | 'weapon' | 'job' + type: 'character' | 'summon' | 'weapon' | 'job' | 'raid' item: any image: string showEdit?: boolean diff --git a/src/lib/stores/sidebar.svelte.ts b/src/lib/stores/sidebar.svelte.ts index 842d599a..8de75579 100644 --- a/src/lib/stores/sidebar.svelte.ts +++ b/src/lib/stores/sidebar.svelte.ts @@ -39,10 +39,16 @@ class SidebarStore { /** The pane stack for sidebar navigation */ paneStack = new PaneStackStore() + /** Timeout ID for delayed pane stack clear after close animation */ + private clearTimeoutId: ReturnType | null = null + /** * Open the sidebar with a snippet content (legacy API) */ open(title?: string, content?: Snippet, scrollable = true) { + // Cancel any pending clear from a previous close() + this.cancelPendingClear() + // For snippet content, we don't use the pane stack // This is for backwards compatibility this.state.open = true @@ -59,6 +65,9 @@ class SidebarStore { props?: Record, options?: OpenWithComponentOptions | boolean ) { + // Cancel any pending clear from a previous close() + this.cancelPendingClear() + // Handle backward compatibility where 4th param was scrollable boolean const opts: OpenWithComponentOptions = typeof options === 'boolean' ? { scrollable: options } : options ?? {} @@ -112,12 +121,23 @@ class SidebarStore { close() { this.state.open = false this.state.activeItemId = undefined - // Clear pane stack after animation - setTimeout(() => { + // Clear pane stack after animation completes + this.clearTimeoutId = setTimeout(() => { this.paneStack.clear() + this.clearTimeoutId = null }, 300) } + /** + * Cancel any pending pane stack clear from a previous close() + */ + private cancelPendingClear() { + if (this.clearTimeoutId) { + clearTimeout(this.clearTimeoutId) + this.clearTimeoutId = null + } + } + /** * Toggle the sidebar open/close state */ diff --git a/src/lib/types/api/entities.ts b/src/lib/types/api/entities.ts index c3e5827f..a4f87b01 100644 --- a/src/lib/types/api/entities.ts +++ b/src/lib/types/api/entities.ts @@ -220,6 +220,9 @@ export interface Raid { name: LocalizedName level: number element: number + enemy_id?: number + summon_id?: number + quest_id?: number group?: RaidGroup } diff --git a/src/lib/types/api/raid.ts b/src/lib/types/api/raid.ts index 25f63614..a0208e17 100644 --- a/src/lib/types/api/raid.ts +++ b/src/lib/types/api/raid.ts @@ -16,6 +16,9 @@ export interface RaidFull { name: LocalizedName level: number element: number + enemy_id?: number + summon_id?: number + quest_id?: number group?: RaidGroupFlat } @@ -45,6 +48,9 @@ export interface CreateRaidInput { level: number element: number group_id: string + enemy_id?: number + summon_id?: number + quest_id?: number } export interface UpdateRaidInput { @@ -54,6 +60,9 @@ export interface UpdateRaidInput { level?: number element?: number group_id?: string + enemy_id?: number + summon_id?: number + quest_id?: number } // Input types for creating/updating raid groups diff --git a/src/routes/(app)/database/raids/+page.svelte b/src/routes/(app)/database/raids/+page.svelte index d346e823..7229a53d 100644 --- a/src/routes/(app)/database/raids/+page.svelte +++ b/src/routes/(app)/database/raids/+page.svelte @@ -17,6 +17,7 @@ import Segment from '$lib/components/ui/segmented-control/Segment.svelte' import RaidGroupNameCell from '$lib/components/database/cells/RaidGroupNameCell.svelte' import RaidGroupFlagsCell from '$lib/components/database/cells/RaidGroupFlagsCell.svelte' + import RaidImageCell from '$lib/components/database/cells/RaidImageCell.svelte' import type { Raid, RaidGroup } from '$lib/types/api/entities' import type { RaidGroupFull } from '$lib/types/api/raid' import { getRaidSectionLabel } from '$lib/utils/raidSection' @@ -364,6 +365,7 @@ + @@ -374,7 +376,7 @@ {#if filteredRaids.length === 0 && !raidsQuery.isLoading} - handleRaidClick(raid)} class="clickable"> + @@ -574,6 +579,11 @@ } } + .col-image { + width: 64px; + padding: 4px !important; + } + .col-name { min-width: 200px; } diff --git a/src/routes/(app)/database/raids/[slug]/+page.svelte b/src/routes/(app)/database/raids/[slug]/+page.svelte index 7a0ad981..17080455 100644 --- a/src/routes/(app)/database/raids/[slug]/+page.svelte +++ b/src/routes/(app)/database/raids/[slug]/+page.svelte @@ -8,9 +8,18 @@ import Button from '$lib/components/ui/Button.svelte' import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte' import DetailItem from '$lib/components/ui/DetailItem.svelte' - import SidebarHeader from '$lib/components/ui/SidebarHeader.svelte' import ElementBadge from '$lib/components/ui/ElementBadge.svelte' + import DetailScaffold, { type DetailTab } from '$lib/features/database/detail/DetailScaffold.svelte' + import EntityImagesTab from '$lib/features/database/detail/tabs/EntityImagesTab.svelte' + import DatabasePageHeader from '$lib/components/database/DatabasePageHeader.svelte' import type { PageData } from './$types' + import type { ImageItem } from '$lib/features/database/detail/tabs/EntityImagesTab.svelte' + + // CDN base URLs for raid images + const ICON_BASE_URL = 'https://prd-game-a-granbluefantasy.akamaized.net/assets_en/img/sp/assets/enemy/m' + const THUMBNAIL_BASE_URL = 'https://prd-game-a1-granbluefantasy.akamaized.net/assets_en/img/sp/assets/summon/qm' + const LOBBY_BASE_URL = 'https://prd-game-a1-granbluefantasy.akamaized.net/assets_en/img/sp/quest/assets/lobby' + const BACKGROUND_BASE_URL = 'https://prd-game-a-granbluefantasy.akamaized.net/assets_en/img/sp/quest/assets/treasureraid' function displayName(input: any): string { if (!input) return '—' @@ -26,6 +35,19 @@ let { data }: Props = $props() + // Tab state from URL + const currentTab = $derived(($page.url.searchParams.get('tab') as DetailTab) || 'info') + + function handleTabChange(tab: DetailTab) { + const url = new URL($page.url) + if (tab === 'info') { + url.searchParams.delete('tab') + } else { + url.searchParams.set('tab', tab) + } + goto(url.toString(), { replaceState: true }) + } + // Get raid slug from URL const raidSlug = $derived($page.params.slug) @@ -40,10 +62,8 @@ const userRole = $derived(data.role || 0) const canEdit = $derived(userRole >= 7) - // Navigate to edit - function handleEdit() { - goto(`/database/raids/${raidSlug}/edit`) - } + // Edit URL for navigation + const editUrl = $derived(raidSlug ? `/database/raids/${raidSlug}/edit` : undefined) // Navigate back function handleBack() { @@ -56,9 +76,119 @@ goto(`/database/raid-groups/${raid.group.id}`) } } + + // Get icon image URL + function getIconUrl(enemyId: number): string { + return `${ICON_BASE_URL}/${enemyId}.png` + } + + // Get thumbnail image URL + function getThumbnailUrl(summonId: number): string { + return `${THUMBNAIL_BASE_URL}/${summonId}_high.png` + } + + // Get lobby image URL (quest_id with "1" appended) + function getLobbyUrl(questId: number): string { + return `${LOBBY_BASE_URL}/${questId}1.png` + } + + // Get background image URL + function getBackgroundUrl(questId: number): string { + return `${BACKGROUND_BASE_URL}/${questId}/raid_image_new.png` + } + + // Get header image - prefer thumbnail, fallback to icon + const headerImage = $derived.by(() => { + if (raid?.summon_id) return getThumbnailUrl(raid.summon_id) + if (raid?.enemy_id) return getIconUrl(raid.enemy_id) + return '' + }) + + // Available image sizes for raids + const raidSizes = $derived.by(() => { + const sizes: string[] = [] + if (raid?.enemy_id) sizes.push('icon') + if (raid?.summon_id) sizes.push('thumbnail') + if (raid?.quest_id) { + sizes.push('lobby') + sizes.push('background') + } + return sizes + }) + + // Generate image items for raid + const raidImages = $derived.by((): ImageItem[] => { + if (!raid) return [] + + const images: ImageItem[] = [] + + // Icon image from enemy + if (raid.enemy_id) { + images.push({ + url: getIconUrl(raid.enemy_id), + label: 'Icon', + variant: 'icon' + }) + } + + // Thumbnail image from summon + if (raid.summon_id) { + images.push({ + url: getThumbnailUrl(raid.summon_id), + label: 'Thumbnail', + variant: 'thumbnail' + }) + } + + // Lobby and background images from quest + if (raid.quest_id) { + images.push({ + url: getLobbyUrl(raid.quest_id), + label: 'Lobby', + variant: 'lobby' + }) + images.push({ + url: getBackgroundUrl(raid.quest_id), + label: 'Background', + variant: 'background' + }) + } + + return images + }) + + // Image download handlers + type RaidImageSize = 'icon' | 'thumbnail' | 'lobby' | 'background' + + async function handleDownloadImage( + size: string, + _transformation: string | undefined, + force: boolean + ) { + if (!raidSlug) return + await raidAdapter.downloadRaidImage(raidSlug, size as RaidImageSize, force) + } + + async function handleDownloadAllImages(force: boolean) { + if (!raidSlug) return + await raidAdapter.downloadRaidImages(raidSlug, { force }) + } + + async function handleDownloadSize(size: string) { + if (!raidSlug) return + await raidAdapter.downloadRaidImage(raidSlug, size as RaidImageSize, false) + }
+ + {#snippet rightAction()} + {#if canEdit && editUrl} + + {/if} + {/snippet} + + {#if raidQuery.isLoading}

Loading raid...

@@ -69,57 +199,73 @@
{:else if raid} - - {#snippet leftAccessory()} - - {/snippet} - {#snippet rightAccessory()} - {#if canEdit} - - {/if} - {/snippet} - + + {#if currentTab === 'info'} +
+ + + + + + + + + + {#if raid.element !== undefined && raid.element !== null} + + {:else} + - + {/if} + + -
- - - - - - - {#if raid.element !== undefined && raid.element !== null} - - {:else} - - - {/if} - - - - - - {#if raid.group} - - {:else} - - - {/if} - - {#if raid.group} - - - {raid.group.hl ? 'Yes' : 'No'} - - - {raid.group.extra ? 'Yes' : 'No'} - - - {raid.group.guidebooks ? 'Yes' : 'No'} - - {/if} - - -
+ + + {#if raid.group} + + {:else} + - + {/if} + + {#if raid.group} + + + {raid.group.hl ? 'Yes' : 'No'} + + + {raid.group.extra ? 'Yes' : 'No'} + + + {raid.group.guidebooks ? 'Yes' : 'No'} + + {/if} + +
+ {:else if currentTab === 'images'} + + {:else if currentTab === 'raw'} +
+

Raw data not available for raids.

+
+ {/if} +
{:else}

Raid Not Found

@@ -190,4 +336,10 @@ color: white; } } + + .raw-placeholder { + padding: spacing.$unit-4x; + text-align: center; + color: var(--text-secondary); + } diff --git a/src/routes/(app)/database/raids/[slug]/edit/+page.svelte b/src/routes/(app)/database/raids/[slug]/edit/+page.svelte index c5e922c9..30ab6798 100644 --- a/src/routes/(app)/database/raids/[slug]/edit/+page.svelte +++ b/src/routes/(app)/database/raids/[slug]/edit/+page.svelte @@ -57,7 +57,10 @@ slug: '', level: 0, element: 0, - group_id: '' + group_id: '', + enemy_id: undefined as number | undefined, + summon_id: undefined as number | undefined, + quest_id: undefined as number | undefined }) // Sync edit data when raid changes @@ -69,7 +72,10 @@ slug: raid.slug || '', level: raid.level ?? 0, element: raid.element ?? 0, - group_id: raid.group?.id || '' + group_id: raid.group?.id || '', + enemy_id: raid.enemy_id, + summon_id: raid.summon_id, + quest_id: raid.quest_id } } }) @@ -112,7 +118,10 @@ slug: editData.slug, level: editData.level, element: editData.element, - group_id: editData.group_id + group_id: editData.group_id, + enemy_id: editData.enemy_id, + summon_id: editData.summon_id, + quest_id: editData.quest_id }) // Invalidate queries @@ -183,6 +192,24 @@ editable={true} type="number" /> + + + + + +
-
+
-
Name Level Element
+ {searchTerm || hasActiveFilters ? 'No raids match your filters' : 'No raids yet'} @@ -383,6 +385,9 @@ {:else} {#each filteredRaids as raid}
+ + {displayName(raid)}