246 lines
10 KiB
Vue
246 lines
10 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 Movie = {
|
|
id: number
|
|
title: string
|
|
year: number | null
|
|
rating: string | null
|
|
description: string | null
|
|
poster_url: string | null
|
|
genres?: { id: number; name: string }[]
|
|
}
|
|
|
|
type Page = {
|
|
data: Movie[]
|
|
total: number
|
|
current_page: number
|
|
last_page: number
|
|
}
|
|
|
|
const q = ref('')
|
|
const sort = ref<'title_asc' | 'title_desc' | 'newest' | 'oldest' | 'year_asc' | 'year_desc'>('title_asc')
|
|
const genre = ref('')
|
|
const rating = ref('')
|
|
const yearMin = ref<string | number | ''>('')
|
|
const yearMax = ref<string | number | ''>('')
|
|
const actor = ref('')
|
|
const director = ref('')
|
|
const studio = ref('')
|
|
const page = ref(1)
|
|
const items = ref<Movie[]>([])
|
|
const total = ref(0)
|
|
const lastPage = ref(1)
|
|
const loading = ref(false)
|
|
const error = ref<string | null>(null)
|
|
|
|
let debounceTimer: any = null
|
|
|
|
const canLoadMore = computed(() => page.value < lastPage.value && !loading.value)
|
|
|
|
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(' ') + '…'
|
|
}
|
|
|
|
async function fetchPage(reset = false) {
|
|
if (loading.value) return
|
|
loading.value = true
|
|
error.value = null
|
|
try {
|
|
const params = new URLSearchParams()
|
|
if (q.value) params.set('q', q.value)
|
|
if (sort.value) params.set('sort', sort.value)
|
|
if (genre.value) params.set('genre', genre.value)
|
|
if (rating.value) params.set('rating', rating.value)
|
|
if (yearMin.value !== '') params.set('year_min', String(yearMin.value))
|
|
if (yearMax.value !== '') params.set('year_max', String(yearMax.value))
|
|
if (actor.value) params.set('actor', actor.value)
|
|
if (director.value) params.set('director', director.value)
|
|
if (studio.value) params.set('studio', studio.value)
|
|
params.set('page', String(page.value))
|
|
const res = await fetch(`/api/movies?${params.toString()}`, {
|
|
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
|
credentials: 'same-origin',
|
|
})
|
|
if (!res.ok) throw new Error(`Fetch failed: ${res.status}`)
|
|
const data: Page = await res.json()
|
|
total.value = data.total
|
|
lastPage.value = data.last_page
|
|
if (reset) items.value = data.data
|
|
else items.value = items.value.concat(data.data)
|
|
} catch (e: any) {
|
|
error.value = e?.message ?? 'Unexpected error'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function onSearchInput() {
|
|
clearTimeout(debounceTimer)
|
|
debounceTimer = setTimeout(() => {
|
|
page.value = 1
|
|
fetchPage(true)
|
|
}, 300)
|
|
}
|
|
|
|
function onControlsChange() {
|
|
const url = new URL(window.location.href)
|
|
q.value ? url.searchParams.set('q', q.value) : url.searchParams.delete('q')
|
|
sort.value ? url.searchParams.set('sort', sort.value) : url.searchParams.delete('sort')
|
|
genre.value ? url.searchParams.set('genre', genre.value) : url.searchParams.delete('genre')
|
|
rating.value ? url.searchParams.set('rating', rating.value) : url.searchParams.delete('rating')
|
|
yearMin.value !== '' ? url.searchParams.set('year_min', String(yearMin.value)) : url.searchParams.delete('year_min')
|
|
yearMax.value !== '' ? url.searchParams.set('year_max', String(yearMax.value)) : url.searchParams.delete('year_max')
|
|
actor.value ? url.searchParams.set('actor', actor.value) : url.searchParams.delete('actor')
|
|
director.value ? url.searchParams.set('director', director.value) : url.searchParams.delete('director')
|
|
studio.value ? url.searchParams.set('studio', studio.value) : url.searchParams.delete('studio')
|
|
window.history.replaceState({}, '', url.toString())
|
|
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(() => {
|
|
const url = new URL(window.location.href)
|
|
q.value = url.searchParams.get('q') ?? ''
|
|
sort.value = (url.searchParams.get('sort') as any) ?? 'title_asc'
|
|
genre.value = url.searchParams.get('genre') ?? ''
|
|
rating.value = url.searchParams.get('rating') ?? ''
|
|
yearMin.value = url.searchParams.get('year_min') ?? ''
|
|
yearMax.value = url.searchParams.get('year_max') ?? ''
|
|
actor.value = url.searchParams.get('actor') ?? ''
|
|
director.value = url.searchParams.get('director') ?? ''
|
|
studio.value = url.searchParams.get('studio') ?? ''
|
|
fetchPage(true)
|
|
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)
|
|
})
|
|
</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="grid grid-cols-1 items-end gap-2 md:grid-cols-12">
|
|
<div class="md:col-span-4">
|
|
<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 in your library…"
|
|
aria-label="Search movies in your library"
|
|
v-model="q"
|
|
@input="onSearchInput"
|
|
/>
|
|
</div>
|
|
<div class="md:col-span-3">
|
|
<select v-model="sort" @change="onControlsChange" class="w-full rounded-md border px-3 py-2 text-sm" aria-label="Sort movies">
|
|
<option value="title_asc">Title A → Z</option>
|
|
<option value="title_desc">Title Z → A</option>
|
|
<option value="newest">Newest</option>
|
|
<option value="oldest">Oldest</option>
|
|
<option value="year_asc">Year ↑</option>
|
|
<option value="year_desc">Year ↓</option>
|
|
</select>
|
|
</div>
|
|
<div class="md:col-span-2">
|
|
<input v-model="genre" @change="onControlsChange" class="w-full rounded-md border px-3 py-2 text-sm" type="text" placeholder="Genre" aria-label="Filter by genre" />
|
|
</div>
|
|
<div class="md:col-span-3">
|
|
<div class="flex gap-2">
|
|
<input v-model="yearMin" @change="onControlsChange" class="w-full rounded-md border px-3 py-2 text-sm" type="number" placeholder="Year min" aria-label="Minimum year" />
|
|
<input v-model="yearMax" @change="onControlsChange" class="w-full rounded-md border px-3 py-2 text-sm" type="number" placeholder="Year max" aria-label="Maximum year" />
|
|
</div>
|
|
</div>
|
|
<div class="md:col-span-2">
|
|
<input v-model="rating" @change="onControlsChange" class="w-full rounded-md border px-3 py-2 text-sm" type="text" placeholder="Rating" aria-label="Filter by rating" />
|
|
</div>
|
|
<div class="md:col-span-3">
|
|
<input v-model="actor" @change="onControlsChange" class="w-full rounded-md border px-3 py-2 text-sm" type="text" placeholder="Actor" aria-label="Filter by actor" />
|
|
</div>
|
|
<div class="md:col-span-3">
|
|
<input v-model="director" @change="onControlsChange" class="w-full rounded-md border px-3 py-2 text-sm" type="text" placeholder="Director" aria-label="Filter by director" />
|
|
</div>
|
|
<div class="md:col-span-3">
|
|
<input v-model="studio" @change="onControlsChange" class="w-full rounded-md border px-3 py-2 text-sm" type="text" placeholder="Studio" aria-label="Filter by studio" />
|
|
</div>
|
|
<div class="md:col-span-12 flex justify-end">
|
|
<a href="/admin/movies/add" class="shrink-0 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:opacity-90">Add</a>
|
|
</div>
|
|
</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" :aria-busy="loading ? 'true' : 'false'">
|
|
<li v-for="m in items" :key="m.id" class="py-1">
|
|
<a
|
|
:href="`/movies/${m.id}`"
|
|
class="flex gap-3 rounded px-2 py-2 hover:bg-muted/40 focus:bg-muted/40 focus:outline-none"
|
|
>
|
|
<div class="h-20 w-14 shrink-0 overflow-hidden rounded bg-muted">
|
|
<img
|
|
v-if="m.poster_url"
|
|
:src="m.poster_url"
|
|
:alt="`Poster for ${m.title}`"
|
|
loading="lazy"
|
|
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">{{ m.title }}</div>
|
|
<div class="text-xs text-muted-foreground">{{ m.year ?? '' }}</div>
|
|
</div>
|
|
<div class="mt-1 text-xs text-muted-foreground">
|
|
<span v-if="m.rating" class="mr-2 rounded border px-1 py-0.5">{{ m.rating }}</span>
|
|
<span v-if="m.genres?.length">{{ m.genres.map(g => g.name).join(', ') }}</span>
|
|
</div>
|
|
<div class="mt-2 text-sm text-foreground/90">
|
|
{{ snippet(m.description) }}
|
|
</div>
|
|
</div>
|
|
</a>
|
|
</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="!canLoadMore && items.length > 0">End of results</template>
|
|
<template v-else-if="!items.length && !loading">No results</template>
|
|
</div>
|
|
</div>
|
|
</AppLayout>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.fade-enter-active, .fade-leave-active { transition: opacity .2s }
|
|
.fade-enter-from, .fade-leave-to { opacity: 0 }
|
|
</style>
|