152 lines
6.0 KiB
Vue
152 lines
6.0 KiB
Vue
<template>
|
|
<Teleport to="body">
|
|
<div v-if="modelValue" class="modal d-block" tabindex="-1" style="z-index:1055;padding-top:72px;padding-bottom:80px">
|
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
|
<div class="modal-content" style="background:var(--bg-card);color:var(--text-primary)">
|
|
<div class="modal-header border-0 pb-0">
|
|
<div class="d-flex align-items-center gap-2 w-100 me-2">
|
|
<i class="fas fa-images" style="color:var(--accent-color)"></i>
|
|
<input v-model="query" type="text" class="form-control rounded-pill form-control-sm"
|
|
placeholder="Search photos…" style="background:var(--bg-primary);color:var(--text-primary);border-color:rgba(128,128,128,.3)" />
|
|
</div>
|
|
<button type="button" class="btn-close" @click="$emit('update:modelValue', false)" />
|
|
</div>
|
|
<div class="modal-body pt-2">
|
|
<div v-if="error" class="alert alert-danger alert-dismissible py-2 mb-2">
|
|
{{ error }} <button type="button" class="btn-close" @click="error=null"/>
|
|
</div>
|
|
<!-- skeleton -->
|
|
<div v-if="loading && !photos.length" class="row g-2">
|
|
<div v-for="n in 9" :key="n" class="col-4">
|
|
<div class="ratio ratio-4x3 rounded" style="background:rgba(128,128,128,.15);animation:pulse 1.5s infinite" />
|
|
</div>
|
|
</div>
|
|
<!-- grid -->
|
|
<div v-else class="row g-2">
|
|
<div v-for="photo in photos" :key="photo.id" class="col-4" style="cursor:pointer" @click="selectPhoto(photo)">
|
|
<div class="ratio ratio-4x3 position-relative rounded overflow-hidden">
|
|
<img :src="photo.thumb" class="w-100 h-100 object-fit-cover" :alt="photo.title" loading="lazy" />
|
|
<div v-if="selecting===photo.id" class="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center" style="background:rgba(0,0,0,.45)">
|
|
<div class="spinner-border text-light spinner-border-sm" />
|
|
</div>
|
|
</div>
|
|
<small class="text-muted d-block text-truncate mt-1 px-1" style="font-size:.7rem">{{ photo.title }}</small>
|
|
</div>
|
|
</div>
|
|
<!-- empty states -->
|
|
<div v-if="!loading && !photos.length && !error && !query.trim()" class="text-center text-muted py-4">
|
|
<i class="fas fa-search d-block mb-2" style="font-size:1.5rem;opacity:.5"></i>
|
|
Type a product name above to search photos.
|
|
</div>
|
|
<div v-else-if="!loading && !photos.length && !error && query.trim()" class="text-center text-muted py-4">
|
|
<i class="fas fa-image d-block mb-2" style="font-size:1.5rem;opacity:.5"></i>
|
|
No photos found for “{{ query.trim() }}”.
|
|
</div>
|
|
<!-- sentinel for infinite scroll -->
|
|
<div ref="sentinel" class="py-2 text-center">
|
|
<div v-if="loadingMore" class="spinner-border spinner-border-sm text-muted" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-backdrop show" style="z-index:-1" @click="$emit('update:modelValue', false)" />
|
|
</div>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, watch, nextTick } from 'vue'
|
|
import axios from 'axios'
|
|
|
|
const props = defineProps({ modelValue: Boolean, productName: { type: String, default: '' } })
|
|
const emit = defineEmits(['update:modelValue', 'photo-selected'])
|
|
|
|
const photos = ref([])
|
|
const query = ref('')
|
|
const page = ref(1)
|
|
const hasMore = ref(false)
|
|
const loading = ref(false)
|
|
const loadingMore = ref(false)
|
|
const selecting = ref(null)
|
|
const error = ref(null)
|
|
const sentinel = ref(null)
|
|
let observer = null
|
|
let debounceTimer = null
|
|
|
|
watch(() => props.modelValue, async (val) => {
|
|
if (val) { query.value = props.productName; await resetAndSearch() }
|
|
else { photos.value = []; page.value = 1; observer?.disconnect() }
|
|
})
|
|
|
|
watch(query, () => {
|
|
clearTimeout(debounceTimer)
|
|
debounceTimer = setTimeout(() => resetAndSearch(), 400)
|
|
})
|
|
|
|
async function resetAndSearch() {
|
|
observer?.disconnect()
|
|
photos.value = []; page.value = 1; error.value = null
|
|
// Don't fire a request with an empty query — the API rejects it (422).
|
|
if (!query.value.trim()) { loading.value = false; return }
|
|
loading.value = true
|
|
await fetchPage(1)
|
|
loading.value = false
|
|
await nextTick()
|
|
setupObserver()
|
|
}
|
|
|
|
async function fetchPage(p) {
|
|
try {
|
|
const res = await axios.get('/api/products/photo-search', { params: { q: query.value.trim(), page: p } })
|
|
if (res.data.success) {
|
|
photos.value.push(...res.data.photos)
|
|
hasMore.value = res.data.has_more
|
|
page.value = p
|
|
} else {
|
|
error.value = res.data.error || 'Could not load photos. Try again.'
|
|
}
|
|
} catch (e) {
|
|
error.value = e?.response?.data?.error || 'Could not load photos. Try again.'
|
|
}
|
|
}
|
|
|
|
async function loadMore() {
|
|
if (!hasMore.value || loadingMore.value) return
|
|
loadingMore.value = true
|
|
await fetchPage(page.value + 1)
|
|
loadingMore.value = false
|
|
await nextTick()
|
|
setupObserver()
|
|
}
|
|
|
|
function setupObserver() {
|
|
observer?.disconnect()
|
|
if (!sentinel.value) return
|
|
observer = new IntersectionObserver(([e]) => { if (e.isIntersecting) loadMore() }, { threshold: 0.1 })
|
|
observer.observe(sentinel.value)
|
|
}
|
|
|
|
async function selectPhoto(photo) {
|
|
if (selecting.value) return
|
|
selecting.value = photo.id
|
|
error.value = null
|
|
try {
|
|
const res = await axios.post('/api/products/photo-download', { src: photo.src })
|
|
if (res.data.success) {
|
|
emit('photo-selected', { hashkey: res.data.hashkey, url: res.data.url })
|
|
emit('update:modelValue', false)
|
|
} else {
|
|
error.value = 'Failed to save photo. Try another.'
|
|
}
|
|
} catch {
|
|
error.value = 'Failed to save photo. Try another.'
|
|
} finally {
|
|
selecting.value = null
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
@keyframes pulse { 0%,100%{opacity:.6} 50%{opacity:.3} }
|
|
</style>
|