Files

99 lines
6.5 KiB
Vue

<template>
<section class="asset-references" :data-reference-asset="`${kind}:${asset.id}`" :aria-label="t('assetReferences.title')" @click.stop @keydown.enter.stop @keydown.space.stop>
<div class="reference-heading">
<span class="reference-label"><Images :size="14" />{{ t('assetReferences.title') }} <small>{{ references.length }}/6</small></span>
<button type="button" class="btn btn-sm" :disabled="busy || references.length >= 6" @click="fileInput?.click()">
<Loader2 v-if="busy" :size="12" class="animate-spin" /><Plus v-else :size="12" />
{{ t(busy ? 'assetReferences.saving' : 'assetReferences.add') }}
</button>
<input ref="fileInput" type="file" class="reference-input" multiple accept=".jpg,.jpeg,.png,.webp,image/jpeg,image/png,image/webp" :disabled="busy" :aria-label="t('assetReferences.add')" @change="upload" />
</div>
<div v-if="references.length" class="reference-images">
<div v-for="(path, index) in references" :key="path" class="reference-image">
<button type="button" class="reference-preview" :aria-label="t('assetReferences.preview', { n: index + 1 })" @click="emit('preview', imageUrl(path), t('assetReferences.preview', { n: index + 1 }))">
<img :src="imageUrl(path).replace(/\.[^./]+$/, '_thumb.webp')" :alt="t('assetReferences.preview', { n: index + 1 })" loading="lazy" @error="fallback($event, path)" />
<span>{{ index + 1 }}</span>
</button>
<button type="button" class="reference-remove" :disabled="busy" :aria-label="t('assetReferences.remove', { n: index + 1 })" :title="t('assetReferences.remove', { n: index + 1 })" @click="remove(path)"><X :size="11" /></button>
</div>
</div>
<p class="reference-hint">{{ t(references.length ? 'assetReferences.used' : 'assetReferences.empty') }}</p>
<p v-if="!references.length" class="reference-formats">{{ t('assetReferences.formats') }}</p>
</section>
</template>
<script setup lang="ts">
import { Images, Plus, X, Loader2 } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { assetReferenceAPI, type AssetKind } from '~/composables/useApi'
import { toastError } from '~/composables/useToast'
const props = defineProps<{ kind: AssetKind; asset: { id: number; reference_images?: string | string[] | null; referenceImages?: string | string[] | null } }>()
const emit = defineEmits<{ updated: [kind: AssetKind, id: number, references: string[]]; preview: [url: string, title: string] }>()
const { t } = useI18n()
const fileInput = ref<HTMLInputElement | null>(null)
const busyIds = useState<string[]>('asset-reference-busy', () => [])
const key = computed(() => `${props.kind}:${props.asset.id}`)
const busy = computed(() => busyIds.value.includes(key.value))
const references = computed<string[]>(() => {
const value = props.asset.reference_images ?? props.asset.referenceImages
try { const list = typeof value === 'string' ? JSON.parse(value) : value; return Array.isArray(list) ? list.filter(v => typeof v === 'string') : [] } catch { return [] }
})
function imageUrl(path: string) { return /^(https?:\/\/|\/)/i.test(path) ? path : `/${path}` }
function fallback(event: Event, path: string) {
const img = event.target as HTMLImageElement
if (!img.dataset.original) { img.dataset.original = 'true'; img.src = imageUrl(path) }
}
async function upload(event: Event) {
const input = event.target as HTMLInputElement, files = Array.from(input.files || [])
input.value = ''
if (!files.length || busy.value) return
if (references.value.length + files.length > 6) { toast.error(t('assetReferences.limit')); return }
if (files.some(file => !/\.(jpe?g|png|webp)$/i.test(file.name) || !file.size || file.size > 20 * 1024 * 1024)) { toast.error(t('assetReferences.invalid')); return }
const { kind, asset: { id } } = props, lock = key.value
busyIds.value = [...busyIds.value, lock]
try {
for (const file of files) {
const result = await assetReferenceAPI.upload(kind, id, file)
emit('updated', kind, id, result.reference_images)
}
toast.success(t('assetReferences.saved'))
} catch (error) { toastError(error) }
finally { busyIds.value = busyIds.value.filter(value => value !== lock) }
}
async function remove(path: string) {
if (busy.value) return
const { kind, asset: { id } } = props, lock = key.value
busyIds.value = [...busyIds.value, lock]
try {
const result = await assetReferenceAPI.remove(kind, id, path)
emit('updated', kind, id, result.reference_images)
toast.success(t('assetReferences.removed'))
} catch (error) { toastError(error) }
finally { busyIds.value = busyIds.value.filter(value => value !== lock) }
}
</script>
<style scoped>
.asset-references { min-width: 0; border-top: 1px solid var(--border); padding-top: 12px; display: flex; flex-direction: column; gap: 8px; cursor: default; }
.reference-heading, .reference-label { display: flex; align-items: center; gap: 5px; min-width: 0; }
.reference-heading { justify-content: space-between; gap: 8px; }
.reference-label { color: var(--text-1); font-size: 12px; font-weight: 600; }
.reference-label svg, .reference-label small { color: var(--text-3); flex-shrink: 0; }
.reference-label small { font-size: 10px; font-weight: 400; }
.reference-heading .btn { flex-shrink: 0; }
.reference-input { display: none; }
.reference-images { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
.reference-image { position: relative; min-width: 0; }
.reference-preview { position: relative; display: block; width: 100%; aspect-ratio: 1.2; border: 1px solid var(--border); border-radius: 9px; background: var(--bg); padding: 0; overflow: hidden; cursor: zoom-in; }
.reference-preview img { width: 100%; height: 100%; object-fit: contain; }
.reference-preview span { position: absolute; left: 4px; bottom: 4px; padding: 1px 5px; border-radius: 4px; background: rgba(0,0,0,.6); color: white; font-size: 10px; }
.reference-remove { position: absolute; right: -3px; top: -3px; display: grid; place-items: center; width: 21px; height: 21px; padding: 0; border: 1px solid var(--border); border-radius: 50%; background: var(--bg-card, white); color: var(--text-2); cursor: pointer; }
.reference-remove:hover { color: var(--action-danger); }
.reference-remove:disabled { opacity: .5; cursor: default; }
.reference-preview:focus-visible, .reference-remove:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.reference-hint, .reference-formats { margin: 0; font-size: 11px; line-height: 1.6; color: var(--text-2); }
.reference-formats { color: var(--text-3); }
</style>