Files
oysqn.app/app/pages/admin/boat.vue

502 lines
15 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<IonPage>
<IonHeader>
<IonToolbar color="primary">
<IonButtons slot="start">
<IonMenuButton />
</IonButtons>
<IonTitle>Manage Boats</IonTitle>
<IonButtons slot="end">
<IonButton @click="openCreate">
<IonIcon slot="icon-only" :icon="addOutline" />
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent class="ion-padding">
<div v-if="loading" class="ion-text-center ion-padding">
<IonSpinner name="crescent" />
</div>
<p v-else-if="boats.length === 0" class="empty-text">No boats yet. Add one to get started.</p>
<IonCard v-else v-for="boat in boats" :key="boat.id" class="boat-card">
<div class="boat-card-inner">
<div class="boat-thumb-wrap">
<img
v-if="boat.img_src"
:src="boatImage.thumbnail(boat.img_src)"
class="boat-thumb"
alt=""
/>
<div v-else class="boat-thumb-placeholder">
<IonIcon :icon="boatOutline" />
</div>
</div>
<div class="boat-info">
<div class="boat-name">{{ boat.display_name || boat.name }}</div>
<div class="boat-meta">
<span v-if="boat.class">{{ boat.class }}</span>
<span v-if="boat.year">{{ boat.year }}</span>
<span>{{ boat.max_passengers }} pax</span>
</div>
<div class="cert-chips" v-if="boat.required_certs.length">
<span v-for="c in boat.required_certs" :key="c" class="cert-chip">{{ c }}</span>
</div>
<IonBadge :color="boat.booking_available ? 'success' : 'medium'" class="avail-badge">
{{ boat.booking_available ? 'Available' : 'Out of service' }}
</IonBadge>
</div>
<div class="boat-actions">
<IonButton fill="clear" @click="openEdit(boat)">
<IonIcon slot="icon-only" :icon="pencilOutline" />
</IonButton>
<IonButton fill="clear" color="danger" @click="confirmDelete(boat)">
<IonIcon slot="icon-only" :icon="trashOutline" />
</IonButton>
</div>
</div>
</IonCard>
<!-- Create / Edit modal -->
<IonModal :is-open="showModal" @did-dismiss="closeModal">
<IonHeader>
<IonToolbar>
<IonTitle>{{ editing ? 'Edit Boat' : 'New Boat' }}</IonTitle>
<IonButtons slot="end">
<IonButton @click="closeModal">Cancel</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent class="ion-padding">
<!-- Image upload section -->
<div class="image-section">
<div class="image-preview-wrap" @click="triggerFileInput">
<img v-if="imagePreviewUrl" :src="imagePreviewUrl" class="image-preview" alt="Boat preview" />
<div v-else-if="form.img_src" class="image-preview-wrap">
<img :src="boatImage.medium(form.img_src)" class="image-preview" alt="Current boat image" />
</div>
<div v-else class="image-placeholder">
<IonIcon :icon="cameraOutline" class="camera-icon" />
<span>Tap to upload photo</span>
</div>
<div v-if="imagePreviewUrl || form.img_src" class="image-overlay">
<IonIcon :icon="cameraOutline" />
</div>
</div>
<input
ref="fileInputRef"
type="file"
accept="image/jpeg,image/png,image/webp"
class="hidden-file-input"
@change="onFileSelected"
/>
<p v-if="pendingFile" class="image-hint">{{ pendingFile.name }} will upload on save</p>
</div>
<IonList lines="full">
<IonItem>
<IonLabel position="stacked">Name <span class="required">*</span></IonLabel>
<IonInput v-model="form.name" placeholder="e.g. J27-1" />
</IonItem>
<IonItem>
<IonLabel position="stacked">Display Name</IonLabel>
<IonInput v-model="form.display_name" placeholder="e.g. Blue Heron" />
</IonItem>
<IonItem>
<IonLabel position="stacked">Class</IonLabel>
<IonInput v-model="form.class" placeholder="e.g. J/27" />
</IonItem>
<IonItem>
<IonLabel position="stacked">Year</IonLabel>
<IonInput v-model.number="form.year" type="number" placeholder="e.g. 1988" />
</IonItem>
<IonItem>
<IonLabel position="stacked">Max Passengers</IonLabel>
<IonInput v-model.number="form.max_passengers" type="number" :min="1" :max="20" />
</IonItem>
<IonItem>
<IonLabel>
<h3>Available for Booking</h3>
</IonLabel>
<IonToggle v-model="form.booking_available" slot="end" color="success" />
</IonItem>
</IonList>
<!-- Required certifications -->
<h4 class="section-title ion-margin-top">Required Certifications</h4>
<div class="cert-edit-area">
<div class="cert-chips-edit" v-if="form.required_certs.length">
<span v-for="(c, i) in form.required_certs" :key="i" class="cert-chip-edit">
{{ c }}
<button class="cert-remove" @click="removeCert(i)">×</button>
</span>
</div>
<p v-else class="empty-text-sm">No certifications required.</p>
<div class="cert-add-row">
<IonInput
v-model="newCert"
placeholder="Add cert code (e.g. j27)"
class="cert-input"
@keyup.enter="addCert"
/>
<IonButton @click="addCert" :disabled="!newCert.trim()" fill="outline">
<IonIcon slot="icon-only" :icon="addOutline" />
</IonButton>
</div>
</div>
<IonButton
expand="block"
class="ion-margin-top"
:disabled="!form.name.trim() || saving"
@click="saveBoat"
>
<IonSpinner v-if="saving" name="crescent" slot="start" />
{{ saving ? 'Saving…' : 'Save Boat' }}
</IonButton>
</IonContent>
</IonModal>
<IonAlert
:is-open="deleteAlert.show"
header="Delete Boat"
:message="`Delete '${deleteAlert.name}'? All slots and reservations for this boat will also be deleted.`"
:buttons="[
{ text: 'Cancel', role: 'cancel', handler: () => { deleteAlert.show = false } },
{ text: 'Delete', role: 'destructive', handler: deleteBoat },
]"
@did-dismiss="deleteAlert.show = false"
/>
<IonToast
v-model:is-open="toast.show"
:message="toast.message"
:color="toast.color"
:duration="2500"
position="bottom"
/>
</IonContent>
</IonPage>
</template>
<script setup lang="ts">
import {
IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonButtons,
IonMenuButton, IonButton, IonIcon, IonCard, IonBadge,
IonList, IonItem, IonLabel, IonInput, IonToggle, IonSpinner,
IonModal, IonAlert, IonToast, onIonViewWillEnter,
} from '@ionic/vue'
import { addOutline, pencilOutline, trashOutline, boatOutline, cameraOutline } from 'ionicons/icons'
import type { Database } from '~/types/supabase'
type Boat = Database['public']['Tables']['boats']['Row']
definePageMeta({ layout: false, middleware: ['auth'] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const supabase = useSupabaseClient() as any
const boatImage = useBoatImage()
const loading = ref(true)
const saving = ref(false)
const showModal = ref(false)
const editing = ref<Boat | null>(null)
const boats = ref<Boat[]>([])
const toast = reactive({ show: false, message: '', color: 'success' })
const deleteAlert = reactive({ show: false, id: '', name: '' })
const fileInputRef = ref<HTMLInputElement | null>(null)
const pendingFile = ref<File | null>(null)
const imagePreviewUrl = ref<string>('')
const newCert = ref('')
const form = reactive({
name: '',
display_name: '',
class: '',
year: null as number | null,
max_passengers: 6,
booking_available: true,
required_certs: [] as string[],
img_src: null as string | null,
})
const user = useSupabaseUser()
watch(user, (val) => { if (val) fetchBoats() }, { immediate: true })
onIonViewWillEnter(() => { if (user.value) fetchBoats() })
async function fetchBoats() {
loading.value = true
const { data } = await supabase.from('boats').select('*').order('name')
boats.value = data ?? []
loading.value = false
}
function openCreate() {
editing.value = null
resetForm()
showModal.value = true
}
function openEdit(boat: Boat) {
editing.value = boat
form.name = boat.name
form.display_name = boat.display_name ?? ''
form.class = boat.class ?? ''
form.year = boat.year ?? null
form.max_passengers = boat.max_passengers
form.booking_available = boat.booking_available
form.required_certs = [...boat.required_certs]
form.img_src = boat.img_src
pendingFile.value = null
imagePreviewUrl.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editing.value = null
resetForm()
}
function resetForm() {
form.name = ''
form.display_name = ''
form.class = ''
form.year = null
form.max_passengers = 6
form.booking_available = true
form.required_certs = []
form.img_src = null
pendingFile.value = null
if (imagePreviewUrl.value) URL.revokeObjectURL(imagePreviewUrl.value)
imagePreviewUrl.value = ''
newCert.value = ''
}
function triggerFileInput() {
fileInputRef.value?.click()
}
function onFileSelected(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) return
pendingFile.value = file
if (imagePreviewUrl.value) URL.revokeObjectURL(imagePreviewUrl.value)
imagePreviewUrl.value = URL.createObjectURL(file)
}
function addCert() {
const c = newCert.value.trim().toLowerCase()
if (c && !form.required_certs.includes(c)) {
form.required_certs.push(c)
}
newCert.value = ''
}
function removeCert(index: number) {
form.required_certs.splice(index, 1)
}
async function saveBoat() {
saving.value = true
const payload = {
name: form.name.trim(),
display_name: form.display_name.trim() || null,
class: form.class.trim() || null,
year: form.year || null,
max_passengers: form.max_passengers,
booking_available: form.booking_available,
required_certs: form.required_certs,
}
let boatId = editing.value?.id
let error
if (editing.value) {
;({ error } = await supabase.from('boats').update(payload).eq('id', boatId))
} else {
const { data, error: insertError } = await supabase
.from('boats').insert(payload).select('id').single()
error = insertError
boatId = data?.id
}
if (error) {
saving.value = false
showToast('Save failed: ' + error.message, 'danger')
return
}
// Upload image if a new file was selected
if (pendingFile.value && boatId) {
const ext = pendingFile.value.name.split('.').pop() ?? 'jpg'
const path = `boats/${boatId}/original.${ext}`
const { error: uploadError } = await supabase.storage
.from('boat-images')
.upload(path, pendingFile.value, { upsert: true, contentType: pendingFile.value.type })
if (uploadError) {
saving.value = false
showToast('Boat saved but image upload failed: ' + uploadError.message, 'warning')
await fetchBoats()
closeModal()
return
}
await supabase.from('boats').update({ img_src: path }).eq('id', boatId)
}
saving.value = false
closeModal()
await fetchBoats()
showToast(editing.value ? 'Boat updated' : 'Boat created', 'success')
}
function confirmDelete(boat: Boat) {
deleteAlert.id = boat.id
deleteAlert.name = boat.display_name || boat.name
deleteAlert.show = true
}
async function deleteBoat() {
deleteAlert.show = false
const { error } = await supabase.from('boats').delete().eq('id', deleteAlert.id)
if (error) { showToast('Delete failed: ' + error.message, 'danger'); return }
await fetchBoats()
showToast('Boat deleted', 'success')
}
function showToast(message: string, color: string) {
toast.message = message
toast.color = color
toast.show = true
}
</script>
<style scoped>
.boat-card { margin: 0 0 0.75rem; }
.boat-card-inner {
display: flex;
align-items: flex-start;
padding: 0.75rem;
gap: 0.75rem;
}
.boat-thumb-wrap { flex-shrink: 0; }
.boat-thumb {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: 0.5rem;
}
.boat-thumb-placeholder {
width: 72px;
height: 72px;
border-radius: 0.5rem;
background: var(--ion-color-light);
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
color: var(--ion-color-medium);
}
.boat-info { flex: 1; min-width: 0; }
.boat-name { font-weight: 600; font-size: 1rem; }
.boat-meta {
display: flex;
gap: 0.5rem;
font-size: 0.8rem;
color: var(--ion-color-medium);
margin: 0.2rem 0;
}
.cert-chips { display: flex; flex-wrap: wrap; gap: 0.3rem; margin: 0.3rem 0; }
.cert-chip {
font-size: 0.72rem;
padding: 0.15rem 0.5rem;
border-radius: 1rem;
background: var(--ion-color-primary-tint);
color: var(--ion-color-primary-shade);
font-weight: 500;
}
.avail-badge { font-size: 0.7rem; }
.boat-actions { display: flex; flex-direction: column; flex-shrink: 0; }
/* Modal image section */
.image-section { margin-bottom: 1rem; }
.image-preview-wrap {
position: relative;
width: 100%;
height: 200px;
border-radius: 0.75rem;
overflow: hidden;
background: var(--ion-color-light);
cursor: pointer;
}
.image-preview {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 0.5rem;
color: var(--ion-color-medium);
font-size: 0.9rem;
}
.camera-icon { font-size: 2.5rem; }
.image-overlay {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.35);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 2rem;
opacity: 0;
transition: opacity 0.2s;
}
.image-preview-wrap:hover .image-overlay { opacity: 1; }
.hidden-file-input { display: none; }
.image-hint { font-size: 0.78rem; color: var(--ion-color-medium); margin: 0.25rem 0 0; }
.required { color: var(--ion-color-danger); }
.section-title { font-size: 0.95rem; font-weight: 600; margin: 0 0 0.5rem; }
/* Cert editor */
.cert-edit-area { margin-bottom: 0.75rem; }
.cert-chips-edit { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.5rem; }
.cert-chip-edit {
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.6rem;
border-radius: 1rem;
background: var(--ion-color-primary-tint);
color: var(--ion-color-primary-shade);
font-size: 0.82rem;
font-weight: 500;
}
.cert-remove {
background: none;
border: none;
cursor: pointer;
padding: 0;
line-height: 1;
font-size: 1rem;
color: var(--ion-color-primary-shade);
}
.cert-add-row { display: flex; gap: 0.5rem; align-items: center; }
.cert-input { flex: 1; }
.empty-text { color: var(--ion-color-medium); text-align: center; padding: 2rem 0; }
.empty-text-sm { color: var(--ion-color-medium); font-size: 0.85rem; margin: 0.25rem 0; }
</style>