add image download buttons to detail scaffold

This commit is contained in:
Justin Edmund 2025-12-02 01:25:18 -08:00
parent 2771e202cb
commit 38762c8946
3 changed files with 406 additions and 25 deletions

View file

@ -1,9 +1,11 @@
<svelte:options runes={true} />
<script lang="ts">
import { DropdownMenu } from 'bits-ui'
import DetailsHeader from '$lib/components/ui/DetailsHeader.svelte'
import SegmentedControl from '$lib/components/ui/segmented-control/SegmentedControl.svelte'
import Segment from '$lib/components/ui/segmented-control/Segment.svelte'
import Button from '$lib/components/ui/Button.svelte'
import type { Snippet } from 'svelte'
export type DetailTab = 'info' | 'images' | 'raw'
@ -24,6 +26,10 @@
currentTab?: DetailTab
onTabChange?: (tab: DetailTab) => void
showTabs?: boolean
// Image download handlers
onDownloadAllImages?: (force: boolean) => Promise<void>
onDownloadSize?: (size: string) => Promise<void>
availableSizes?: string[]
}
let {
@ -41,12 +47,45 @@
currentTab = 'info',
onTabChange,
showTabs = true,
onDownloadAllImages,
onDownloadSize,
availableSizes = [],
children
}: Props & { children: Snippet } = $props()
let isDownloading = $state(false)
let dropdownOpen = $state(false)
function handleTabChange(value: string) {
onTabChange?.(value as DetailTab)
}
async function handleDownloadAll(force: boolean) {
if (!onDownloadAllImages) return
isDownloading = true
dropdownOpen = false
try {
await onDownloadAllImages(force)
} finally {
isDownloading = false
}
}
async function handleDownloadSize(size: string) {
if (!onDownloadSize) return
isDownloading = true
dropdownOpen = false
try {
await onDownloadSize(size)
} finally {
isDownloading = false
}
}
// Show download button when on images tab and can edit
const showDownloadDropdown = $derived(
showEdit && currentTab === 'images' && onDownloadAllImages && !editMode
)
</script>
<div class="content">
@ -74,6 +113,48 @@
<Segment value="images">Images</Segment>
<Segment value="raw">Raw Data</Segment>
</SegmentedControl>
{#if showDownloadDropdown}
<div class="download-dropdown">
<DropdownMenu.Root bind:open={dropdownOpen}>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="secondary" size="small" disabled={isDownloading}>
{isDownloading ? 'Downloading...' : 'Download Images'}
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content class="dropdown-menu" sideOffset={4}>
<DropdownMenu.Item
class="dropdown-menu-item"
onclick={() => handleDownloadAll(false)}
>
Download All Images
</DropdownMenu.Item>
<DropdownMenu.Item
class="dropdown-menu-item"
onclick={() => handleDownloadAll(true)}
>
Re-download All Images
</DropdownMenu.Item>
{#if availableSizes.length > 0}
<DropdownMenu.Separator class="dropdown-menu-separator" />
{#each availableSizes as size}
<DropdownMenu.Item
class="dropdown-menu-item"
onclick={() => handleDownloadSize(size)}
>
Download All "{size}" Images
</DropdownMenu.Item>
{/each}
{/if}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
{/if}
</div>
{/if}
@ -109,6 +190,15 @@
.tab-navigation {
padding: spacing.$unit-2x;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: spacing.$unit;
}
.download-dropdown {
margin-left: auto;
}
.edit-controls {
@ -139,4 +229,36 @@
opacity: 1;
}
}
// Import menu styles
:global(.dropdown-menu) {
background: var(--app-bg, white);
border: 1px solid var(--border-color, #ddd);
border-radius: layout.$card-corner;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: spacing.$unit-half;
min-width: calc(spacing.$unit * 22.5);
z-index: 200;
}
:global(.dropdown-menu-item) {
padding: spacing.$unit spacing.$unit-2x;
border-radius: layout.$item-corner-small;
cursor: pointer;
font-size: typography.$font-regular;
color: var(--text-primary);
display: flex;
align-items: center;
gap: spacing.$unit;
&:hover {
background: var(--button-contained-bg-hover, #f5f5f5);
}
}
:global(.dropdown-menu-separator) {
height: 1px;
background: var(--menu-separator, #e5e5e5);
margin: spacing.$unit-half 0;
}
</style>

View file

@ -1,31 +1,205 @@
<svelte:options runes={true} />
<script lang="ts">
import { ContextMenu } from 'bits-ui'
import ContextMenuWrapper from '$lib/components/ui/menu/ContextMenuWrapper.svelte'
export interface ImageItem {
url: string
label: string
variant: string
pose?: string
poseLabel?: string // Custom label for the pose group (e.g., "ULB" for summons)
}
interface Props {
images: ImageItem[]
canEdit?: boolean
onDownloadImage?: (
size: string,
transformation: string | undefined,
force: boolean
) => Promise<void>
onDownloadAllPose?: (pose: string, force: boolean) => Promise<void>
}
let { images }: Props = $props()
let {
images,
canEdit = false,
onDownloadImage,
onDownloadAllPose
}: Props = $props()
// Track download status per image
let downloadingImages = $state<Set<string>>(new Set())
// Group images by pose for better layout
const imagesByPose = $derived.by(() => {
const groups = new Map<string, ImageItem[]>()
for (const image of images) {
const key = image.pose ?? 'default'
if (!groups.has(key)) {
groups.set(key, [])
}
groups.get(key)!.push(image)
}
return groups
})
// Get pose labels in order
// Characters: 01=Base, 02=MLB, 03=FLB, 04=Transcendence
// Summons: 01=Base, 02=ULB, 03=Transcendence
// Weapons: 01=Base, 02=Transcendence
const poseOrder = ['01', '02', '03', '04', 'default']
const poseLabels: Record<string, string> = {
'01': 'Base',
'02': 'MLB', // Will be overridden by label from page for summons/weapons
'03': 'FLB',
'04': 'Transcendence',
default: ''
}
const sortedPoses = $derived(
Array.from(imagesByPose.keys()).sort(
(a, b) => poseOrder.indexOf(a) - poseOrder.indexOf(b)
)
)
// Get the pose label from the first image in each group, or fall back to default
function getPoseLabel(pose: string, poseImages: ImageItem[]): string {
const customLabel = poseImages[0]?.poseLabel
if (customLabel) return customLabel
return poseLabels[pose] || pose
}
// Create a unique key for an image (for tracking download state)
function getImageKey(image: ImageItem): string {
return `${image.pose ?? 'default'}-${image.variant}`
}
// Handle single image download
async function handleDownload(image: ImageItem, force: boolean) {
if (!onDownloadImage) return
const key = getImageKey(image)
downloadingImages.add(key)
downloadingImages = new Set(downloadingImages)
try {
await onDownloadImage(image.variant, image.pose, force)
} finally {
downloadingImages.delete(key)
downloadingImages = new Set(downloadingImages)
}
}
// Handle download all images for a pose
async function handleDownloadAllPose(pose: string, force: boolean) {
if (!onDownloadAllPose) return
// Mark all images in this pose as downloading
const poseImages = imagesByPose.get(pose) ?? []
for (const img of poseImages) {
downloadingImages.add(getImageKey(img))
}
downloadingImages = new Set(downloadingImages)
try {
await onDownloadAllPose(pose, force)
} finally {
for (const img of poseImages) {
downloadingImages.delete(getImageKey(img))
}
downloadingImages = new Set(downloadingImages)
}
}
</script>
<div class="images-tab">
<div class="images-grid">
{#each images as image}
<div class="image-item">
<a href={image.url} target="_blank" rel="noopener noreferrer">
<img src={image.url} alt={image.label} loading="lazy" />
</a>
<span class="image-label">{image.label}</span>
</div>
{/each}
</div>
{#each sortedPoses as pose}
{@const poseImages = imagesByPose.get(pose) ?? []}
{@const poseLabel = getPoseLabel(pose, poseImages)}
{@const showHeader = poseLabel && sortedPoses.length > 1}
{#if showHeader}
<h3 class="pose-header">{poseLabel}</h3>
{/if}
<div class="images-grid">
{#each poseImages as image}
{@const imageKey = getImageKey(image)}
{@const isDownloading = downloadingImages.has(imageKey)}
{#if canEdit && onDownloadImage}
<ContextMenuWrapper>
{#snippet trigger()}
<div class="image-item" class:downloading={isDownloading}>
<a
href={image.url}
target="_blank"
rel="noopener noreferrer"
class="image-container"
>
<img src={image.url} alt={image.label} loading="lazy" />
{#if isDownloading}
<div class="download-overlay">
<span class="download-spinner"></span>
</div>
{/if}
</a>
<span class="image-label">{image.variant}</span>
</div>
{/snippet}
{#snippet menu()}
<ContextMenu.Item
class="context-menu-item"
onclick={() => handleDownload(image, false)}
disabled={isDownloading}
>
Download Image
</ContextMenu.Item>
<ContextMenu.Item
class="context-menu-item"
onclick={() => handleDownload(image, true)}
disabled={isDownloading}
>
Re-download Image
</ContextMenu.Item>
{#if onDownloadAllPose}
<ContextMenu.Separator class="context-menu-separator" />
<ContextMenu.Item
class="context-menu-item"
onclick={() => handleDownloadAllPose(pose, false)}
>
Download All {poseLabel} Images
</ContextMenu.Item>
{/if}
<ContextMenu.Separator class="context-menu-separator" />
<ContextMenu.Item
class="context-menu-item"
onclick={() => window.open(image.url, '_blank')}
>
Open in New Tab
</ContextMenu.Item>
{/snippet}
</ContextMenuWrapper>
{:else}
<div class="image-item">
<a
href={image.url}
target="_blank"
rel="noopener noreferrer"
class="image-container"
>
<img src={image.url} alt={image.label} loading="lazy" />
</a>
<span class="image-label">{image.variant}</span>
</div>
{/if}
{/each}
</div>
{/each}
</div>
<style lang="scss">
@ -38,10 +212,23 @@
padding: spacing.$unit-2x;
}
.pose-header {
font-size: typography.$font-regular;
font-weight: 600;
color: colors.$grey-30;
margin: 0 0 spacing.$unit 0;
padding-top: spacing.$unit-2x;
&:first-child {
padding-top: 0;
}
}
.images-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: spacing.$unit-2x;
margin-bottom: spacing.$unit-2x;
}
.image-item {
@ -50,22 +237,58 @@
align-items: center;
gap: spacing.$unit;
a {
display: block;
border-radius: layout.$item-corner;
overflow: hidden;
transition: transform 0.2s ease;
&.downloading {
opacity: 0.7;
}
}
&:hover {
transform: scale(1.02);
}
.image-container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
aspect-ratio: 1;
background: colors.$grey-90;
border-radius: layout.$item-corner;
overflow: hidden;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.02);
}
img {
display: block;
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
background: colors.$grey-90;
object-fit: contain;
}
}
.download-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
}
.download-spinner {
width: 24px;
height: 24px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@ -73,5 +296,6 @@
font-size: typography.$font-small;
color: colors.$grey-40;
text-align: center;
text-transform: capitalize;
}
</style>

View file

@ -119,13 +119,33 @@ export function getCharacterDetailImage(
/**
* Get weapon image URL
* @param transformation - Optional transformation suffix ('02' for transcendence)
*/
export function getWeaponImage(
id: string | number | null | undefined,
variant: ImageVariant = 'main',
element?: number
element?: number,
transformation?: string
): string {
return getImageUrl('weapon', id, variant, { element })
if (!id) {
return getPlaceholderImage('weapon', variant)
}
const directory = getImageDirectory('weapon', variant)
const extension = getFileExtension('weapon', variant)
const basePath = `${getBasePath()}/${directory}`
// Handle element-specific weapon grids
if (variant === 'grid' && element && element > 0) {
return `${basePath}/${id}_${element}${extension}`
}
// Handle transformation suffix (transcendence)
if (transformation) {
return `${basePath}/${id}_${transformation}${extension}`
}
return `${basePath}/${id}${extension}`
}
/**
@ -139,12 +159,27 @@ export function getWeaponBaseImage(
/**
* Get summon image URL
* @param transformation - Optional transformation suffix ('02' for ULB, '03' for transcendence)
*/
export function getSummonImage(
id: string | number | null | undefined,
variant: ImageVariant = 'main'
variant: ImageVariant = 'main',
transformation?: string
): string {
return getImageUrl('summon', id, variant)
if (!id) {
return getPlaceholderImage('summon', variant)
}
const directory = getImageDirectory('summon', variant)
const extension = getFileExtension('summon', variant)
const basePath = `${getBasePath()}/${directory}`
// Handle transformation suffix (ULB, transcendence)
if (transformation) {
return `${basePath}/${id}_${transformation}${extension}`
}
return `${basePath}/${id}${extension}`
}
/**