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
This commit is contained in:
parent
77949edd9d
commit
b4608846f5
16 changed files with 596 additions and 256 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', '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 ====================
|
||||
|
||||
/**
|
||||
|
|
|
|||
51
src/lib/components/database/cells/RaidImageCell.svelte
Normal file
51
src/lib/components/database/cells/RaidImageCell.svelte
Normal 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>
|
||||
|
|
@ -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 @@
|
|||
</script>
|
||||
|
||||
<div class="youtube-input-wrapper">
|
||||
{#if label}
|
||||
<label class="input-label">{label}</label>
|
||||
{/if}
|
||||
<div class="input-container" class:disabled class:error={showError} class:contained>
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://youtube.com/watch?v=..."
|
||||
value={inputValue}
|
||||
oninput={handleInput}
|
||||
onblur={handleBlur}
|
||||
onkeydown={handleKeydown}
|
||||
{disabled}
|
||||
class="url-input"
|
||||
/>
|
||||
{#if inputValue && !disabled}
|
||||
<button type="button" class="clear-button" onclick={clearInput} aria-label="Clear URL">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showError}
|
||||
<p class="error-message">Please enter a valid YouTube URL</p>
|
||||
{/if}
|
||||
<Input
|
||||
{label}
|
||||
type="url"
|
||||
placeholder="https://youtube.com/watch?v=..."
|
||||
bind:value={inputValue}
|
||||
handleInput={handleInput}
|
||||
handleBlur={handleBlur}
|
||||
{disabled}
|
||||
{contained}
|
||||
clearable
|
||||
onClear={clearInput}
|
||||
error={showError ? 'Please enter a valid YouTube URL' : undefined}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
{#if showPreview && thumbnailUrl}
|
||||
<div class="preview-card">
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement | undefined>(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))
|
||||
</script>
|
||||
|
||||
<div class="description-tile">
|
||||
<div class="description-tile" class:has-fade={needsFade}>
|
||||
<!-- Header: Title + Actions -->
|
||||
<div class="tile-header-container">
|
||||
<div class="tile-header">
|
||||
|
|
@ -118,7 +130,7 @@
|
|||
<!-- Description content (clickable) -->
|
||||
<button type="button" class="description-content" onclick={onOpenDescription}>
|
||||
{#if previewParagraphs.length}
|
||||
<div class="preview-text">
|
||||
<div class="preview-text" bind:this={contentEl}>
|
||||
{#each previewParagraphs as paragraph}
|
||||
<p>{paragraph}</p>
|
||||
{/each}
|
||||
|
|
@ -146,7 +158,7 @@
|
|||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
&.has-fade::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
|
|
|
|||
|
|
@ -20,9 +20,14 @@
|
|||
Array.from({ length: 6 }, (_, i) => summons.find((s: GridSummon) => s?.position === i))
|
||||
)
|
||||
|
||||
// Transparent SVG placeholders with correct aspect ratios for grid sizing
|
||||
// Main/friend: 56x97, Grid: 184x138
|
||||
const mainPlaceholder = `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 56 97'%3E%3C/svg%3E`
|
||||
const gridPlaceholder = `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 184 138'%3E%3C/svg%3E`
|
||||
|
||||
function summonImageUrl(s?: GridSummon, isMain = false): string {
|
||||
const id = s?.summon?.granblueId
|
||||
if (!id) return ''
|
||||
if (!id) return isMain ? mainPlaceholder : gridPlaceholder
|
||||
const size = isMain ? 'main' : 'grid'
|
||||
return getSummonImage(id, size)
|
||||
}
|
||||
|
|
@ -30,27 +35,27 @@
|
|||
|
||||
<div class="rep" class:extended={extendedView}>
|
||||
<div class="mainSummon" class:empty={!main}>
|
||||
{#if main}<img
|
||||
alt="Main Summon"
|
||||
src={summonImageUrl(main, true)}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>{/if}
|
||||
<img
|
||||
alt={main ? 'Main Summon' : ''}
|
||||
src={summonImageUrl(main, true)}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<ul class="summons">
|
||||
{#each grid as s, i}
|
||||
<li class="summon" class:empty={!s}>
|
||||
{#if s}<img alt="Summon" src={summonImageUrl(s)} loading="lazy" decoding="async" />{/if}
|
||||
<img alt={s ? 'Summon' : ''} src={summonImageUrl(s)} loading="lazy" decoding="async" />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<div class="friendSummon" class:empty={!friend}>
|
||||
{#if friend}<img
|
||||
alt="Friend Summon"
|
||||
src={summonImageUrl(friend, true)}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>{/if}
|
||||
<img
|
||||
alt={friend ? 'Friend Summon' : ''}
|
||||
src={summonImageUrl(friend, true)}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -67,6 +72,7 @@
|
|||
|
||||
// Layout: main summon | 6 grid summons (3x2) | friend summon
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-rows: 1fr;
|
||||
|
||||
.summon,
|
||||
.mainSummon,
|
||||
|
|
@ -90,7 +96,6 @@
|
|||
.mainSummon,
|
||||
.friendSummon {
|
||||
@include rep.aspect(rep.$summon-main-w, rep.$summon-main-h);
|
||||
display: grid;
|
||||
height: calc(100% - 6px);
|
||||
align-self: center;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -280,17 +280,51 @@
|
|||
</script>
|
||||
|
||||
<div class="party-edit-sidebar">
|
||||
<div class="top-fields">
|
||||
<Input
|
||||
label="Title"
|
||||
bind:value={name}
|
||||
placeholder="Enter party title..."
|
||||
contained
|
||||
fullWidth
|
||||
/>
|
||||
<YouTubeUrlInput label="Video" bind:value={videoUrl} contained />
|
||||
<div class="top-section">
|
||||
<h3>Details</h3>
|
||||
<div class="top-fields">
|
||||
<Input
|
||||
label="Title"
|
||||
bind:value={name}
|
||||
placeholder="Enter party title..."
|
||||
contained
|
||||
fullWidth
|
||||
/>
|
||||
<YouTubeUrlInput label="Video" bind:value={videoUrl} contained />
|
||||
<div class="raid-field">
|
||||
<span class="raid-label">Raid</span>
|
||||
<button
|
||||
type="button"
|
||||
class="raid-select-button {getRaidElementClass(raid)}"
|
||||
onclick={openRaidPane}
|
||||
>
|
||||
{#if raid}
|
||||
<span class="raid-name">{getRaidName(raid)}</span>
|
||||
{:else}
|
||||
<span class="placeholder">Select raid...</span>
|
||||
{/if}
|
||||
<Icon name="chevron-right" size={16} class="chevron-icon" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="divider" />
|
||||
|
||||
<button type="button" class="description-button" onclick={openDescriptionPane}>
|
||||
<div class="description-header">
|
||||
<span class="description-label">Description</span>
|
||||
<Icon name="chevron-right" size={16} class="description-chevron" />
|
||||
</div>
|
||||
{#if descriptionPreview}
|
||||
<p class="description-preview">{descriptionPreview}</p>
|
||||
{:else}
|
||||
<span class="description-placeholder">Add description...</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<hr class="divider" />
|
||||
|
||||
<DetailsSection title="Sharing">
|
||||
<DetailRow label="Visibility" noHover compact>
|
||||
{#snippet children()}
|
||||
|
|
@ -314,36 +348,7 @@
|
|||
{/if}
|
||||
</DetailsSection>
|
||||
|
||||
<button type="button" class="description-button" onclick={openDescriptionPane}>
|
||||
<div class="description-header">
|
||||
<span class="description-label">Description</span>
|
||||
<Icon name="chevron-right" size={16} class="description-chevron" />
|
||||
</div>
|
||||
{#if descriptionPreview}
|
||||
<p class="description-preview">{descriptionPreview}</p>
|
||||
{:else}
|
||||
<span class="description-placeholder">Add description...</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<DetailsSection title="Battle">
|
||||
<DetailRow label="Raid" noHover compact>
|
||||
{#snippet children()}
|
||||
<button
|
||||
type="button"
|
||||
class="raid-select-button {getRaidElementClass(raid)}"
|
||||
onclick={openRaidPane}
|
||||
>
|
||||
{#if raid}
|
||||
<span class="raid-name">{getRaidName(raid)}</span>
|
||||
{:else}
|
||||
<span class="placeholder">Select raid...</span>
|
||||
{/if}
|
||||
<Icon name="chevron-right" size={16} class="chevron-icon" />
|
||||
</button>
|
||||
{/snippet}
|
||||
</DetailRow>
|
||||
</DetailsSection>
|
||||
<hr class="divider" />
|
||||
|
||||
<BattleSettingsSection
|
||||
bind:fullAuto
|
||||
|
|
@ -354,6 +359,8 @@
|
|||
onchange={handleSettingsChange}
|
||||
/>
|
||||
|
||||
<hr class="divider" />
|
||||
|
||||
<DetailsSection title="Performance">
|
||||
<DetailRow label="Clear Time" noHover compact>
|
||||
{#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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<typeof setTimeout> | 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<string, any>,
|
||||
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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -220,6 +220,9 @@ export interface Raid {
|
|||
name: LocalizedName
|
||||
level: number
|
||||
element: number
|
||||
enemy_id?: number
|
||||
summon_id?: number
|
||||
quest_id?: number
|
||||
group?: RaidGroup
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
<table class="raids-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-image"></th>
|
||||
<th class="col-name">Name</th>
|
||||
<th class="col-level">Level</th>
|
||||
<th class="col-element">Element</th>
|
||||
|
|
@ -374,7 +376,7 @@
|
|||
<tbody>
|
||||
{#if filteredRaids.length === 0 && !raidsQuery.isLoading}
|
||||
<tr>
|
||||
<td colspan="5" class="empty-state">
|
||||
<td colspan="6" class="empty-state">
|
||||
{searchTerm || hasActiveFilters
|
||||
? 'No raids match your filters'
|
||||
: 'No raids yet'}
|
||||
|
|
@ -383,6 +385,9 @@
|
|||
{:else}
|
||||
{#each filteredRaids as raid}
|
||||
<tr onclick={() => handleRaidClick(raid)} class="clickable">
|
||||
<td class="col-image">
|
||||
<RaidImageCell {raid} />
|
||||
</td>
|
||||
<td class="col-name">
|
||||
<span class="raid-name">{displayName(raid)}</span>
|
||||
</td>
|
||||
|
|
@ -574,6 +579,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
.col-image {
|
||||
width: 64px;
|
||||
padding: 4px !important;
|
||||
}
|
||||
|
||||
.col-name {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
</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,57 +199,73 @@
|
|||
<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="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="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="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>
|
||||
|
|
@ -190,4 +336,10 @@
|
|||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.raw-placeholder {
|
||||
padding: spacing.$unit-4x;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
<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
|
||||
label="Element"
|
||||
bind:value={editData.element}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,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
|
||||
})
|
||||
|
||||
// Element options
|
||||
|
|
@ -85,7 +88,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
|
||||
|
|
@ -151,6 +157,24 @@
|
|||
editable={true}
|
||||
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
|
||||
label="Element"
|
||||
bind:value={editData.element}
|
||||
|
|
|
|||
|
|
@ -756,7 +756,7 @@
|
|||
|
||||
<main>
|
||||
<div class="page-container">
|
||||
<section class="party-content">
|
||||
<section class="party-section">
|
||||
<div class="description-tile-wrapper">
|
||||
<DescriptionTile
|
||||
name={party.name}
|
||||
|
|
@ -838,7 +838,9 @@
|
|||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
|
||||
<style>
|
||||
<style lang="scss">
|
||||
@use '$src/themes/spacing' as *;
|
||||
|
||||
.page-container {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
|
|
@ -851,13 +853,11 @@
|
|||
padding-bottom: 20vh;
|
||||
}
|
||||
|
||||
.party-content {
|
||||
.party-section {
|
||||
flex: 1;
|
||||
padding: 1rem 2rem;
|
||||
}
|
||||
|
||||
.description-tile-wrapper {
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
|
||||
.party-content {
|
||||
|
|
@ -867,7 +867,7 @@
|
|||
.character-tab-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
|
||||
/* Dialog styles */
|
||||
|
|
|
|||
Loading…
Reference in a new issue