PIMS/resources/js/pages/admin/movies/Edit.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>