initial: bootstrap from BukidBountyApp base
This commit is contained in:
151
resources/js/Components/Core/StockPhotoPicker.vue
Normal file
151
resources/js/Components/Core/StockPhotoPicker.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user