Files
BarangaySystem/resources/js/Components/Core/StockPhotoPicker.vue
2026-06-06 18:43:00 +08:00

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>