287 lines
12 KiB
Vue
287 lines
12 KiB
Vue
|
|
<script setup lang="ts">
|
||
|
|
import AppLayout from '@/layouts/AppLayout.vue'
|
||
|
|
import { Head, usePage } from '@inertiajs/vue3'
|
||
|
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||
|
|
|
||
|
|
type Rel = { id: number; name: string }
|
||
|
|
type Movie = {
|
||
|
|
id: number
|
||
|
|
title: string
|
||
|
|
original_title: string | null
|
||
|
|
description: string | null
|
||
|
|
poster_url: string | null
|
||
|
|
backdrop_url: string | null
|
||
|
|
rating: string | null
|
||
|
|
release_date: string | null
|
||
|
|
year: number | null
|
||
|
|
runtime: number | null
|
||
|
|
genres: Rel[]
|
||
|
|
actors: Rel[]
|
||
|
|
directors: Rel[]
|
||
|
|
studios: Rel[]
|
||
|
|
countries: Rel[]
|
||
|
|
languages: Rel[]
|
||
|
|
}
|
||
|
|
|
||
|
|
const page = usePage<{ props: { movieId: number } }>()
|
||
|
|
const movieId = computed(() => (page.props as any).movieId as number)
|
||
|
|
|
||
|
|
const loading = ref(false)
|
||
|
|
const saving = ref(false)
|
||
|
|
const error = ref<string | null>(null)
|
||
|
|
const validation = reactive<Record<string, string[]>>({})
|
||
|
|
const showToast = ref(false)
|
||
|
|
const toastText = ref('')
|
||
|
|
|
||
|
|
const m = ref<Movie | null>(null)
|
||
|
|
|
||
|
|
// Editable fields
|
||
|
|
const form = reactive({
|
||
|
|
title: '',
|
||
|
|
original_title: '',
|
||
|
|
description: '',
|
||
|
|
poster_url: '',
|
||
|
|
backdrop_url: '',
|
||
|
|
rating: '',
|
||
|
|
release_date: '',
|
||
|
|
year: '' as string | number | '',
|
||
|
|
runtime: '' as string | number | '',
|
||
|
|
// relations as comma-separated strings for free-text chips
|
||
|
|
genres: '' as string,
|
||
|
|
actors: '' as string,
|
||
|
|
directors: '' as string,
|
||
|
|
studios: '' as string,
|
||
|
|
countries: '' as string,
|
||
|
|
languages: '' as string,
|
||
|
|
})
|
||
|
|
|
||
|
|
function toNames(list?: Rel[]): string {
|
||
|
|
return (list ?? []).map(r => r.name).join(', ')
|
||
|
|
}
|
||
|
|
|
||
|
|
function parseNames(input: string): string[] {
|
||
|
|
return input
|
||
|
|
.split(',')
|
||
|
|
.map(s => s.trim())
|
||
|
|
.filter(Boolean)
|
||
|
|
}
|
||
|
|
|
||
|
|
async function load() {
|
||
|
|
loading.value = true
|
||
|
|
error.value = null
|
||
|
|
try {
|
||
|
|
const res = await fetch(`/api/movies/${movieId.value}`, {
|
||
|
|
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||
|
|
credentials: 'same-origin',
|
||
|
|
})
|
||
|
|
if (!res.ok) throw new Error(`Failed: ${res.status}`)
|
||
|
|
const data: Movie = await res.json()
|
||
|
|
m.value = data
|
||
|
|
// hydrate form
|
||
|
|
form.title = data.title ?? ''
|
||
|
|
form.original_title = data.original_title ?? ''
|
||
|
|
form.description = data.description ?? ''
|
||
|
|
form.poster_url = data.poster_url ?? ''
|
||
|
|
form.backdrop_url = data.backdrop_url ?? ''
|
||
|
|
form.rating = data.rating ?? ''
|
||
|
|
form.release_date = data.release_date ?? ''
|
||
|
|
form.year = data.year ?? ''
|
||
|
|
form.runtime = data.runtime ?? ''
|
||
|
|
form.genres = toNames(data.genres)
|
||
|
|
form.actors = toNames(data.actors)
|
||
|
|
form.directors = toNames(data.directors)
|
||
|
|
form.studios = toNames(data.studios)
|
||
|
|
form.countries = toNames(data.countries)
|
||
|
|
form.languages = toNames(data.languages)
|
||
|
|
} catch (e: any) {
|
||
|
|
error.value = e?.message ?? 'Unexpected error'
|
||
|
|
} finally {
|
||
|
|
loading.value = false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function setToast(text: string) {
|
||
|
|
toastText.value = text
|
||
|
|
showToast.value = true
|
||
|
|
setTimeout(() => (showToast.value = false), 2500)
|
||
|
|
}
|
||
|
|
|
||
|
|
async function onSave() {
|
||
|
|
if (!m.value) return
|
||
|
|
saving.value = true
|
||
|
|
error.value = null
|
||
|
|
Object.keys(validation).forEach(k => delete validation[k])
|
||
|
|
try {
|
||
|
|
const payload: Record<string, any> = {
|
||
|
|
title: form.title,
|
||
|
|
original_title: form.original_title || null,
|
||
|
|
description: form.description || null,
|
||
|
|
poster_url: form.poster_url || null,
|
||
|
|
backdrop_url: form.backdrop_url || null,
|
||
|
|
rating: form.rating || null,
|
||
|
|
release_date: form.release_date || null,
|
||
|
|
year: form.year === '' ? null : Number(form.year),
|
||
|
|
runtime: form.runtime === '' ? null : Number(form.runtime),
|
||
|
|
genres: parseNames(form.genres),
|
||
|
|
actors: parseNames(form.actors),
|
||
|
|
directors: parseNames(form.directors),
|
||
|
|
studios: parseNames(form.studios),
|
||
|
|
countries: parseNames(form.countries),
|
||
|
|
languages: parseNames(form.languages),
|
||
|
|
}
|
||
|
|
|
||
|
|
const res = await fetch(`/admin/movies/${m.value.id}`, {
|
||
|
|
method: 'PATCH',
|
||
|
|
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(payload),
|
||
|
|
credentials: 'same-origin',
|
||
|
|
})
|
||
|
|
|
||
|
|
if (res.status === 422) {
|
||
|
|
const j = await res.json()
|
||
|
|
Object.assign(validation, j.errors || {})
|
||
|
|
throw new Error('Validation failed')
|
||
|
|
}
|
||
|
|
if (!res.ok) throw new Error(`Save failed: ${res.status}`)
|
||
|
|
setToast('Saved')
|
||
|
|
// refresh local data
|
||
|
|
await load()
|
||
|
|
} catch (e: any) {
|
||
|
|
if (!('errors' in validation)) {
|
||
|
|
setToast(e?.message ?? 'Save failed')
|
||
|
|
}
|
||
|
|
} finally {
|
||
|
|
saving.value = false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function onCancel() {
|
||
|
|
// Navigate back to the public Movie Details page without saving changes
|
||
|
|
const id = movieId.value
|
||
|
|
window.location.href = `/movies/${id}`
|
||
|
|
}
|
||
|
|
|
||
|
|
onMounted(load)
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<template>
|
||
|
|
<Head :title="m?.title ? `Edit · ${m.title}` : 'Edit Movie'" />
|
||
|
|
<AppLayout :breadcrumbs="[{ title: 'Dashboard', href: '/dashboard' }, { title: 'Movies', href: '/admin/movies' }, { title: m?.title ?? 'Edit', href: `/admin/movies/${movieId}/edit` }]">
|
||
|
|
<div class="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4">
|
||
|
|
<div v-if="loading" class="text-sm text-muted-foreground">Loading…</div>
|
||
|
|
<div v-else-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>
|
||
|
|
<form v-else @submit.prevent="onSave" class="space-y-4">
|
||
|
|
<div class="grid gap-4 md:grid-cols-2">
|
||
|
|
<div>
|
||
|
|
<label class="block text-xs font-medium">Title</label>
|
||
|
|
<input v-model="form.title" class="mt-1 w-full rounded border px-3 py-2 text-sm" type="text" required />
|
||
|
|
<p v-if="validation.title" class="mt-1 text-xs text-red-600">{{ validation.title.join(', ') }}</p>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<label class="block text-xs font-medium">Original Title</label>
|
||
|
|
<input v-model="form.original_title" class="mt-1 w-full rounded border px-3 py-2 text-sm" type="text" />
|
||
|
|
<p v-if="validation.original_title" class="mt-1 text-xs text-red-600">{{ validation.original_title.join(', ') }}</p>
|
||
|
|
</div>
|
||
|
|
<div class="md:col-span-2">
|
||
|
|
<label class="block text-xs font-medium">Description</label>
|
||
|
|
<textarea v-model="form.description" class="mt-1 w-full rounded border px-3 py-2 text-sm" rows="5" />
|
||
|
|
<p v-if="validation.description" class="mt-1 text-xs text-red-600">{{ validation.description.join(', ') }}</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<label class="block text-xs font-medium">Poster URL</label>
|
||
|
|
<input v-model="form.poster_url" class="mt-1 w-full rounded border px-3 py-2 text-sm" type="url" />
|
||
|
|
<p v-if="validation.poster_url" class="mt-1 text-xs text-red-600">{{ validation.poster_url.join(', ') }}</p>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<label class="block text-xs font-medium">Backdrop URL</label>
|
||
|
|
<input v-model="form.backdrop_url" class="mt-1 w-full rounded border px-3 py-2 text-sm" type="url" />
|
||
|
|
<p v-if="validation.backdrop_url" class="mt-1 text-xs text-red-600">{{ validation.backdrop_url.join(', ') }}</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<label class="block text-xs font-medium">Rating</label>
|
||
|
|
<input v-model="form.rating" class="mt-1 w-full rounded border px-3 py-2 text-sm" type="text" />
|
||
|
|
<p v-if="validation.rating" class="mt-1 text-xs text-red-600">{{ validation.rating.join(', ') }}</p>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<label class="block text-xs font-medium">Release Date (YYYY-MM-DD)</label>
|
||
|
|
<input v-model="form.release_date" class="mt-1 w-full rounded border px-3 py-2 text-sm" type="date" />
|
||
|
|
<p v-if="validation.release_date" class="mt-1 text-xs text-red-600">{{ validation.release_date.join(', ') }}</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<label class="block text-xs font-medium">Year</label>
|
||
|
|
<input v-model="form.year" class="mt-1 w-full rounded border px-3 py-2 text-sm" type="number" min="1800" max="3000" />
|
||
|
|
<p v-if="validation.year" class="mt-1 text-xs text-red-600">{{ validation.year.join(', ') }}</p>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<label class="block text-xs font-medium">Runtime (minutes)</label>
|
||
|
|
<input v-model="form.runtime" class="mt-1 w-full rounded border px-3 py-2 text-sm" type="number" min="1" max="10000" />
|
||
|
|
<p v-if="validation.runtime" class="mt-1 text-xs text-red-600">{{ validation.runtime.join(', ') }}</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="grid gap-4 md:grid-cols-2">
|
||
|
|
<div>
|
||
|
|
<label class="block text-xs font-medium">Genres (comma separated)</label>
|
||
|
|
<input v-model="form.genres" class="mt-1 w-full rounded border px-3 py-2 text-sm" type="text" />
|
||
|
|
<p v-if="validation.genres" class="mt-1 text-xs text-red-600">{{ validation.genres.join(', ') }}</p>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<label class="block text-xs font-medium">Actors (comma separated)</label>
|
||
|
|
<input v-model="form.actors" class="mt-1 w-full rounded border px-3 py-2 text-sm" type="text" />
|
||
|
|
<p v-if="validation.actors" class="mt-1 text-xs text-red-600">{{ validation.actors.join(', ') }}</p>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<label class="block text-xs font-medium">Directors (comma separated)</label>
|
||
|
|
<input v-model="form.directors" class="mt-1 w-full rounded border px-3 py-2 text-sm" type="text" />
|
||
|
|
<p v-if="validation.directors" class="mt-1 text-xs text-red-600">{{ validation.directors.join(', ') }}</p>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<label class="block text-xs font-medium">Studios (comma separated)</label>
|
||
|
|
<input v-model="form.studios" class="mt-1 w-full rounded border px-3 py-2 text-sm" type="text" />
|
||
|
|
<p v-if="validation.studios" class="mt-1 text-xs text-red-600">{{ validation.studios.join(', ') }}</p>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<label class="block text-xs font-medium">Countries (comma separated)</label>
|
||
|
|
<input v-model="form.countries" class="mt-1 w-full rounded border px-3 py-2 text-sm" type="text" />
|
||
|
|
<p v-if="validation.countries" class="mt-1 text-xs text-red-600">{{ validation.countries.join(', ') }}</p>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<label class="block text-xs font-medium">Languages (comma separated)</label>
|
||
|
|
<input v-model="form.languages" class="mt-1 w-full rounded border px-3 py-2 text-sm" type="text" />
|
||
|
|
<p v-if="validation.languages" class="mt-1 text-xs text-red-600">{{ validation.languages.join(', ') }}</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="flex items-center gap-2">
|
||
|
|
<button type="submit" :disabled="saving" class="rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-60">
|
||
|
|
<span v-if="saving">Saving…</span>
|
||
|
|
<span v-else>Save</span>
|
||
|
|
</button>
|
||
|
|
<button type="button" @click="onCancel" class="rounded-md border px-3 py-2 text-sm hover:bg-muted">
|
||
|
|
Cancel
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
|
||
|
|
<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>
|
||
|
|
</AppLayout>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<style scoped>
|
||
|
|
.fade-enter-active, .fade-leave-active { transition: opacity .2s }
|
||
|
|
.fade-enter-from, .fade-leave-to { opacity: 0 }
|
||
|
|
</style>
|