add image download buttons to detail scaffold
This commit is contained in:
parent
2771e202cb
commit
38762c8946
3 changed files with 406 additions and 25 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue