296 lines
9.9 KiB
Vue
296 lines
9.9 KiB
Vue
|
|
<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 OMDb otherwise (unless Enter is pressed)
|
||
|
|
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>
|