99 lines
6.5 KiB
Vue
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>
|