PIMS/resources/js/pages/admin/movies/Index.vue

296 lines
9.9 KiB
Vue
Raw Normal View History

2025-12-07 03:49:26 +00:00
<script setup lang="ts">
import AppLayout from '@/layouts/AppLayout.vue'
import { Head } from '@inertiajs/vue3'
import { ref, computed, onMounted, watch } from 'vue'
type SearchItem = {
provider: string
provider_id: string
title: string | null
year: string | null
poster_url: string | null
}
type SearchResponse = {
results: SearchItem[]
total: number
page: number
has_more: boolean
}
const q = ref('')
const page = ref(1)
const items = ref<SearchItem[]>([])
const total = ref(0)
const hasMore = ref(false)
const loading = ref(false)
const error = ref<string | null>(null)
// Toasts
const showToast = ref(false)
const toastText = ref('')
// Track which provider IDs are currently being accepted to disable buttons
const accepting = ref<Set<string>>(new Set())
// Duplicate modal state
const showDuplicateModal = ref(false)
const duplicateTitle = ref('')
const pendingProviderId = ref('')
let debounceTimer: any = null
const canLoadMore = computed(() => hasMore.value && !loading.value)
async function fetchPage(reset = false) {
if (loading.value) return
loading.value = true
error.value = null
try {
const params = new URLSearchParams()
params.set('q', q.value)
params.set('page', String(page.value))
const res = await fetch(`/admin/movies/search?${params.toString()}`, {
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
credentials: 'same-origin',
})
if (!res.ok) throw new Error(`Search failed: ${res.status}`)
const data: SearchResponse = await res.json()
total.value = data.total
hasMore.value = data.has_more
if (reset) {
items.value = data.results
} else {
items.value = items.value.concat(data.results)
}
} catch (e: any) {
error.value = e?.message ?? 'Unexpected error'
} finally {
loading.value = false
}
}
function onSearchInput() {
// Only trigger search when length is divisible by 3 (3, 6, 9, ...)
// Per request, we do not hit TMDb otherwise (unless Enter is pressed)
2025-12-07 03:49:26 +00:00
clearTimeout(debounceTimer)
const len = q.value.trim().length
if (len >= 3 && len % 3 === 0) {
debounceTimer = setTimeout(() => {
page.value = 1
fetchPage(true)
}, 300)
}
}
function onSearchEnter() {
// Immediate fetch when user presses Enter regardless of length
clearTimeout(debounceTimer)
page.value = 1
fetchPage(true)
}
async function loadMore() {
if (!canLoadMore.value) return
page.value += 1
await fetchPage(false)
}
// Infinite scroll via IntersectionObserver
const sentinel = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | null = null
onMounted(() => {
// Do not auto-fetch on mount; wait for divisible length or Enter key
observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
loadMore()
}
}
}, { root: null, rootMargin: '200px', threshold: 0 })
if (sentinel.value) observer.observe(sentinel.value)
})
watch(sentinel, (el, prev) => {
if (prev && observer) observer.unobserve(prev)
if (el && observer) observer.observe(el)
})
// Accept flow
async function onAccept(item: SearchItem) {
try {
if (accepting.value.has(item.provider_id)) return
// Check duplicate
const p = new URLSearchParams({ provider_id: item.provider_id })
const existsRes = await fetch(`/admin/movies/exists?${p.toString()}`, {
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
credentials: 'same-origin',
})
if (!existsRes.ok) throw new Error('Duplicate check failed')
const existsData = await existsRes.json()
if (existsData.exists) {
duplicateTitle.value = item.title ?? 'This movie'
pendingProviderId.value = item.provider_id
showDuplicateModal.value = true
return
}
await acceptWithMode(item.provider_id, 'overwrite')
} catch (e) {
console.error(e)
toastText.value = e instanceof Error ? e.message : 'Something went wrong'
showToast.value = true
setTimeout(() => (showToast.value = false), 2500)
}
}
async function acceptWithMode(providerId: string, mode: 'overwrite' | 'duplicate') {
try {
accepting.value.add(providerId)
const res = await fetch('/admin/movies/accept', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content ?? '',
},
body: JSON.stringify({ provider_id: providerId, mode }),
credentials: 'same-origin',
})
if (!res.ok) throw new Error('Accept failed')
// Optionally refresh first page to reflect addition in local DB-backed UIs later
// For now, we leave the search results as-is; could show a success message
try {
const data = await res.json()
// Mark accepted in current list if present
const idx = items.value.findIndex(i => i.provider_id === providerId)
if (idx !== -1) {
// attach a transient flag
;(items.value as any)[idx]._accepted = true
}
toastText.value = mode === 'duplicate' ? 'Saved as duplicate' : 'Accepted (overwrote if existed)'
showToast.value = true
setTimeout(() => (showToast.value = false), 2000)
} catch (_) {}
} catch (e) {
console.error(e)
toastText.value = e instanceof Error ? e.message : 'Accept failed'
showToast.value = true
setTimeout(() => (showToast.value = false), 2500)
throw e
} finally {
accepting.value.delete(providerId)
}
}
function onOverwrite() {
const pid = pendingProviderId.value
showDuplicateModal.value = false
if (pid) acceptWithMode(pid, 'overwrite')
}
function onDuplicate() {
const pid = pendingProviderId.value
showDuplicateModal.value = false
if (pid) acceptWithMode(pid, 'duplicate')
}
function onCancel() {
showDuplicateModal.value = false
}
function snippet(text: string | null, wordLimit = 50) {
if (!text) return ''
const words = text.split(/\s+/)
if (words.length <= wordLimit) return text
return words.slice(0, wordLimit).join(' ') + '…'
}
</script>
<template>
<Head title="Movies" />
<AppLayout :breadcrumbs="[{ title: 'Dashboard', href: '/dashboard' }, { title: 'Movies', href: '/admin/movies' }]">
<div class="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4">
<div class="flex items-center gap-2">
<input
class="w-full rounded-md border border-gray-300 bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary"
type="text"
placeholder="Search movies by title…"
v-model="q"
@input="onSearchInput"
@keyup.enter="onSearchEnter"
/>
</div>
<div v-if="error" class="rounded-md border border-red-300 bg-red-50 p-3 text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-200">
{{ error }}
</div>
<ul class="divide-y divide-muted-foreground/20">
<li v-for="item in items" :key="item.provider_id" class="flex gap-3 py-3">
<div class="h-20 w-14 shrink-0 overflow-hidden rounded bg-muted">
<img v-if="item.poster_url" :src="item.poster_url" alt="Poster" class="h-full w-full object-cover" />
</div>
<div class="flex min-w-0 flex-1 flex-col">
<div class="flex items-center justify-between gap-2">
<div class="truncate font-medium">{{ item.title }}</div>
<div class="text-xs text-muted-foreground">{{ item.year }}</div>
</div>
<!-- We intentionally limit to fields available from OMDb search per your instruction -->
<div class="mt-2">
<template v-if="(item as any)._accepted">
<span class="inline-flex items-center rounded-md border px-3 py-1.5 text-xs">Accepted</span>
</template>
<template v-else>
<button
class="inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:opacity-90"
:disabled="accepting.has(item.provider_id)"
:aria-busy="accepting.has(item.provider_id) ? 'true' : 'false'"
@click="onAccept(item)"
>
<span v-if="accepting.has(item.provider_id)">Working</span>
<span v-else>Accept</span>
</button>
</template>
</div>
</div>
</li>
</ul>
<div ref="sentinel" class="py-6 text-center text-sm text-muted-foreground">
<template v-if="loading">Loading</template>
<template v-else-if="!hasMore && items.length > 0">End of results</template>
<template v-else-if="!items.length && !loading">No results</template>
</div>
<transition name="fade">
<div
v-if="showToast"
class="fixed bottom-4 left-1/2 z-50 -translate-x-1/2 rounded bg-foreground px-3 py-2 text-sm text-background shadow-lg"
>
{{ toastText }}
</div>
</transition>
</div>
<!-- Duplicate modal -->
<div v-if="showDuplicateModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="w-full max-w-md rounded-lg bg-background p-5 shadow-lg">
<h3 class="text-base font-semibold">{{ duplicateTitle }} is already in our list. How should we proceed?</h3>
<div class="mt-4 flex justify-end gap-2">
<button class="rounded-md border px-3 py-1.5 text-sm" @click="onCancel">Cancel</button>
<button class="rounded-md border px-3 py-1.5 text-sm" @click="onDuplicate">Save as Duplicate</button>
<button class="rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground" @click="onOverwrite">Overwrite</button>
</div>
</div>
</div>
</AppLayout>
</template>
<style scoped>
.fade-enter-active, .fade-leave-active { transition: opacity .2s }
.fade-enter-from, .fade-leave-to { opacity: 0 }
</style>