502 lines
15 KiB
Vue
502 lines
15 KiB
Vue
<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>
|