Merge branch 'svelte-main' of github.com:jedmund/hensei-web into svelte-main

This commit is contained in:
Justin Edmund 2026-01-08 00:38:47 -08:00
commit bf921ada94
10 changed files with 427 additions and 64 deletions

View file

@ -13,6 +13,21 @@ import type {
} from '$lib/types/api/raid' } from '$lib/types/api/raid'
import type { Raid, RaidGroup } from '$lib/types/api/entities' 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 * Adapter for Raid and RaidGroup API operations
*/ */
@ -88,6 +103,77 @@ export class RaidAdapter extends BaseAdapter {
this.clearCache(`/raids/${slug}`) 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<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 ==================== // ==================== RaidGroup Operations ====================
/** /**

View file

@ -0,0 +1,51 @@
<svelte:options runes={true} />
<script lang="ts">
import type { Raid } from '$lib/types/api/entities'
const ICON_BASE_URL = 'https://prd-game-a-granbluefantasy.akamaized.net/assets_en/img/sp/assets/enemy/m'
interface Props {
raid: Raid
}
const { raid }: Props = $props()
function getIconUrl(enemyId: number): string {
return `${ICON_BASE_URL}/${enemyId}.png`
}
</script>
<div class="image-cell">
{#if raid.enemy_id}
<img src={getIconUrl(raid.enemy_id)} alt="" class="database-image" />
{:else}
<div class="no-image"></div>
{/if}
</div>
<style lang="scss">
.image-cell {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: 4px;
}
.database-image {
max-width: 100%;
max-height: 48px;
width: auto;
height: auto;
object-fit: contain;
border-radius: 4px;
}
.no-image {
width: 48px;
height: 48px;
background: #f0f0f0;
border-radius: 4px;
}
</style>

View file

@ -9,7 +9,7 @@
// Props // Props
interface Props { interface Props {
type: 'character' | 'summon' | 'weapon' | 'job' type: 'character' | 'summon' | 'weapon' | 'job' | 'raid'
item: any // The character/summon/weapon/job object item: any // The character/summon/weapon/job object
image: string image: string
editUrl?: string // URL to navigate to for editing (view mode) editUrl?: string // URL to navigate to for editing (view mode)
@ -74,7 +74,8 @@
character: '/images/placeholders/placeholder-character-main.png', character: '/images/placeholders/placeholder-character-main.png',
summon: '/images/placeholders/placeholder-summon-main.png', summon: '/images/placeholders/placeholder-summon-main.png',
weapon: '/images/placeholders/placeholder-weapon-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 } as const
;(e.currentTarget as HTMLImageElement).src = placeholders[type] ;(e.currentTarget as HTMLImageElement).src = placeholders[type]
}} }}

View file

@ -11,7 +11,7 @@
export type DetailTab = 'info' | 'images' | 'raw' export type DetailTab = 'info' | 'images' | 'raw'
interface Props { interface Props {
type: 'character' | 'summon' | 'weapon' | 'job' type: 'character' | 'summon' | 'weapon' | 'job' | 'raid'
item: any item: any
image: string image: string
showEdit?: boolean showEdit?: boolean

View file

@ -220,6 +220,9 @@ export interface Raid {
name: LocalizedName name: LocalizedName
level: number level: number
element: number element: number
enemy_id?: number
summon_id?: number
quest_id?: number
group?: RaidGroup group?: RaidGroup
} }

View file

@ -16,6 +16,9 @@ export interface RaidFull {
name: LocalizedName name: LocalizedName
level: number level: number
element: number element: number
enemy_id?: number
summon_id?: number
quest_id?: number
group?: RaidGroupFlat group?: RaidGroupFlat
} }
@ -45,6 +48,9 @@ export interface CreateRaidInput {
level: number level: number
element: number element: number
group_id: string group_id: string
enemy_id?: number
summon_id?: number
quest_id?: number
} }
export interface UpdateRaidInput { export interface UpdateRaidInput {
@ -54,6 +60,9 @@ export interface UpdateRaidInput {
level?: number level?: number
element?: number element?: number
group_id?: string group_id?: string
enemy_id?: number
summon_id?: number
quest_id?: number
} }
// Input types for creating/updating raid groups // Input types for creating/updating raid groups

View file

@ -17,6 +17,7 @@
import Segment from '$lib/components/ui/segmented-control/Segment.svelte' import Segment from '$lib/components/ui/segmented-control/Segment.svelte'
import RaidGroupNameCell from '$lib/components/database/cells/RaidGroupNameCell.svelte' import RaidGroupNameCell from '$lib/components/database/cells/RaidGroupNameCell.svelte'
import RaidGroupFlagsCell from '$lib/components/database/cells/RaidGroupFlagsCell.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 { Raid, RaidGroup } from '$lib/types/api/entities'
import type { RaidGroupFull } from '$lib/types/api/raid' import type { RaidGroupFull } from '$lib/types/api/raid'
import { getRaidSectionLabel } from '$lib/utils/raidSection' import { getRaidSectionLabel } from '$lib/utils/raidSection'
@ -364,6 +365,7 @@
<table class="raids-table"> <table class="raids-table">
<thead> <thead>
<tr> <tr>
<th class="col-image"></th>
<th class="col-name">Name</th> <th class="col-name">Name</th>
<th class="col-level">Level</th> <th class="col-level">Level</th>
<th class="col-element">Element</th> <th class="col-element">Element</th>
@ -374,7 +376,7 @@
<tbody> <tbody>
{#if filteredRaids.length === 0 && !raidsQuery.isLoading} {#if filteredRaids.length === 0 && !raidsQuery.isLoading}
<tr> <tr>
<td colspan="5" class="empty-state"> <td colspan="6" class="empty-state">
{searchTerm || hasActiveFilters {searchTerm || hasActiveFilters
? 'No raids match your filters' ? 'No raids match your filters'
: 'No raids yet'} : 'No raids yet'}
@ -383,6 +385,9 @@
{:else} {:else}
{#each filteredRaids as raid} {#each filteredRaids as raid}
<tr onclick={() => handleRaidClick(raid)} class="clickable"> <tr onclick={() => handleRaidClick(raid)} class="clickable">
<td class="col-image">
<RaidImageCell {raid} />
</td>
<td class="col-name"> <td class="col-name">
<span class="raid-name">{displayName(raid)}</span> <span class="raid-name">{displayName(raid)}</span>
</td> </td>
@ -574,6 +579,11 @@
} }
} }
.col-image {
width: 64px;
padding: 4px !important;
}
.col-name { .col-name {
min-width: 200px; min-width: 200px;
} }

View file

@ -8,9 +8,18 @@
import Button from '$lib/components/ui/Button.svelte' import Button from '$lib/components/ui/Button.svelte'
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte' import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
import DetailItem from '$lib/components/ui/DetailItem.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 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 { 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 { function displayName(input: any): string {
if (!input) return '—' if (!input) return '—'
@ -26,6 +35,19 @@
let { data }: Props = $props() 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 // Get raid slug from URL
const raidSlug = $derived($page.params.slug) const raidSlug = $derived($page.params.slug)
@ -40,10 +62,8 @@
const userRole = $derived(data.role || 0) const userRole = $derived(data.role || 0)
const canEdit = $derived(userRole >= 7) const canEdit = $derived(userRole >= 7)
// Navigate to edit // Edit URL for navigation
function handleEdit() { const editUrl = $derived(raidSlug ? `/database/raids/${raidSlug}/edit` : undefined)
goto(`/database/raids/${raidSlug}/edit`)
}
// Navigate back // Navigate back
function handleBack() { function handleBack() {
@ -56,9 +76,119 @@
goto(`/database/raid-groups/${raid.group.id}`) 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)
}
</script> </script>
<div class="page"> <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} {#if raidQuery.isLoading}
<div class="loading-state"> <div class="loading-state">
<p>Loading raid...</p> <p>Loading raid...</p>
@ -69,57 +199,73 @@
<Button variant="secondary" onclick={handleBack}>Back to Raids</Button> <Button variant="secondary" onclick={handleBack}>Back to Raids</Button>
</div> </div>
{:else if raid} {:else if raid}
<SidebarHeader title={displayName(raid)}> <DetailScaffold
{#snippet leftAccessory()} type="raid"
<Button variant="secondary" size="small" onclick={handleBack}>Back</Button> item={raid}
{/snippet} image={headerImage}
{#snippet rightAccessory()} showEdit={canEdit}
{#if canEdit} {editUrl}
<Button variant="primary" size="small" onclick={handleEdit}>Edit</Button> {currentTab}
{/if} onTabChange={handleTabChange}
{/snippet} onDownloadAllImages={canEdit ? handleDownloadAllImages : undefined}
</SidebarHeader> 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="Quest ID" value={raid.quest_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="Classification">
<DetailsContainer title="Raid Details"> <DetailItem label="Group">
<DetailItem label="Name (EN)" value={raid.name.en || '-'} /> {#if raid.group}
<DetailItem label="Name (JA)" value={raid.name.ja || '-'} /> <Button variant="ghost" size="small" rightIcon="chevron-right-small" onclick={handleGroupClick}>
<DetailItem label="Slug" value={raid.slug || '-'} /> {displayName(raid.group)}
<DetailItem label="Level" value={raid.level?.toString() ?? '-'} /> </Button>
<DetailItem label="Element"> {:else}
{#if raid.element !== undefined && raid.element !== null} <span class="no-value">-</span>
<ElementBadge element={raid.element} /> {/if}
{:else} </DetailItem>
<span class="no-value">-</span> {#if raid.group}
{/if} <DetailItem label="Difficulty" value={raid.group.difficulty?.toString() ?? '-'} />
</DetailItem> <DetailItem label="HL">
</DetailsContainer> <span class="badge" class:active={raid.group.hl}>{raid.group.hl ? 'Yes' : 'No'}</span>
</DetailItem>
<DetailsContainer title="Classification"> <DetailItem label="Extra">
<DetailItem label="Group"> <span class="badge" class:active={raid.group.extra}>{raid.group.extra ? 'Yes' : 'No'}</span>
{#if raid.group} </DetailItem>
<Button variant="ghost" size="small" rightIcon="chevron-right-small" onclick={handleGroupClick}> <DetailItem label="Guidebooks">
{displayName(raid.group)} <span class="badge" class:active={raid.group.guidebooks}>{raid.group.guidebooks ? 'Yes' : 'No'}</span>
</Button> </DetailItem>
{:else} {/if}
<span class="no-value">-</span> </DetailsContainer>
{/if} </section>
</DetailItem> {:else if currentTab === 'images'}
{#if raid.group} <EntityImagesTab
<DetailItem label="Difficulty" value={raid.group.difficulty?.toString() ?? '-'} /> images={raidImages}
<DetailItem label="HL"> {canEdit}
<span class="badge" class:active={raid.group.hl}>{raid.group.hl ? 'Yes' : 'No'}</span> onDownloadImage={canEdit ? handleDownloadImage : undefined}
</DetailItem> />
<DetailItem label="Extra"> {:else if currentTab === 'raw'}
<span class="badge" class:active={raid.group.extra}>{raid.group.extra ? 'Yes' : 'No'}</span> <div class="raw-placeholder">
</DetailItem> <p>Raw data not available for raids.</p>
<DetailItem label="Guidebooks"> </div>
<span class="badge" class:active={raid.group.guidebooks}>{raid.group.guidebooks ? 'Yes' : 'No'}</span> {/if}
</DetailItem> </DetailScaffold>
{/if}
</DetailsContainer>
</section>
{:else} {:else}
<div class="not-found"> <div class="not-found">
<h2>Raid Not Found</h2> <h2>Raid Not Found</h2>
@ -190,4 +336,10 @@
color: white; color: white;
} }
} }
.raw-placeholder {
padding: spacing.$unit-4x;
text-align: center;
color: var(--text-secondary);
}
</style> </style>

View file

@ -57,7 +57,10 @@
slug: '', slug: '',
level: 0, level: 0,
element: 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 // Sync edit data when raid changes
@ -69,7 +72,10 @@
slug: raid.slug || '', slug: raid.slug || '',
level: raid.level ?? 0, level: raid.level ?? 0,
element: raid.element ?? 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, slug: editData.slug,
level: editData.level, level: editData.level,
element: editData.element, 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 // Invalidate queries
@ -183,6 +192,24 @@
editable={true} editable={true}
type="number" type="number"
/> />
<DetailItem
label="Enemy ID"
bind:value={editData.enemy_id}
editable={true}
type="number"
/>
<DetailItem
label="Summon ID"
bind:value={editData.summon_id}
editable={true}
type="number"
/>
<DetailItem
label="Quest ID"
bind:value={editData.quest_id}
editable={true}
type="number"
/>
<DetailItem <DetailItem
label="Element" label="Element"
bind:value={editData.element} bind:value={editData.element}

View file

@ -44,7 +44,10 @@
slug: '', slug: '',
level: 0, level: 0,
element: 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
}) })
// Element options // Element options
@ -85,7 +88,10 @@
slug: editData.slug, slug: editData.slug,
level: editData.level, level: editData.level,
element: editData.element, 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 // Invalidate queries
@ -151,6 +157,24 @@
editable={true} editable={true}
type="number" type="number"
/> />
<DetailItem
label="Enemy ID"
bind:value={editData.enemy_id}
editable={true}
type="number"
/>
<DetailItem
label="Summon ID"
bind:value={editData.summon_id}
editable={true}
type="number"
/>
<DetailItem
label="Quest ID"
bind:value={editData.quest_id}
editable={true}
type="number"
/>
<DetailItem <DetailItem
label="Element" label="Element"
bind:value={editData.element} bind:value={editData.element}