add images tab to raid detail page
- add download methods to RaidAdapter - extend DetailScaffold and DetailsHeader to support raid type - add tabs (Info, Images, Raw Data) to raid detail page - show icon and thumbnail images with right-click download menu
This commit is contained in:
parent
ba6df44df1
commit
6f4e305cdf
4 changed files with 264 additions and 60 deletions
|
|
@ -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<string, string[]>
|
||||
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' or 'thumbnail')
|
||||
* @param force - Force re-download even if image exists
|
||||
*/
|
||||
async downloadRaidImage(
|
||||
slug: string,
|
||||
size: 'icon' | 'thumbnail',
|
||||
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' },
|
||||
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<RaidDownloadStatus> {
|
||||
const response = await this.request<{
|
||||
status: string
|
||||
progress?: number
|
||||
images_downloaded?: number
|
||||
images_total?: number
|
||||
error?: string
|
||||
raid_id?: string
|
||||
slug?: string
|
||||
images?: Record<string, string[]>
|
||||
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 ====================
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -8,9 +8,16 @@
|
|||
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'
|
||||
|
||||
function displayName(input: any): string {
|
||||
if (!input) return '—'
|
||||
|
|
@ -26,6 +33,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 +60,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 +74,89 @@
|
|||
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 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')
|
||||
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'
|
||||
})
|
||||
}
|
||||
|
||||
return images
|
||||
})
|
||||
|
||||
// Image download handlers
|
||||
async function handleDownloadImage(
|
||||
size: string,
|
||||
_transformation: string | undefined,
|
||||
force: boolean
|
||||
) {
|
||||
if (!raidSlug) return
|
||||
await raidAdapter.downloadRaidImage(raidSlug, size as 'icon' | 'thumbnail', 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 'icon' | 'thumbnail', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<DatabasePageHeader title="Raid" backHref="/database/raids">
|
||||
{#snippet rightAction()}
|
||||
{#if canEdit && editUrl}
|
||||
<Button variant="secondary" size="small" href={editUrl}>Edit</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DatabasePageHeader>
|
||||
|
||||
{#if raidQuery.isLoading}
|
||||
<div class="loading-state">
|
||||
<p>Loading raid...</p>
|
||||
|
|
@ -69,59 +167,72 @@
|
|||
<Button variant="secondary" onclick={handleBack}>Back to Raids</Button>
|
||||
</div>
|
||||
{:else if raid}
|
||||
<SidebarHeader title={displayName(raid)}>
|
||||
{#snippet leftAccessory()}
|
||||
<Button variant="secondary" size="small" onclick={handleBack}>Back</Button>
|
||||
{/snippet}
|
||||
{#snippet rightAccessory()}
|
||||
{#if canEdit}
|
||||
<Button variant="primary" size="small" onclick={handleEdit}>Edit</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SidebarHeader>
|
||||
<DetailScaffold
|
||||
type="raid"
|
||||
item={raid}
|
||||
image={headerImage}
|
||||
showEdit={canEdit}
|
||||
{editUrl}
|
||||
{currentTab}
|
||||
onTabChange={handleTabChange}
|
||||
onDownloadAllImages={canEdit ? handleDownloadAllImages : undefined}
|
||||
onDownloadSize={canEdit ? handleDownloadSize : undefined}
|
||||
availableSizes={raidSizes}
|
||||
>
|
||||
{#if currentTab === 'info'}
|
||||
<section class="details">
|
||||
<DetailsContainer title="Raid Details">
|
||||
<DetailItem label="Name (EN)" value={raid.name.en || '-'} />
|
||||
<DetailItem label="Name (JA)" value={raid.name.ja || '-'} />
|
||||
<DetailItem label="Slug" value={raid.slug || '-'} />
|
||||
<DetailItem label="Level" value={raid.level?.toString() ?? '-'} />
|
||||
<DetailItem label="Enemy ID" value={raid.enemy_id?.toString() ?? '-'} />
|
||||
<DetailItem label="Summon ID" value={raid.summon_id?.toString() ?? '-'} />
|
||||
<DetailItem label="Element">
|
||||
{#if raid.element !== undefined && raid.element !== null}
|
||||
<ElementBadge element={raid.element} />
|
||||
{:else}
|
||||
<span class="no-value">-</span>
|
||||
{/if}
|
||||
</DetailItem>
|
||||
</DetailsContainer>
|
||||
|
||||
<section class="details">
|
||||
<DetailsContainer title="Raid Details">
|
||||
<DetailItem label="Name (EN)" value={raid.name.en || '-'} />
|
||||
<DetailItem label="Name (JA)" value={raid.name.ja || '-'} />
|
||||
<DetailItem label="Slug" value={raid.slug || '-'} />
|
||||
<DetailItem label="Level" value={raid.level?.toString() ?? '-'} />
|
||||
<DetailItem label="Enemy ID" value={raid.enemy_id?.toString() ?? '-'} />
|
||||
<DetailItem label="Summon ID" value={raid.summon_id?.toString() ?? '-'} />
|
||||
<DetailItem label="Element">
|
||||
{#if raid.element !== undefined && raid.element !== null}
|
||||
<ElementBadge element={raid.element} />
|
||||
{:else}
|
||||
<span class="no-value">-</span>
|
||||
{/if}
|
||||
</DetailItem>
|
||||
</DetailsContainer>
|
||||
|
||||
<DetailsContainer title="Classification">
|
||||
<DetailItem label="Group">
|
||||
{#if raid.group}
|
||||
<Button variant="ghost" size="small" rightIcon="chevron-right-small" onclick={handleGroupClick}>
|
||||
{displayName(raid.group)}
|
||||
</Button>
|
||||
{:else}
|
||||
<span class="no-value">-</span>
|
||||
{/if}
|
||||
</DetailItem>
|
||||
{#if raid.group}
|
||||
<DetailItem label="Difficulty" value={raid.group.difficulty?.toString() ?? '-'} />
|
||||
<DetailItem label="HL">
|
||||
<span class="badge" class:active={raid.group.hl}>{raid.group.hl ? 'Yes' : 'No'}</span>
|
||||
</DetailItem>
|
||||
<DetailItem label="Extra">
|
||||
<span class="badge" class:active={raid.group.extra}>{raid.group.extra ? 'Yes' : 'No'}</span>
|
||||
</DetailItem>
|
||||
<DetailItem label="Guidebooks">
|
||||
<span class="badge" class:active={raid.group.guidebooks}>{raid.group.guidebooks ? 'Yes' : 'No'}</span>
|
||||
</DetailItem>
|
||||
{/if}
|
||||
</DetailsContainer>
|
||||
|
||||
</section>
|
||||
<DetailsContainer title="Classification">
|
||||
<DetailItem label="Group">
|
||||
{#if raid.group}
|
||||
<Button variant="ghost" size="small" rightIcon="chevron-right-small" onclick={handleGroupClick}>
|
||||
{displayName(raid.group)}
|
||||
</Button>
|
||||
{:else}
|
||||
<span class="no-value">-</span>
|
||||
{/if}
|
||||
</DetailItem>
|
||||
{#if raid.group}
|
||||
<DetailItem label="Difficulty" value={raid.group.difficulty?.toString() ?? '-'} />
|
||||
<DetailItem label="HL">
|
||||
<span class="badge" class:active={raid.group.hl}>{raid.group.hl ? 'Yes' : 'No'}</span>
|
||||
</DetailItem>
|
||||
<DetailItem label="Extra">
|
||||
<span class="badge" class:active={raid.group.extra}>{raid.group.extra ? 'Yes' : 'No'}</span>
|
||||
</DetailItem>
|
||||
<DetailItem label="Guidebooks">
|
||||
<span class="badge" class:active={raid.group.guidebooks}>{raid.group.guidebooks ? 'Yes' : 'No'}</span>
|
||||
</DetailItem>
|
||||
{/if}
|
||||
</DetailsContainer>
|
||||
</section>
|
||||
{:else if currentTab === 'images'}
|
||||
<EntityImagesTab
|
||||
images={raidImages}
|
||||
{canEdit}
|
||||
onDownloadImage={canEdit ? handleDownloadImage : undefined}
|
||||
/>
|
||||
{:else if currentTab === 'raw'}
|
||||
<div class="raw-placeholder">
|
||||
<p>Raw data not available for raids.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</DetailScaffold>
|
||||
{:else}
|
||||
<div class="not-found">
|
||||
<h2>Raid Not Found</h2>
|
||||
|
|
@ -192,4 +303,10 @@
|
|||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.raw-placeholder {
|
||||
padding: spacing.$unit-4x;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Reference in a new issue