refactor: everything to nuxt.js

This commit is contained in:
2026-03-19 14:30:36 -04:00
parent 6e1f58cd8e
commit bb3042014e
159 changed files with 6786 additions and 11198 deletions

5
app/app.vue Normal file
View File

@@ -0,0 +1,5 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>

BIN
app/assets/OYS-Burgee.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
app/assets/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
app/assets/oysqn_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 356 360">
<path
d="M43.4 303.4c0 3.8-2.3 6.3-7.1 6.3h-15v-22h14.4c4.3 0 6.2 2.2 6.2 5.2 0 2.6-1.5 4.4-3.4 5 2.8.4 4.9 2.5 4.9 5.5zm-8-13H24.1v6.9H35c2.1 0 4-1.3 4-3.8 0-2.2-1.3-3.1-3.7-3.1zm5.1 12.6c0-2.3-1.8-3.7-4-3.7H24.2v7.7h11.7c3.4 0 4.6-1.8 4.6-4zm36.3 4v2.7H56v-22h20.6v2.7H58.9v6.8h14.6v2.3H58.9v7.5h17.9zm23-5.8v8.5H97v-8.5l-11-13.4h3.4l8.9 11 8.8-11h3.4l-10.8 13.4zm19.1-1.8V298c0-7.9 5.2-10.7 12.7-10.7 7.5 0 13 2.8 13 10.7v1.4c0 7.9-5.5 10.8-13 10.8s-12.7-3-12.7-10.8zm22.7 0V298c0-5.7-3.9-8-10-8-6 0-9.8 2.3-9.8 8v1.4c0 5.8 3.8 8.1 9.8 8.1 6 0 10-2.3 10-8.1zm37.2-11.6v21.9h-2.9l-15.8-17.9v17.9h-2.8v-22h3l15.6 18v-18h2.9zm37.9 10.2v1.3c0 7.8-5.2 10.4-12.4 10.4H193v-22h11.2c7.2 0 12.4 2.8 12.4 10.3zm-3 0c0-5.3-3.3-7.6-9.4-7.6h-8.4V307h8.4c6 0 9.5-2 9.5-7.7V298zm50.8-7.6h-9.7v19.3h-3v-19.3h-9.7v-2.6h22.4v2.6zm34.4-2.6v21.9h-3v-10.1h-16.8v10h-2.8v-21.8h2.8v9.2H296v-9.2h2.9zm34.9 19.2v2.7h-20.7v-22h20.6v2.7H316v6.8h14.5v2.3H316v7.5h17.8zM24 340.2v7.3h13.9v2.4h-14v9.6H21v-22h20v2.7H24zm41.5 11.4h-9.8v7.9H53v-22h13.3c5.1 0 8 1.9 8 6.8 0 3.7-2 6.3-5.6 7l6 8.2h-3.3l-5.8-8zm-9.8-2.6H66c3.1 0 5.3-1.5 5.3-4.7 0-3.3-2.2-4.1-5.3-4.1H55.7v8.8zm47.9 6.2H89l-2 4.3h-3.2l10.7-22.2H98l10.7 22.2h-3.2l-2-4.3zm-1-2.3l-6.3-13-6 13h12.2zm46.3-15.3v21.9H146v-17.2L135.7 358h-2.1l-10.2-15.6v17h-2.8v-21.8h3l11 16.9 11.3-17h3zm35 19.3v2.6h-20.7v-22h20.6v2.7H166v6.8h14.5v2.3H166v7.6h17.8zm47-19.3l-8.3 22h-3l-7.1-18.6-7 18.6h-3l-8.2-22h3.3L204 356l6.8-18.5h3.4L221 356l6.6-18.5h3.3zm10 11.6v-1.4c0-7.8 5.2-10.7 12.7-10.7 7.6 0 13 2.9 13 10.7v1.4c0 7.9-5.4 10.8-13 10.8-7.5 0-12.7-3-12.7-10.8zm22.8 0v-1.4c0-5.7-4-8-10-8s-9.9 2.3-9.9 8v1.4c0 5.8 3.8 8.2 9.8 8.2 6.1 0 10-2.4 10-8.2zm28.3 2.4h-9.8v7.9h-2.8v-22h13.2c5.2 0 8 1.9 8 6.8 0 3.7-2 6.3-5.6 7l6 8.2h-3.3l-5.8-8zm-9.8-2.6h10.2c3 0 5.2-1.5 5.2-4.7 0-3.3-2.1-4.1-5.2-4.1h-10.2v8.8zm40.3-1.5l-6.8 5.6v6.4h-2.9v-22h2.9v12.3l15.2-12.2h3.7l-9.9 8.1 10.3 13.8h-3.6l-8.9-12z" />
<path fill="#050A14"
d="M188.4 71.7a10.4 10.4 0 01-20.8 0 10.4 10.4 0 1120.8 0zM224.2 45c-2.2-3.9-5-7.5-8.2-10.7l-12 7c-3.7-3.2-8-5.7-12.6-7.3a49.4 49.4 0 00-9.7 13.9 59 59 0 0140.1 14l7.6-4.4a57 57 0 00-5.2-12.5zM178 125.1c4.5 0 9-.6 13.4-1.7v-14a40 40 0 0012.5-7.2 47.7 47.7 0 00-7.1-15.3 59 59 0 01-32.2 27.7v8.7c4.4 1.2 8.9 1.8 13.4 1.8zM131.8 45c-2.3 4-4 8.1-5.2 12.5l12 7a40 40 0 000 14.4c5.7 1.5 11.3 2 16.9 1.5a59 59 0 01-8-41.7l-7.5-4.3c-3.2 3.2-6 6.7-8.2 10.6z" />
<path fill="#00B4FF"
d="M224.2 98.4c2.3-3.9 4-8 5.2-12.4l-12-7a40 40 0 000-14.5c-5.7-1.5-11.3-2-16.9-1.5a59 59 0 018 41.7l7.5 4.4c3.2-3.2 6-6.8 8.2-10.7zm-92.4 0c2.2 4 5 7.5 8.2 10.7l12-7a40 40 0 0012.6 7.3c4-4.1 7.3-8.8 9.7-13.8a59 59 0 01-40-14l-7.7 4.4c1.2 4.3 3 8.5 5.2 12.4zm46.2-80c-4.5 0-9 .5-13.4 1.7V34a40 40 0 00-12.5 7.2c1.5 5.7 4 10.8 7.1 15.4a59 59 0 0132.2-27.7V20a53.3 53.3 0 00-13.4-1.8z" />
<path fill="#00B4FF"
d="M178 9.2a62.6 62.6 0 11-.1 125.2A62.6 62.6 0 01178 9.2m0-9.2a71.7 71.7 0 100 143.5A71.7 71.7 0 00178 0z" />
<path fill="#050A14"
d="M96.6 212v4.3c-9.2-.8-15.4-5.8-15.4-17.8V180h4.6v18.4c0 8.6 4 12.6 10.8 13.5zm16-31.9v18.4c0 8.9-4.3 12.8-10.9 13.5v4.4c9.2-.7 15.5-5.6 15.5-18v-18.3h-4.7zM62.2 199v-2.2c0-12.7-8.8-17.4-21-17.4-12.1 0-20.7 4.7-20.7 17.4v2.2c0 12.8 8.6 17.6 20.7 17.6 1.5 0 3-.1 4.4-.3l11.8 6.2 2-3.3-8.2-4-6.4-3.1a32 32 0 01-3.6.2c-9.8 0-16-3.9-16-13.3v-2.2c0-9.3 6.2-13.1 16-13.1 9.9 0 16.3 3.8 16.3 13.1v2.2c0 5.3-2.1 8.7-5.6 10.8l4.8 2.4c3.4-2.8 5.5-7 5.5-13.2zM168 215.6h5.1L156 179.7h-4.8l17 36zM143 205l7.4-15.7-2.4-5-15.1 31.4h5.1l3.3-7h18.3l-1.8-3.7H143zm133.7 10.7h5.2l-17.3-35.9h-4.8l17 36zm-25-10.7l7.4-15.7-2.4-5-15.1 31.4h5.1l3.3-7h18.3l-1.7-3.7h-14.8zm73.8-2.5c6-1.2 9-5.4 9-11.4 0-8-4.5-10.9-12.9-10.9h-21.4v35.5h4.6v-31.3h16.5c5 0 8.5 1.4 8.5 6.7 0 5.2-3.5 7.7-8.5 7.7h-11.4v4.1h10.7l9.3 12.8h5.5l-9.9-13.2zm-117.4 9.9c-9.7 0-14.7-2.5-18.6-6.3l-2.2 3.8c5.1 5 11 6.7 21 6.7 1.6 0 3.1-.1 4.6-.3l-1.9-4h-3zm18.4-7c0-6.4-4.7-8.6-13.8-9.4l-10.1-1c-6.7-.7-9.3-2.2-9.3-5.6 0-2.5 1.4-4 4.6-5l-1.8-3.8c-4.7 1.4-7.5 4.2-7.5 8.9 0 5.2 3.4 8.7 13 9.6l11.3 1.2c6.4.6 8.9 2 8.9 5.4 0 2.7-2.1 4.7-6 5.8l1.8 3.9c5.3-1.6 8.9-4.7 8.9-10zm-20.3-21.9c7.9 0 13.3 1.8 18.1 5.7l1.8-3.9a30 30 0 00-19.6-5.9c-2 0-4 .1-5.7.3l1.9 4 3.5-.2z" />
<path fill="#00B4FF"
d="M.5 251.9c29.6-.5 59.2-.8 88.8-1l88.7-.3 88.7.3 44.4.4 44.4.6-44.4.6-44.4.4-88.7.3-88.7-.3a7981 7981 0 01-88.8-1z" />
<path fill="none" d="M-565.2 324H-252v15.8h-313.2z" />
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -0,0 +1,227 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useAuthStore } from '~/stores/auth';
import { useBoatStore } from '~/stores/boat';
import type { Boat } from '~/utils/boat.types';
import type { Interval, Reservation } from '~/utils/schedule.types';
import BoatScheduleTableComponent from '~/components/scheduling/boat/BoatScheduleTableComponent.vue';
import { formatDate } from '~/utils/schedule';
import { useReservationStore } from '~/stores/reservation';
import { useQuasar } from 'quasar';
interface BookingForm {
$id?: string;
user?: string;
interval?: Interval | null;
reason?: string;
members?: string[];
guests?: string[];
comment?: string;
}
const reason_options = ['Open Sail', 'Private Sail', 'Racing', 'Other'];
const boatStore = useBoatStore();
const auth = useAuthStore();
const newForm = {
user: auth.currentUser?.$id,
interval: {} as Interval,
reason: 'Open Sail',
members: [],
guests: [],
comment: '',
};
const reservation = defineModel<Reservation>();
const reservationStore = useReservationStore();
const boatSelect = ref(false);
const bookingForm = ref<BookingForm>({ ...newForm });
const $q = useQuasar();
const $router = useRouter();
watch(reservation, (newReservation) => {
if (!newReservation) {
bookingForm.value = newForm;
} else {
bookingForm.value = {
...newReservation,
user: auth.currentUser?.$id,
interval: {
start: newReservation.start,
end: newReservation.end,
resource: newReservation.resource,
},
};
}
});
const updateInterval = (interval: Interval | null | undefined) => {
bookingForm.value.interval = interval;
boatSelect.value = false;
};
const bookingDuration = computed((): { hours: number; minutes: number } => {
if (bookingForm.value.interval?.start && bookingForm.value.interval?.end) {
const start = new Date(bookingForm.value.interval.start).getTime();
const end = new Date(bookingForm.value.interval.end).getTime();
const delta = Math.abs(end - start) / 1000;
const hours = Math.floor(delta / 3600) % 24;
const minutes = Math.floor(delta - hours * 3600) % 60;
return { hours, minutes };
}
return { hours: 0, minutes: 0 };
});
const bookingName = computed(() => auth.getUserNameById(bookingForm.value?.user));
const boat = computed((): Boat | null => {
const boatId = bookingForm.value.interval?.resource;
return boatStore.getBoatById(boatId);
});
const onDelete = () => {
reservationStore.deleteReservation(reservation.value?.$id);
$router.go(-1);
};
const onReset = () => {
bookingForm.value.interval = null;
bookingForm.value = reservation.value
? {
...reservation.value,
interval: {
start: reservation.value.start,
end: reservation.value.end,
resource: reservation.value.resource,
},
}
: { ...newForm };
};
const onSubmit = async () => {
const booking = bookingForm.value;
if (
!(
booking.interval &&
booking.interval.resource &&
booking.interval.start &&
booking.interval.end &&
auth.currentUser
)
) {
return false;
}
const newReservation = <Reservation>{
resource: booking.interval.resource,
start: booking.interval.start,
end: booking.interval.end,
user: auth.currentUser.$id,
status: 'confirmed',
reason: booking.reason,
comment: booking.comment,
$id: reservation.value?.$id,
};
const status = $q.notify({
color: 'secondary',
textColor: 'white',
message: 'Submitting Reservation',
spinner: true,
closeBtn: 'Dismiss',
position: 'top',
timeout: 0,
group: false,
});
try {
const r = await reservationStore.createOrUpdateReservation(newReservation);
status({
color: 'positive',
icon: 'cloud_done',
message: `Booking ${newReservation.$id ? 'updated' : 'created'}: ${
boatStore.getBoatById(r.resource)?.name
} at ${formatDate(r.start)}`,
spinner: false,
});
} catch (e) {
status({ color: 'negative', icon: 'error', spinner: false, message: 'Failed to book!' + e });
}
$router.go(-1);
};
</script>
<template>
<div class="q-pa-xs row q-gutter-xs">
<q-card flat class="col-lg-4 col-md-6 col-sm-8 col-xs-12">
<q-card-section>
<div class="text-h5 q-mt-none q-mb-xs">
{{ reservation ? 'Modify Booking' : 'New Booking' }}
</div>
<div class="text-caption text-grey-8">for: {{ bookingName }}</div>
</q-card-section>
<q-list class="q-px-xs">
<q-item class="q-pa-none" clickable @click="boatSelect = true">
<q-card v-if="boat" class="col-12">
<q-card-section>
<q-img :src="boat.imgSrc" :fit="'scale-down'">
<div class="row absolute-top">
<div class="col text-h7 text-left">{{ boat.name }}</div>
<div class="col text-right text-caption">{{ boat.class }}</div>
</div>
</q-img>
</q-card-section>
<q-separator />
<q-card-section horizontal>
<q-card-section class="col-9">
<q-list dense class="row">
<q-item class="q-ma-none col-12">
<q-item-section avatar>
<q-badge color="primary" label="Start" />
</q-item-section>
<q-item-section class="text-body2">
{{ formatDate(bookingForm.interval?.start) }}
</q-item-section>
</q-item>
<q-item class="q-ma-none col-12">
<q-item-section avatar>
<q-badge color="primary" label="End" />
</q-item-section>
<q-item-section class="text-body2" style="min-width: 150px">
{{ formatDate(bookingForm.interval?.end) }}
</q-item-section>
</q-item>
</q-list>
</q-card-section>
<q-separator vertical />
<q-card-section class="col-3 flex flex-center bg-grey-4">
{{ bookingDuration.hours }} hours
<div v-if="bookingDuration.minutes">
<q-separator />
{{ bookingDuration.minutes }} mins
</div>
</q-card-section>
</q-card-section>
</q-card>
<div v-else class="col-12">
<q-field filled>Tap to Select a Boat / Time</q-field>
</div>
</q-item>
<q-item class="q-px-none">
<q-item-section>
<q-select filled v-model="bookingForm.reason" :options="reason_options" label="Reason for sail" />
</q-item-section>
</q-item>
<q-item class="q-px-none">
<q-item-section>
<q-input v-model="bookingForm.comment" clearable autogrow filled label="Additional Comments (optional)" />
</q-item-section>
</q-item>
</q-list>
<q-card-actions align="right">
<q-btn label="Delete" color="negative" size="lg" v-if="reservation?.$id" @click="onDelete" />
<q-btn label="Reset" @click="onReset" size="lg" color="secondary" />
<q-btn label="Submit" @click="onSubmit" size="lg" color="primary" />
</q-card-actions>
</q-card>
<q-dialog v-model="boatSelect" full-width>
<BoatScheduleTableComponent :model-value="bookingForm.interval" @update:model-value="updateInterval" />
</q-dialog>
</div>
</template>

View File

@@ -0,0 +1,8 @@
<template>
<q-tabs class="mobile-only">
<q-route-tab name="Boats" icon="sailing" to="/boat" />
<q-route-tab name="Schedule" icon="calendar_month" to="/schedule" />
</q-tabs>
</template>
<script setup lang="ts"></script>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
const certifications = [
{ title: 'J/27 Skipper', badgeText: 'J/27', description: 'Certified to be a skipper on a J/27 class boat.' },
{ title: 'Capri 25 Skipper', badgeText: 'Capri25', description: 'Certified to be a skipper on a Capri 25 class boat.' },
{ title: 'Night', badgeText: 'Night', description: 'Certified to operate boats at night' },
{ title: 'Navigation', badgeText: 'Nav', description: 'Advanced Navigation' },
{ title: 'Crew', badgeText: 'crew', description: 'Crew certification.' },
];
</script>
<template>
<div>Certification</div>
<q-item
v-for="cert in certifications"
:key="cert.title"
clickable
v-ripple
class="rounded-borders"
:class="$q.dark.isActive ? 'bg-grey-9 text-white' : 'bg-grey-2'">
<q-item-section avatar>
<q-avatar rounded>
<q-icon :name="`check`" />
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label>{{ cert.title }}</q-item-label>
<q-item-label caption>
<q-badge color="green-4" text-color="black">{{ cert.badgeText }}</q-badge>
</q-item-label>
</q-item-section>
<q-item-section>
<span>{{ cert.description }}</span>
</q-item-section>
</q-item>
</template>

View File

@@ -0,0 +1,78 @@
<script lang="ts" setup>
import { Dialog } from 'quasar';
import { useNavLinks } from '~/utils/navlinks';
import { useAuthStore } from '~/stores/auth';
import { APP_VERSION } from '~/utils/version';
defineProps(['drawer']);
defineEmits(['drawer-toggle']);
const { enabledLinks } = useNavLinks();
const authStore = useAuthStore();
function showAbout() {
Dialog.create({
title: 'OYS Borrow a Boat',
message: `Version ${APP_VERSION}<br>Manage a Borrow a Boat program for a Yacht Club.<br><br>© Oakville Yacht Squadron`,
html: true,
});
}
async function logout() {
await authStore.logout();
await navigateTo('/login');
}
</script>
<template>
<q-drawer
:model-value="drawer"
show-if-above
:width="200"
:breakpoint="1024"
@update:model-value="$emit('drawer-toggle')">
<q-scroll-area class="fit">
<q-list padding class="menu-list">
<template v-for="link in enabledLinks" :key="link.name">
<q-item clickable v-ripple :to="link.to">
<q-item-section avatar>
<q-icon :name="link.icon" />
</q-item-section>
<q-item-section>
<span :class="link.color ? `text-${link.color}` : ''">
{{ link.name }}
</span>
</q-item-section>
</q-item>
<q-list v-if="link.sublinks">
<div v-for="sublink in link.sublinks" :key="sublink.name">
<q-item clickable v-ripple :to="sublink.to" class="q-ml-md">
<q-item-section avatar>
<q-icon :name="sublink.icon" />
</q-item-section>
<q-item-section>
<span :class="sublink.color ? `text-${sublink.color}` : ''">
{{ sublink.name }}
</span>
</q-item-section>
</q-item>
</div>
</q-list>
</template>
<q-item clickable v-ripple @click="showAbout()">
<q-item-section avatar><q-icon name="info" /></q-item-section>
<q-item-section>About</q-item-section>
</q-item>
<q-item clickable v-ripple @click="logout()">
<q-item-section avatar><q-icon name="logout" /></q-item-section>
<q-item-section>Logout</q-item-section>
</q-item>
</q-list>
</q-scroll-area>
</q-drawer>
</template>
<style lang="sass" scoped>
.menu-list .q-item
border-radius: 0 32px 32px 0
</style>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { ReferenceEntry } from '~/stores/reference';
defineProps({ entries: Array<ReferenceEntry> });
</script>
<template>
<q-card flat bordered class="my-card" v-for="entry in entries" :key="entry.title">
<q-card-section>
<div class="row items-center no-wrap">
<div class="col">
<div class="text-h6">{{ entry.title }}</div>
<div class="text-subtitle2">{{ entry.subtitle }}</div>
</div>
<div class="col-auto">
<q-btn color="grey-7" round flat icon="more_vert">
<q-menu cover auto-close>
<q-list>
<q-item clickable><q-item-section>Remove Card</q-item-section></q-item>
<q-item clickable><q-item-section>Send Feedback</q-item-section></q-item>
<q-item clickable><q-item-section>Share</q-item-section></q-item>
</q-list>
</q-menu>
</q-btn>
</div>
</div>
</q-card-section>
<q-separator />
<q-card-actions>
<q-btn flat :to="'reference/' + entry.id + '/view'">Read</q-btn>
</q-card-actions>
</q-card>
</template>

View File

@@ -0,0 +1,168 @@
<!-- Abandoned: superseded by block-based booking. Retained for future reference. -->
<script setup lang="ts">
import { ref } from 'vue';
import type { TimestampOrNull, Timestamp } from '@quasar/quasar-ui-qcalendar';
import {
QCalendarResource,
QCalendarMonth,
today,
parseTimestamp,
addToDate,
parsed,
} from '@quasar/quasar-ui-qcalendar';
import { useBoatStore } from '~/stores/boat';
import type { Boat } from '~/utils/boat.types';
import { useReservationStore } from '~/stores/reservation';
import { date } from 'quasar';
import { computed } from 'vue';
import type { StatusTypes } from '~/utils/schedule.types';
import { useIntervalStore } from '~/stores/interval';
import { storeToRefs } from 'pinia';
interface EventData {
event: object;
scope: { timestamp: object; columnindex: number; activeDate: boolean; droppable: boolean };
}
const durations = [1, 1.5, 2, 2.5, 3, 3.5, 4];
interface ResourceIntervalScope {
resource: Boat;
intervals: [];
timeStartPosX(start: TimestampOrNull): number;
timeDurationWidth(duration: number): number;
}
const statusLookup = {
confirmed: ['#14539a', 'white'],
pending: ['#f2c037', 'white'],
tentative: ['white', 'grey'],
};
const calendar = ref();
const boatStore = useBoatStore();
const reservationStore = useReservationStore();
const { selectedDate } = storeToRefs(useIntervalStore());
const duration = ref(1);
const formattedMonth = computed(() => {
const d = new Date(selectedDate.value);
return monthFormatter()?.format(d);
});
const disabledBefore = computed(() => {
const todayTs = parseTimestamp(today()) as Timestamp;
return addToDate(todayTs, { day: -1 }).date;
});
function monthFormatter() {
try {
return new Intl.DateTimeFormat('en-CA', { month: 'long', timeZone: 'UTC' });
} catch { /* */ }
}
function getEvents(scope: ResourceIntervalScope) {
const resourceEvents = reservationStore.getReservationsByDate(selectedDate.value, scope.resource.$id);
return resourceEvents.value.map((event) => ({
left: scope.timeStartPosX(parsed(event.start)),
width: scope.timeDurationWidth(date.getDateDiff(event.end, event.start, 'minutes')),
title: event.user,
status: event.status,
}));
}
function getStyle(event: { left: number; width: number; title: string; status: StatusTypes }) {
return {
position: 'absolute',
background: event.status ? statusLookup[event.status][0] : 'white',
color: event.status ? statusLookup[event.status][1] : '#14539a',
left: `${event.left}px`,
width: `${event.width}px`,
height: '32px',
overflow: 'hidden',
};
}
const emit = defineEmits(['onClickTime', 'onUpdateDuration']);
function onPrev() { calendar.value.prev(); }
function onNext() { calendar.value.next(); }
function onClickDate(data: EventData) { return data; }
function onClickTime(data: EventData) { emit('onClickTime', data); }
function onUpdateDuration(value: EventData) { emit('onUpdateDuration', value); }
const onClickInterval = () => {};
const onClickHeadResources = () => {};
const onClickResource = () => {};
const onResourceExpanded = () => {};
const onMoved = () => {};
const onChange = () => {};
</script>
<template>
<q-card-section>
<div class="text-caption text-justify">
Use the calendar to pick a date. Tap a box in the grid for the boat and start time.
</div>
<div style="width: 100%; display: flex; justify-content: center">
<div style="width: 50%; max-width: 350px; display: flex; justify-content: space-between">
<span class="q-button" style="cursor: pointer; user-select: none" @click="onPrev">&lt;</span>
{{ formattedMonth }}
<span class="q-button" style="cursor: pointer; user-select: none" @click="onNext">&gt;</span>
</div>
</div>
<div style="display: flex; justify-content: center; align-items: center; flex-wrap: nowrap">
<div style="display: flex; width: 100%">
<q-calendar-month
ref="calendar"
v-model="selectedDate"
:disabled-before="disabledBefore"
animated
bordered
mini-mode
date-type="rounded"
@change="onChange"
@moved="onMoved"
@click-date="onClickDate" />
</div>
</div>
</q-card-section>
<q-calendar-resource
v-model="selectedDate"
:model-resources="boatStore.boats"
resource-key="id"
resource-label="displayName"
resource-width="32"
:interval-start="6"
:interval-count="18"
:interval-minutes="60"
cell-width="48"
style="--calendar-resources-width: 48px"
resource-min-height="40"
animated
bordered
@change="onChange"
@moved="onMoved"
@resource-expanded="onResourceExpanded"
@click-date="onClickDate"
@click-time="onClickTime"
@click-resource="onClickResource"
@click-head-resources="onClickHeadResources"
@click-interval="onClickInterval">
<template #resource-intervals="{ scope }">
<template v-for="(event, index) in getEvents(scope)" :key="index">
<q-badge outline :label="event.title" :style="getStyle(event)" />
</template>
</template>
<template #resource-label="{ scope: { resource } }">
<div class="col-12 .col-md-auto">
{{ resource.displayName }}
<q-icon v-if="resource.defects" name="warning" color="warning" />
</div>
</template>
</q-calendar-resource>
<q-card-section>
<q-select filled v-model="duration" :options="durations" dense @update:model-value="onUpdateDuration" label="Duration (hours)" stack-label>
<template v-slot:append><q-icon name="timelapse" /></template>
</q-select>
</q-card-section>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<div>My component</div>
</template>
<script setup lang="ts"></script>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import { useBoatStore } from '~/stores/boat';
import type { Boat } from '~/utils/boat.types';
const boats = useBoatStore().boats;
const boat = <Boat | undefined>undefined;
</script>
<template>
<q-select v-model="boat" :options="boats" option-value="id" option-label="name" label="Boat">
<template v-slot:prepend>
<q-item-section avatar>
<q-img v-if="boat?.iconSrc" :src="boat?.iconSrc" />
<q-icon v-else name="sailing" />
</q-item-section>
</template>
<template v-slot:option="scope">
<q-item v-bind="scope.itemProps">
<q-item-section avatar>
<q-img :src="scope.opt.iconsrc" />
</q-item-section>
<q-item-section>
<q-item-label>{{ scope.opt.name }}</q-item-label>
<q-item-label caption>{{ scope.opt.class }}</q-item-label>
</q-item-section>
<q-item-section avatar v-if="scope.opt.defects">
<q-icon name="warning" color="warning" />
<q-tooltip class="bg-amber text-black shadow-7">
This boat has notices. Select it to see details.
</q-tooltip>
</q-item-section>
</q-item>
</template>
</q-select>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { Boat } from '~/utils/boat.types';
defineProps({ boats: Array<Boat> });
</script>
<template>
<div v-if="boats" class="row">
<q-card
v-for="boat in boats"
:key="boat.$id"
class="q-ma-sm col-xs-12 col-sm-6 col-xl-3">
<q-card-section>
<q-img :src="boat.imgSrc" :fit="'scale-down'">
<div class="row absolute-top">
<div class="col text-h6 text-left">{{ boat.name }}</div>
<div class="col text-right">{{ boat.class }}</div>
</div>
</q-img>
</q-card-section>
<q-separator />
</q-card>
</div>
<div v-else><q-card>Sorry, no boats to show you!</q-card></div>
</template>

View File

@@ -0,0 +1 @@
<template><div /></template>

View File

@@ -0,0 +1,101 @@
<script setup lang="ts">
import { useIntervalTemplateStore } from '~/stores/intervalTemplate';
import type { IntervalTemplate } from '~/utils/schedule.types';
import { copyIntervalTemplate, timeTuplesOverlapped } from '~/utils/schedule';
import { ref } from 'vue';
const alert = ref(false);
const overlapped = ref();
const intervalTemplateStore = useIntervalTemplateStore();
const props = defineProps<{ edit?: boolean; modelValue: IntervalTemplate }>();
const edit = ref(props.edit);
const expanded = ref(props.edit);
const template = ref(copyIntervalTemplate(props.modelValue));
const emit = defineEmits<{ (e: 'cancel'): void; (e: 'saved'): void }>();
const revert = () => {
template.value = copyIntervalTemplate(props.modelValue);
edit.value = false;
emit('cancel');
};
const toggleEdit = () => {
edit.value = !edit.value;
};
const deleteTemplate = (event: Event, tmpl: IntervalTemplate | undefined) => {
if (tmpl?.$id) intervalTemplateStore.deleteIntervalTemplate(tmpl.$id);
};
function onDragStart(e: DragEvent, tmpl: IntervalTemplate) {
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'copy';
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('ID', tmpl.$id || '');
}
}
const saveTemplate = (evt: Event, tmpl: IntervalTemplate | undefined) => {
if (!tmpl) return false;
overlapped.value = timeTuplesOverlapped(tmpl.timeTuples);
if (overlapped.value.length > 0) {
alert.value = true;
} else {
edit.value = false;
if (tmpl.$id && tmpl.$id !== 'unsaved') {
intervalTemplateStore.updateIntervalTemplate(tmpl, tmpl.$id);
} else {
intervalTemplateStore.createIntervalTemplate(tmpl);
emit('saved');
}
}
};
</script>
<template>
<q-expansion-item expand-icon-toggle draggable="true" @dragstart="onDragStart($event, template)" v-model="expanded">
<template v-slot:header>
<q-item-section>
<q-input label="Template name" :borderless="!edit" dense v-model="template.name" v-if="edit" />
<q-item-label v-if="!edit" class="cursor-pointer">{{ template.name }}</q-item-label>
</q-item-section>
</template>
<q-card flat>
<q-card-section horizontal>
<q-card-section class="q-pt-xs">
<q-list dense>
<q-item v-for="(item, index) in template.timeTuples" :key="item[0]">
<q-input class="q-mx-sm" dense v-model="item[0]" type="time" label="Start" :borderless="!edit" :readonly="!edit" />
<q-input class="q-mx-sm" dense v-model="item[1]" type="time" label="End" :borderless="!edit" :readonly="!edit">
<template v-slot:after>
<q-btn v-if="edit" round dense flat icon="delete" @click="template.timeTuples.splice(index, 1)" />
</template>
</q-input>
</q-item>
</q-list>
<q-btn v-if="edit" dense color="primary" size="sm" label="Add interval" @click="template.timeTuples.push(['00:00', '00:00'])" />
</q-card-section>
<q-card-actions vertical>
<q-btn v-if="!edit" color="primary" icon="edit" label="Edit" @click="toggleEdit" />
<q-btn v-if="edit" color="primary" icon="save" label="Save" @click="saveTemplate($event, template)" />
<q-btn v-if="edit" color="secondary" icon="cancel" label="Cancel" @click="revert" />
<q-btn color="negative" icon="delete" label="Delete" v-if="template.$id !== ''" @click="deleteTemplate($event, template)" />
</q-card-actions>
</q-card-section>
</q-card>
</q-expansion-item>
<q-dialog v-model="alert">
<q-card>
<q-card-section><div class="text-h6">Overlapped blocks!</div></q-card-section>
<q-card-section class="q-pt-none">
<q-chip square icon="schedule" v-for="item in overlapped" :key="item.start">
{{ item.start }}-{{ item.end }}
</q-chip>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="OK" color="primary" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
</template>

View File

@@ -0,0 +1,13 @@
<template>
<div class="row justify-center">
<div class="q-pa-md q-gutter-sm row">
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('today')">Today</q-btn>
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('prev')">&lt; Prev</q-btn>
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('next')">Next &gt;</q-btn>
</div>
</div>
</template>
<script setup lang="ts">
defineEmits(['today', 'prev', 'next']);
</script>

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
import { useBoatStore } from '~/stores/boat';
import { useReservationStore } from '~/stores/reservation';
import type { Reservation } from '~/utils/schedule.types';
import { formatDate, isPast } from '~/utils/schedule';
import { ref } from 'vue';
const cancelDialog = ref(false);
const boatStore = useBoatStore();
const reservationStore = useReservationStore();
const reservation = defineModel<Reservation>({ required: true });
const cancelReservation = () => {
cancelDialog.value = true;
};
</script>
<template>
<q-card
bordered
:class="isPast(reservation.end) ? 'text-blue-grey-6' : ''"
class="q-ma-md">
<q-card-section>
<div class="row items-center no-wrap">
<div class="col">
<div class="text-h6">
{{ boatStore.getBoatById(reservation.resource)?.name }}
</div>
<div class="text-subtitle2">
<p>
Start: {{ formatDate(reservation.start) }}<br />
End: {{ formatDate(reservation.end) }}<br />
Type: {{ reservation.reason }}
</p>
</div>
</div>
</div>
</q-card-section>
<q-separator />
<q-card-actions v-if="!isPast(reservation.end)">
<q-btn flat size="lg" :to="`/schedule/edit/${reservation.$id}`">Modify</q-btn>
<q-btn flat size="lg" @click="cancelReservation()">Delete</q-btn>
</q-card-actions>
</q-card>
<q-dialog v-model="cancelDialog">
<q-card>
<q-card-section class="row items-center">
<q-avatar icon="warning" color="negative" text-color="white" />
<span class="q-ml-md">Warning!</span>
<p class="q-pt-md">
This will delete your reservation for
{{ boatStore.getBoatById(reservation?.resource)?.name }} on
{{ formatDate(reservation?.start) }}
</p>
</q-card-section>
<q-card-actions align="right">
<q-btn flat size="lg" label="Cancel" color="primary" v-close-popup />
<q-btn
flat
size="lg"
label="Delete"
color="negative"
@click="reservationStore.deleteReservation(reservation)"
v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
</template>

View File

@@ -0,0 +1,236 @@
<script setup lang="ts">
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
import {
QCalendarDay,
diffTimestamp,
today,
parseTimestamp,
parseDate,
addToDate,
} from '@quasar/quasar-ui-qcalendar';
import CalendarHeaderComponent from './CalendarHeaderComponent.vue';
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useBoatStore } from '~/stores/boat';
import { useAuthStore } from '~/stores/auth';
import type { Interval, Reservation } from '~/utils/schedule.types';
import { storeToRefs } from 'pinia';
import { useReservationStore } from '~/stores/reservation';
import { useIntervalTemplateStore } from '~/stores/intervalTemplate';
import { useIntervalStore } from '~/stores/interval';
const intervalTemplateStore = useIntervalTemplateStore();
const reservationStore = useReservationStore();
const { boats } = storeToRefs(useBoatStore());
const selectedBlock = defineModel<Interval | null>();
const selectedDate = ref(today());
const { getAvailableIntervals } = useIntervalStore();
const calendar = ref<typeof QCalendarDay | null>(null);
const now = ref(new Date());
let intervalId: ReturnType<typeof setInterval> | undefined;
onMounted(async () => {
await useBoatStore().fetchBoats();
await intervalTemplateStore.fetchIntervalTemplates();
intervalId = setInterval(() => { now.value = new Date(); }, 60000);
});
onUnmounted(() => clearInterval(intervalId));
function handleSwipe({ direction }: { direction: string }) {
if (direction === 'right') {
calendar.value?.prev();
} else {
calendar.value?.next();
}
}
function reservationStyles(
reservation: Reservation,
timeStartPos: (t: string) => string,
timeDurationHeight: (d: number) => string
) {
return genericBlockStyle(
parseDate(new Date(reservation.start)) as Timestamp,
parseDate(new Date(reservation.end)) as Timestamp,
timeStartPos,
timeDurationHeight
);
}
function getUserName(userid: string) {
return useAuthStore().getUserNameById(userid);
}
function blockStyles(
block: Interval,
timeStartPos: (t: string) => string,
timeDurationHeight: (d: number) => string
) {
return genericBlockStyle(
parseDate(new Date(block.start)) as Timestamp,
parseDate(new Date(block.end)) as Timestamp,
timeStartPos,
timeDurationHeight
);
}
function getBoatDisplayName(scope: DayBodyScope) {
return boats.value[scope.columnIndex]?.displayName ?? '';
}
function beforeNow(time: Date) {
return time < now.value || null;
}
function genericBlockStyle(
start: Timestamp,
end: Timestamp,
timeStartPos: (t: string) => string,
timeDurationHeight: (d: number) => string
) {
const s = { top: '', height: '', opacity: '' };
if (timeStartPos && timeDurationHeight) {
s.top = timeStartPos(start.time) + 'px';
s.height =
parseInt(timeDurationHeight(diffTimestamp(start, end, false) / 1000 / 60)) - 1 + 'px';
}
return s;
}
interface DayBodyScope {
columnIndex: number;
timeDurationHeight: string;
timeStartPos: (time: string, clamp: boolean) => string;
timestamp: Timestamp;
}
function selectBlock(event: MouseEvent, scope: DayBodyScope, block: Interval) {
if (scope.timestamp.disabled || new Date(block.end) < new Date()) return false;
selectedBlock.value = block;
}
const boatReservations = computed((): Record<string, Reservation[]> => {
return reservationStore
.getReservationsByDate(selectedDate.value)
.value.reduce((result, reservation) => {
if (!result[reservation.resource]) result[reservation.resource] = [];
result[reservation.resource]!.push(reservation);
return result;
}, <Record<string, Reservation[]>>{});
});
function getBoatReservations(scope: DayBodyScope): Reservation[] {
const boat = boats.value[scope.columnIndex];
return boat ? boatReservations.value[boat.$id] ?? [] : [];
}
const disabledBefore = computed(() => {
const todayTs = parseTimestamp(today()) as Timestamp;
return addToDate(todayTs, { day: -1 }).date;
});
</script>
<template>
<div>
<q-card>
<q-toolbar>
<q-toolbar-title>Select a Boat and Time</q-toolbar-title>
<q-btn icon="close" flat round dense v-close-popup />
</q-toolbar>
<q-separator />
<CalendarHeaderComponent v-model="selectedDate" />
<div class="boat-schedule-table-component">
<QCalendarDay
ref="calendar"
class="q-pa-xs"
flat
animated
dense
:disabled-before="disabledBefore"
interval-height="24"
interval-count="18"
interval-start="06:00"
:short-interval-label="true"
v-model="selectedDate"
:column-count="boats.length"
v-touch-swipe.left.right="handleSwipe">
<template #head-day="{ scope }">
<div style="text-align: center; font-weight: 800">
{{ getBoatDisplayName(scope) }}
</div>
</template>
<template #day-body="{ scope }">
<div
v-for="block in getAvailableIntervals(scope.timestamp, boats[scope.columnIndex]).value"
:key="block.$id">
<div
class="timeblock"
:disabled="beforeNow(new Date(block.end))"
:class="selectedBlock?.$id === block.$id ? 'selected' : ''"
:style="blockStyles(block, scope.timeStartPos, scope.timeDurationHeight)"
:id="block.$id"
@click="selectBlock($event, scope, block)">
{{ boats[scope.columnIndex]?.name }}<br />
{{ selectedBlock?.$id === block.$id ? 'Selected' : 'Available' }}
</div>
</div>
<div v-for="reservation in getBoatReservations(scope)" :key="reservation.$id">
<div
class="reservation column"
:style="reservationStyles(reservation, scope.timeStartPos, scope.timeDurationHeight)">
{{ getUserName(reservation.user) || 'loading...' }}<br />
<q-chip class="gt-md">{{ reservation.reason }}</q-chip>
</div>
</div>
</template>
</QCalendarDay>
</div>
</q-card>
</div>
</template>
<style lang="sass">
.boat-schedule-table-component
display: flex
max-height: 60vh
max-width: 98vw
.reservation
display: flex
position: absolute
justify-content: center
align-items: center
text-align: center
width: 100%
opacity: 1
margin: 0px
text-overflow: ellipsis
font-size: 0.8em
cursor: pointer
background: $accent
color: white
border: 1px solid black
.timeblock
display: flex
position: absolute
justify-content: center
text-align: center
align-items: center
width: 100%
opacity: 0.5
margin: 0px
text-overflow: ellipsis
font-size: 0.8em
cursor: pointer
background: $primary
color: white
border: 1px solid black
.selected
opacity: 1 !important
.q-calendar-day__interval--text
font-size: 0.8em
.q-calendar-day__day.q-current-day
padding: 1px
.q-calendar-day__head--days__column
background: $primary
color: white
</style>

View File

@@ -0,0 +1,192 @@
<script setup lang="ts">
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
import {
addToDate,
createDayList,
createNativeLocaleFormatter,
getEndOfWeek,
getStartOfWeek,
parseTimestamp,
today,
} from '@quasar/quasar-ui-qcalendar';
import { ref, reactive, computed } from 'vue';
const selectedDate = defineModel<string>();
const weekdays = reactive([1, 2, 3, 4, 5, 6, 0]);
const locale = ref('en-CA');
const monthFormatter = monthFormatterFunc();
const dayFormatter = dayFormatterFunc();
const weekdayFormatter = weekdayFormatterFunc();
const today2 = computed(() => parseTimestamp(today()));
const parsedStart = computed(() =>
getStartOfWeek(
parseTimestamp(selectedDate.value || today()) as Timestamp,
weekdays,
today2.value as Timestamp
)
);
const parsedEnd = computed(() =>
getEndOfWeek(
parseTimestamp(selectedDate.value || today()) as Timestamp,
weekdays,
today2.value as Timestamp
)
);
const days = computed(() => {
if (parsedStart.value && parsedEnd.value) {
return createDayList(parsedStart.value, parsedEnd.value, today2.value as Timestamp, weekdays);
}
return [];
});
const dayStyle = computed(() => ({ width: 100 / weekdays.length + '%' }));
function onPrev() {
selectedDate.value = addToDate(parsedStart.value, { day: -7 }).date;
}
function onNext() {
selectedDate.value = addToDate(parsedStart.value, { day: 7 }).date;
}
function dayClass(day: Timestamp) {
return { 'date-button': true, 'selected-date-button': selectedDate.value === day.date };
}
function monthFormatterFunc() {
const longOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', month: 'long' };
const shortOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', month: 'short' };
return createNativeLocaleFormatter(locale.value, (_tms, short) => short ? shortOptions : longOptions);
}
function weekdayFormatterFunc() {
const longOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', weekday: 'long' };
const shortOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', weekday: 'short' };
return createNativeLocaleFormatter(locale.value, (_tms, short) => short ? shortOptions : longOptions);
}
function dayFormatterFunc() {
const longOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', day: '2-digit' };
const shortOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', day: 'numeric' };
return createNativeLocaleFormatter(locale.value, (_tms, short) => short ? shortOptions : longOptions);
}
</script>
<template>
<div class="title-bar" style="display: flex">
<button tabindex="0" class="date-button direction-button direction-button__left" @click="onPrev">
<span class="q-calendar__focus-helper" tabindex="-1" />
</button>
<div class="dates-holder">
<div :key="parsedStart?.date" class="internal-dates-holder">
<div v-for="day in days" :key="day.date" :style="dayStyle">
<button tabindex="0" style="width: 100%" :class="dayClass(day)" @click="selectedDate = day.date">
<span class="q-calendar__focus-helper" tabindex="-1" />
<div style="width: 100%; font-size: 0.9em">{{ monthFormatter(day, true) }}</div>
<div style="width: 100%; font-size: 1.2em; font-weight: 700">{{ dayFormatter(day, false) }}</div>
<div style="width: 100%; font-size: 1em">{{ weekdayFormatter(day, true) }}</div>
</button>
</div>
</div>
</div>
<button tabindex="0" class="date-button direction-button direction-button__right" @click="onNext">
<span class="q-calendar__focus-helper" tabindex="-1" />
</button>
</div>
</template>
<style lang="sass">
.title-bar
position: relative
width: 100%
height: 70px
background: white
display: flex
flex-direction: row
flex: 1 0 100%
justify-content: space-between
align-items: center
overflow: hidden
border-radius: 3px
user-select: none
margin: 2px 0px 2px
.dates-holder
position: relative
width: 100%
align-items: center
display: flex
justify-content: space-between
color: #fff
overflow: hidden
user-select: none
.internal-dates-holder
position: relative
width: 100%
display: inline-flex
flex: 1 1 100%
flex-direction: row
justify-content: space-between
overflow: hidden
user-select: none
.direction-button
background: white
color: $primary
width: 40px
max-width: 50px !important
.direction-button__left
&:before
content: '<'
display: inline-flex
flex-direction: column
justify-content: center
height: 100%
font-weight: 900
font-size: 3em
.direction-button__right
&:before
content: '>'
display: inline-flex
flex-direction: column
justify-content: center
height: 100%
font-weight: 900
font-size: 3em
.date-button
color: $primary
background: white
z-index: 2
height: 100%
outline: 0
cursor: pointer
border-radius: 3px
display: inline-flex
flex: 1 0 auto
flex-direction: column
align-items: stretch
position: relative
border: 0
vertical-align: middle
padding: 0
font-size: 14px
line-height: 1.715em
text-decoration: none
font-weight: 500
text-transform: uppercase
text-align: center
user-select: none
.selected-date-button
color: white !important
background: $primary !important
</style>

28
app/layouts/admin.vue Normal file
View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import { ref } from 'vue';
const leftDrawer = ref(false);
</script>
<template>
<q-layout view="hHh Lpr fFf">
<q-header elevated>
<q-toolbar>
<q-btn flat round dense icon="menu" @click="leftDrawer = !leftDrawer" />
<q-toolbar-title>Admin</q-toolbar-title>
</q-toolbar>
<q-tabs>
<q-route-tab icon="person" to="/admin/user" replace label="Users" />
<q-route-tab icon="directions_boat" to="/admin/boat" replace label="Boats" />
</q-tabs>
</q-header>
<q-drawer v-model="leftDrawer" side="left" bordered content-class="bg-grey-2">
<q-scroll-area class="fit q-pa-sm" />
</q-drawer>
<q-page-container>
<slot />
</q-page-container>
</q-layout>
</template>

45
app/layouts/default.vue Normal file
View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useQuasar } from 'quasar';
import BottomNavComponent from '~/components/BottomNavComponent.vue';
import LeftDrawer from '~/components/LeftDrawer.vue';
import { APP_VERSION } from '~/utils/version';
const q = useQuasar();
const route = useRoute();
const leftDrawerOpen = ref(false);
function toggleLeftDrawer() {
leftDrawerOpen.value = !leftDrawerOpen.value;
}
q.addressbarColor?.set('#14539a');
</script>
<template>
<q-layout view="hHh Lpr fFf">
<q-header elevated>
<q-toolbar>
<q-btn
flat
dense
round
icon="menu"
aria-label="Menu"
@click="toggleLeftDrawer" />
<q-toolbar-title>{{ route?.meta?.title as string }}</q-toolbar-title>
<q-space />
<div>v{{ APP_VERSION }}</div>
</q-toolbar>
</q-header>
<LeftDrawer
:drawer="leftDrawerOpen"
@drawer-toggle="toggleLeftDrawer" />
<q-page-container>
<slot />
</q-page-container>
<q-footer>
<BottomNavComponent />
</q-footer>
</q-layout>
</template>

View File

@@ -0,0 +1,27 @@
import { useAuthStore } from '~/stores/auth';
export default defineNuxtRouteMiddleware((to) => {
const authStore = useAuthStore();
// Public routes (set via definePageMeta({ public: true }) in each page)
if (to.meta.public === true) {
// Redirect already-authenticated users away from /login
if (to.path === '/login' && authStore.currentUser) {
return navigateTo('/');
}
return;
}
// All other routes require auth
if (!authStore.currentUser) {
return navigateTo('/login');
}
// Role-based access: pages set requiredRoles via definePageMeta
const requiredRoles = to.meta.requiredRoles as string[] | undefined;
if (requiredRoles && requiredRoles.length > 0) {
if (!authStore.hasRequiredRole(requiredRoles)) {
return abortNavigation();
}
}
});

20
app/pages/[...slug].vue Normal file
View File

@@ -0,0 +1,20 @@
<script lang="ts" setup>
definePageMeta({ public: true, layout: false });
</script>
<template>
<div class="fullscreen bg-blue text-white text-center q-pa-md flex flex-center">
<div>
<div style="font-size: 30vh">404</div>
<div class="text-h2" style="opacity: 0.4">Oops. Nothing here...</div>
<q-btn
class="q-mt-xl"
color="white"
text-color="blue"
unelevated
to="/"
label="Go Home"
no-caps />
</div>
</div>
</template>

9
app/pages/admin/boat.vue Normal file
View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
definePageMeta({ layout: 'admin', requiredRoles: ['admin'] });
</script>
<template>
<q-page padding>
<!-- content -->
</q-page>
</template>

9
app/pages/admin/user.vue Normal file
View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
definePageMeta({ layout: 'admin', requiredRoles: ['admin'] });
</script>
<template>
<q-page padding>
<!-- content -->
</q-page>
</template>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import { useAuthStore } from '~/stores/auth';
definePageMeta({ public: true, layout: false });
const route = useRoute();
const authStore = useAuthStore();
const error = ref<string | null>(null);
onMounted(async () => {
const userId = route.query.userId as string | undefined;
const secret = route.query.secret as string | undefined;
if (!userId || !secret) {
error.value = 'Invalid magic link — missing parameters.';
return;
}
try {
await authStore.magicURLLogin(userId, secret);
await navigateTo('/');
} catch (e) {
console.error(e);
error.value = 'Login failed. The link may have expired.';
}
});
</script>
<template>
<q-page class="flex flex-center">
<div v-if="error" class="text-negative text-body1">{{ error }}</div>
<q-spinner v-else color="primary" size="50px" />
</q-page>
</template>

18
app/pages/boat.vue Normal file
View File

@@ -0,0 +1,18 @@
<script lang="ts" setup>
import BoatPreviewComponent from '~/components/boat/BoatPreviewComponent.vue';
import { useBoatStore } from '~/stores/boat';
import { storeToRefs } from 'pinia';
definePageMeta({ title: 'Boats' });
const boatStore = useBoatStore();
const { boats } = storeToRefs(boatStore);
onMounted(() => boatStore.fetchBoats());
</script>
<template>
<q-page>
<boat-preview-component :boats="boats" />
</q-page>
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import CertificationComponent from '~/components/CertificationComponent.vue';
definePageMeta({ title: 'Certifications' });
</script>
<template>
<q-page padding>
<CertificationComponent />
</q-page>
</template>

24
app/pages/checklist.vue Normal file
View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
definePageMeta({ title: 'Checklists' });
</script>
<template>
<q-page padding>
<q-card bordered separator style="max-width: 400px">
<q-card-section clickable v-ripple>
<div class="text-h6">Engine Starting</div>
<div class="text-subtitle2">
Proper procedures for starting an outboard engine.
</div>
</q-card-section>
</q-card>
<q-card bordered separator style="max-width: 400px">
<q-card-section clickable v-ripple>
<div class="text-h6">Pre-Sail Checklist J/27</div>
<div class="text-subtitle2">
Mandatory Safety and Equipment readiness check for J/27 class boats.
</div>
</q-card-section>
</q-card>
</q-page>
</template>

28
app/pages/index.vue Normal file
View File

@@ -0,0 +1,28 @@
<script lang="ts" setup>
import { useNavLinks } from '~/utils/navlinks';
definePageMeta({ title: 'OYS Borrow a Boat' });
const { enabledLinks } = useNavLinks();
</script>
<template>
<q-page class="row justify-center">
<q-img alt="OYS Logo" src="/oysqn_logo.png" fit="scale-down" />
<q-list class="full-width mobile-only">
<q-item
v-for="link in enabledLinks.filter((x) => x.front_links)"
:key="link.name">
<q-btn
:icon="link.icon"
color="primary"
:size="`1.25em`"
:to="link.to"
:label="link.name"
rounded
class="full-width"
:align="'left'" />
</q-item>
</q-list>
</q-page>
</template>

127
app/pages/login.vue Normal file
View File

@@ -0,0 +1,127 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Dialog, Notify } from 'quasar';
import { useAuthStore } from '~/stores/auth';
import { AppwriteException } from 'appwrite';
definePageMeta({ public: true, layout: false });
const email = ref('');
const token = ref('');
const userId = ref<string | undefined>();
const authStore = useAuthStore();
const sendMagicLink = async () => {
if (!email.value) {
Dialog.create({ message: 'Please enter your e-mail address.' });
return;
}
try {
await authStore.createMagicURLSession(email.value);
Dialog.create({ message: 'Check your e-mail for a magic login link.' });
} catch {
Dialog.create({ message: 'An error occurred. Please ask for help in Discord.' });
}
};
const doTokenLogin = async () => {
if (!userId.value) {
try {
const sessionToken = await authStore.createTokenSession(email.value);
userId.value = sessionToken.userId;
Dialog.create({ message: 'Check your e-mail for your login code.' });
} catch {
Dialog.create({ message: 'An error occurred. Please ask for help in Discord.' });
}
} else {
const notification = Notify.create({
type: 'primary',
position: 'top',
spinner: true,
message: 'Logging you in...',
timeout: 8000,
group: false,
});
try {
await authStore.tokenLogin(userId.value, token.value);
notification({ type: 'positive', message: 'Logged in!', timeout: 2000, spinner: false, icon: 'check_circle' });
await navigateTo('/');
} catch (error: unknown) {
if (error instanceof AppwriteException) {
if (error.type === 'user_session_already_exists') {
notification({ type: 'positive', message: 'Already logged in!', timeout: 2000, spinner: false, icon: 'check_circle' });
await navigateTo('/');
return;
}
Dialog.create({ title: 'Login Error!', message: error.message, persistent: true });
}
notification({ type: 'negative', message: 'Login failed.', timeout: 2000 });
}
}
};
</script>
<template>
<q-layout>
<q-page-container>
<q-page class="flex bg-image flex-center">
<q-card
v-bind:style="$q.screen.lt.sm ? { width: '80%' } : { width: '30%' }">
<q-card-section>
<q-img fit="scale-down" src="/oysqn_logo.png" />
</q-card-section>
<q-card-section>
<div class="text-center q-pt-sm">
<div class="col text-h6">Log in</div>
</div>
</q-card-section>
<q-form @keydown.enter.prevent="doTokenLogin">
<q-card-section class="q-gutter-md">
<q-input
v-model="email"
label="E-Mail"
type="email"
color="darkblue"
filled />
<q-input
v-if="userId"
v-model="token"
label="6-digit code"
type="number"
color="darkblue"
filled />
</q-card-section>
</q-form>
<q-card-section class="q-pa-none">
<div class="row justify-center q-ma-sm">
<q-btn
v-if="!userId"
type="button"
@click="sendMagicLink"
color="secondary"
label="Send Magic Link"
style="width: 300px" />
</div>
<div class="row justify-center q-ma-sm">
<q-btn
type="button"
@click="doTokenLogin"
color="primary"
:label="userId ? 'Login' : 'Send Code'"
style="width: 300px" />
</div>
</q-card-section>
</q-card>
</q-page>
</q-page-container>
</q-layout>
</template>
<style>
.bg-image {
background-image: url('~/assets/oys_lighthouse.jpg');
background-repeat: no-repeat;
background-position-x: center;
background-size: cover;
}
</style>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
definePageMeta({ public: true, layout: false });
</script>
<template>
<q-layout>
<q-page-container>
<q-page padding>
<h1>Privacy Policy for Undock.ca</h1>
<p>
At Undock, accessible from https://undock.ca, one of our main priorities is the
privacy of our visitors. This Privacy Policy document contains types of information
that is collected and recorded by Undock and how we use it.
</p>
<h2>General Data Protection Regulation (GDPR)</h2>
<p>We are a Data Controller of your information.</p>
<h2>Log Files</h2>
<p>
Undock follows a standard procedure of using log files. These files log visitors
when they visit websites. The information collected by log files include internet
protocol (IP) addresses, browser type, Internet Service Provider (ISP), date and
time stamp, referring/exit pages, and possibly the number of clicks.
</p>
<h2>Cookies and Web Beacons</h2>
<p>
Like any other website, Undock uses "cookies". These cookies are used to store
information including visitors' preferences, and the pages on the website that
the visitor accessed or visited.
</p>
<h2>Consent</h2>
<p>
By using our website, you hereby consent to our Privacy Policy and agree to its
<a href="/terms-of-service">terms</a>.
</p>
</q-page>
</q-page-container>
</q-layout>
</template>

76
app/pages/profile.vue Normal file
View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
import { useAuthStore } from '~/stores/auth';
import { ref } from 'vue';
definePageMeta({ title: 'Member Profile' });
const authStore = useAuthStore();
const newName = ref<string | undefined>();
const editName = async () => {
if (newName.value) {
try {
await authStore.updateName(newName.value);
newName.value = undefined;
} catch (e) {
console.log(e);
}
} else {
newName.value = authStore.currentUser?.name || '';
}
};
</script>
<template>
<q-page padding class="row">
<q-list class="col-sm-4 col-12">
<q-separator />
<q-item>
<q-item-section avatar>
<q-avatar icon="person" />
</q-item-section>
<q-item-section>
<q-item-label caption>Name</q-item-label>
<q-input
filled
v-model="newName"
@keydown.enter.prevent="editName"
v-if="newName !== undefined" />
<div v-else>{{ authStore.currentUser?.name }}</div>
</q-item-section>
<q-item-section avatar>
<q-btn
square
@click="editName"
:icon="newName !== undefined ? 'check' : 'edit'" />
<q-btn
v-if="newName !== undefined"
square
color="negative"
@click="newName = undefined"
icon="cancel" />
</q-item-section>
</q-item>
<q-item>
<q-item-section avatar>
<q-avatar icon="email" />
</q-item-section>
<q-item-section>
<q-item-label caption>E-mail</q-item-label>
{{ authStore.currentUser?.email }}
</q-item-section>
</q-item>
<q-separator />
<q-item>
<q-item-section>
<q-item-label overline>Certifications</q-item-label>
<div>
<q-chip square icon="verified" color="green" text-color="white">J/27</q-chip>
<q-chip square icon="verified" color="blue" text-color="white">Capri25</q-chip>
<q-chip square icon="verified" color="grey-9" text-color="white">Night</q-chip>
</div>
</q-item-section>
</q-item>
</q-list>
</q-page>
</template>

38
app/pages/pwreset.vue Normal file
View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
// NOTE: Password reset removed — auth is magic link + OTP only.
definePageMeta({ public: true, layout: false });
</script>
<template>
<q-layout>
<q-page-container>
<q-page class="flex bg-image flex-center">
<q-card v-bind:style="$q.screen.lt.sm ? { width: '80%' } : { width: '30%' }">
<q-card-section>
<q-img fit="scale-down" src="/oysqn_logo.png" />
</q-card-section>
<q-card-section>
<div class="text-center q-pt-sm">
<div class="text-h6">Password Reset</div>
<div class="text-body2 q-mt-md">
This application uses magic link and email code login no password is required.
</div>
</div>
</q-card-section>
<q-card-section class="text-center">
<q-btn flat color="primary" label="Back to Login" to="/login" />
</q-card-section>
</q-card>
</q-page>
</q-page-container>
</q-layout>
</template>
<style>
.bg-image {
background-image: url('~/assets/oys_lighthouse.jpg');
background-repeat: no-repeat;
background-position-x: center;
background-size: cover;
}
</style>

7
app/pages/reference.vue Normal file
View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
definePageMeta({ title: 'Reference' });
</script>
<template>
<NuxtPage />
</template>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import ReferencePreviewComponent from '~/components/ReferencePreviewComponent.vue';
import { ref } from 'vue';
import { useReferenceStore } from '~/stores/reference';
const items = ref(useReferenceStore().allItems);
</script>
<template>
<q-page padding>
<ReferencePreviewComponent :entries="items" />
</q-page>
</template>

View File

@@ -0,0 +1,11 @@
<template>
<q-page padding>
<div class="text-h4">Engine Starting</div>
<q-video
title="Engine Starting"
:ratio="16 / 9"
src="https://www.youtube.com/embed/GMHMLDlkKcE" />
</q-page>
</template>
<script setup lang="ts"></script>

7
app/pages/schedule.vue Normal file
View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
definePageMeta({ title: 'Schedule' });
</script>
<template>
<NuxtPage />
</template>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import BoatReservationComponent from '~/components/BoatReservationComponent.vue';
import { useIntervalStore } from '~/stores/interval';
import type { Interval, Reservation } from '~/utils/schedule.types';
import { ref } from 'vue';
const route = useRoute();
const newReservation = ref<Reservation>();
if (typeof route.query.interval === 'string') {
useIntervalStore()
.fetchInterval(route.query.interval)
.then(
(interval: Interval) =>
(newReservation.value = <Reservation>{
resource: interval.resource,
start: interval.start,
end: interval.end,
})
);
}
</script>
<template>
<q-page>
<BoatReservationComponent v-model="newReservation" />
</q-page>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import BoatReservationComponent from '~/components/BoatReservationComponent.vue';
import { useReservationStore } from '~/stores/reservation';
import type { Reservation } from '~/utils/schedule.types';
import { onMounted, ref } from 'vue';
const route = useRoute();
const reservation = ref<Reservation>();
onMounted(async () => {
const id = route.params.id as string;
reservation.value = await useReservationStore().getReservationById(id);
});
</script>
<template>
<q-page>
<BoatReservationComponent v-model="reservation" />
</q-page>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import { useNavLinks } from '~/utils/navlinks';
const { enabledLinks } = useNavLinks();
const navlinks = enabledLinks.find((link) => link.name === 'Schedule')?.sublinks;
</script>
<template>
<q-page padding>
<q-item v-for="link in navlinks" :key="link.name">
<q-btn
:icon="link.icon"
:color="link.color ? link.color : 'primary'"
size="1.25em"
:to="link.to"
:label="link.name"
rounded
class="full-width"
align="left" />
</q-item>
</q-page>
</template>

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import { useReservationStore } from '~/stores/reservation';
import ReservationCardComponent from '~/components/scheduling/ReservationCardComponent.vue';
import { onMounted, ref } from 'vue';
const reservationStore = useReservationStore();
onMounted(() => reservationStore.fetchUserReservations());
const tab = ref('upcoming');
</script>
<template>
<q-page>
<q-tabs v-model="tab" inline-label class="text-primary">
<q-tab name="upcoming" icon="schedule" label="Upcoming" />
<q-tab name="past" icon="history" label="Past" />
</q-tabs>
<q-separator />
<q-tab-panels v-model="tab" animated>
<q-tab-panel name="upcoming" class="q-pa-none">
<q-card clas="q-ma-md" v-if="!reservationStore.futureUserReservations.length">
<q-card-section>
<div class="text-h6">You don't have any upcoming bookings!</div>
<div class="text-h8">Why don't you go make one?</div>
</q-card-section>
<q-card-actions>
<q-btn
color="primary"
icon="event"
:size="`1.25em`"
label="Book Now"
rounded
class="full-width"
:align="'left'"
to="/schedule/book" />
</q-card-actions>
</q-card>
<div v-else>
<div
v-for="reservation in reservationStore.futureUserReservations"
:key="reservation.$id">
<ReservationCardComponent :modelValue="reservation" />
</div>
</div>
</q-tab-panel>
<q-tab-panel name="past" class="q-pa-none">
<div
v-for="reservation in reservationStore.pastUserReservations"
:key="reservation.$id">
<ReservationCardComponent :modelValue="reservation" />
</div>
</q-tab-panel>
</q-tab-panels>
</q-page>
</template>

View File

@@ -0,0 +1,238 @@
<script setup lang="ts">
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
import {
QCalendarScheduler,
today,
} from '@quasar/quasar-ui-qcalendar';
import type { Boat } from '~/utils/boat.types';
import { useIntervalStore } from '~/stores/interval';
import { computed, onMounted, ref } from 'vue';
import type { Interval, IntervalTemplate, TimeTuple } from '~/utils/schedule.types';
import { date } from 'quasar';
import IntervalTemplateComponent from '~/components/scheduling/IntervalTemplateComponent.vue';
import NavigationBar from '~/components/scheduling/NavigationBar.vue';
import { storeToRefs } from 'pinia';
import { buildInterval, intervalsOverlapped } from '~/utils/schedule';
import { useIntervalTemplateStore } from '~/stores/intervalTemplate';
definePageMeta({ requiredRoles: ['Schedule Admins'] });
const selectedDate = ref(today());
const { fetchBoats } = useBoatStore();
const intervalStore = useIntervalStore();
const intervalTemplateStore = useIntervalTemplateStore();
const { boats } = storeToRefs(useBoatStore());
const intervalTemplates = intervalTemplateStore.getIntervalTemplates();
const calendar = ref();
const overlapped = ref();
const alert = ref(false);
const newTemplate = ref<IntervalTemplate>({
$id: '',
name: 'NewTemplate',
timeTuples: [['09:00', '12:00']],
});
onMounted(async () => {
await fetchBoats();
await intervalTemplateStore.fetchIntervalTemplates();
});
const filteredIntervals = (ts: Timestamp, boat: Boat) => {
return intervalStore.getIntervals(ts, boat);
};
const sortedIntervals = (ts: Timestamp, boat: Boat) => {
return computed(() =>
filteredIntervals(ts, boat).value.sort(
(a, b) => Date.parse(a.start) - Date.parse(b.start)
)
);
};
function resetNewTemplate() {
newTemplate.value = {
$id: 'unsaved',
name: 'NewTemplate',
timeTuples: [['09:00', '12:00']],
};
}
function createTemplate() {
newTemplate.value.$id = 'unsaved';
}
function createIntervals(boat: Boat, templateId: string, dateStr: string) {
const intervals = intervalsFromTemplate(boat, templateId, dateStr);
intervals.forEach((interval) => intervalStore.createInterval(interval));
}
function getIntervals(ts: Timestamp, boat: Boat) {
return intervalStore.getIntervals(ts, boat);
}
function intervalsFromTemplate(
boat: Boat,
templateId: string,
dateStr: string
): Interval[] {
const template = intervalTemplateStore
.getIntervalTemplates()
.value.find((t) => t.$id === templateId);
return template
? template.timeTuples.map((timeTuple: TimeTuple) =>
buildInterval(boat, timeTuple, dateStr)
)
: [];
}
function deleteBlock(block: Interval) {
if (block.$id) {
intervalStore.deleteInterval(block.$id);
}
}
function onDragEnter(e: DragEvent, type: string) {
if (type === 'day' || type === 'head-day') {
e.preventDefault();
if (e.target instanceof HTMLDivElement)
e.target.classList.add('bg-secondary');
}
}
function onDragOver(e: DragEvent, type: string) {
if (type === 'day' || type === 'head-day') {
e.preventDefault();
}
}
function onDragLeave(e: DragEvent, type: string) {
if (type === 'day' || type === 'head-day') {
e.preventDefault();
if (e.target instanceof HTMLDivElement)
e.target.classList.remove('bg-secondary');
}
}
function onDrop(
e: DragEvent,
type: string,
scope: { resource: Boat; timestamp: Timestamp }
) {
if (e.target instanceof HTMLDivElement)
e.target.classList.remove('bg-secondary');
if ((type === 'day' || type === 'head-day') && e.dataTransfer) {
const templateId = e.dataTransfer.getData('ID');
const dateStr = scope.timestamp.date;
const resource = scope.resource;
const existingIntervals = getIntervals(scope.timestamp, resource);
const boatsToApply = type === 'head-day' ? boats.value : [resource];
overlapped.value = boatsToApply
.map((boat) =>
intervalsOverlapped(
existingIntervals.value.concat(
intervalsFromTemplate(boat, templateId, dateStr)
)
)
)
.flat(1);
if (overlapped.value.length === 0) {
boatsToApply.map((b) => createIntervals(b, templateId, dateStr));
} else {
alert.value = true;
}
}
if (e.target instanceof HTMLDivElement)
e.target.classList.remove('bg-secondary');
return false;
}
function onToday() { calendar.value.moveToToday(); }
function onPrev() { calendar.value.prev(); }
function onNext() { calendar.value.next(); }
</script>
<template>
<div class="fit row">
<div class="q-pa-md">
<div class="scheduler col-12">
<NavigationBar @next="onNext" @today="onToday" @prev="onPrev" />
<q-calendar-scheduler
ref="calendar"
v-model="selectedDate"
v-model:model-resources="boats"
resource-key="$id"
resource-label="name"
view="week"
:weekdays="[1, 2, 3, 4, 5, 6, 0]"
animated
bordered
:drag-enter-func="onDragEnter"
:drag-over-func="onDragOver"
:drag-leave-func="onDragLeave"
:drop-func="onDrop"
day-min-height="50px"
cell-width="150px">
<template #day="{ scope }">
<div
v-if="filteredIntervals(scope.timestamp, scope.resource).value.length"
style="display: flex; flex-wrap: wrap; justify-content: space-evenly; align-items: center; font-size: 12px;">
<template
v-for="block in sortedIntervals(scope.timestamp, scope.resource).value"
:key="block.$id">
<q-chip class="cursor-pointer">
{{ date.formatDate(block.start, 'HH:mm') }} -
{{ date.formatDate(block.end, 'HH:mm') }}
</q-chip>
<q-btn size="xs" icon="delete" round @click="deleteBlock(block)" />
</template>
</div>
</template>
</q-calendar-scheduler>
</div>
</div>
<div class="q-pa-md" style="width: 400px">
<q-list padding bordered class="rounded-borders">
<q-item>
<q-item-section>
<q-item-label overline>Availability Templates</q-item-label>
<q-item-label caption>
Drag and drop a template to a boat / date to create booking availability
</q-item-label>
</q-item-section>
</q-item>
<q-card-actions align="right">
<q-btn label="Add Template" color="primary" @click="createTemplate" />
</q-card-actions>
<q-item v-if="newTemplate.$id === 'unsaved'">
<IntervalTemplateComponent
:model-value="newTemplate"
:edit="true"
@cancel="resetNewTemplate"
@saved="resetNewTemplate" />
</q-item>
<q-separator spaced />
<IntervalTemplateComponent
v-for="template in intervalTemplates"
:key="template.$id"
:model-value="template" />
</q-list>
</div>
<q-dialog v-model="alert">
<q-card>
<q-card-section>
<div class="text-h6">Warning!</div>
</q-card-section>
<q-card-section class="q-pt-none">
Conflicting times! Please delete overlapped items!
<q-chip v-for="item in overlapped" :key="item.index">
{{ boats.find((b) => b.$id === item.boatId)?.name }}:
{{ date.formatDate(item.start, 'hh:mm') }} -
{{ date.formatDate(item.end, 'hh:mm') }}
</q-chip>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="OK" color="primary" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
</div>
</template>

120
app/pages/schedule/view.vue Normal file
View File

@@ -0,0 +1,120 @@
<script setup lang="ts">
import { useReservationStore } from '~/stores/reservation';
import { ref } from 'vue';
import { useAuthStore } from '~/stores/auth';
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
import { getDate, QCalendarScheduler } from '@quasar/quasar-ui-qcalendar';
import type { Boat } from '~/utils/boat.types';
import NavigationBar from '~/components/scheduling/NavigationBar.vue';
import { useQuasar } from 'quasar';
import { formatTime } from '~/utils/schedule';
import { useIntervalStore } from '~/stores/interval';
import type { Interval, Reservation } from '~/utils/schedule.types';
import { storeToRefs } from 'pinia';
const reservationStore = useReservationStore();
const boatStore = useBoatStore();
const calendar = ref();
const $q = useQuasar();
const { getAvailableIntervals } = useIntervalStore();
const { selectedDate } = storeToRefs(useIntervalStore());
const currentUser = useAuthStore().currentUser;
const getSortedIntervals = (timestamp: Timestamp, boat?: Boat): Interval[] => {
return getAvailableIntervals(timestamp, boat)
.value.concat(boatReservationEvents(timestamp, boat))
.sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
};
const createReservationFromInterval = (interval: Interval | Reservation) => {
if (interval.user) {
if (interval.user === currentUser?.$id) {
navigateTo(`/schedule/edit/${interval.$id}`);
} else {
return false;
}
} else {
navigateTo({ path: '/schedule/book', query: { interval: interval.$id } });
}
};
function handleSwipe({ ...event }: { direction: string }) {
if (event.direction === 'right') {
calendar.value?.prev();
} else {
calendar.value?.next();
}
}
const boatReservationEvents = (
timestamp: Timestamp,
resource: Boat | undefined
): Reservation[] => {
if (!resource) return [] as Reservation[];
return reservationStore.getReservationsByDate(
getDate(timestamp),
(resource as Boat).$id
).value;
};
function onToday() { calendar.value.moveToToday(); }
function onPrev() { calendar.value.prev(); }
function onNext() { calendar.value.next(); }
</script>
<template>
<q-page>
<div class="col">
<navigation-bar @today="onToday" @prev="onPrev" @next="onNext" />
</div>
<div class="col q-ma-sm">
<q-calendar-scheduler
ref="calendar"
v-model="selectedDate"
v-model:model-resources="boatStore.boats"
resource-key="$id"
resource-label="displayName"
:weekdays="[1, 2, 3, 4, 5, 6, 0]"
:view="$q.screen.gt.md ? 'week' : 'day'"
v-touch-swipe.mouse.left.right="handleSwipe"
:max-days="$q.screen.lt.sm ? 3 : 7"
animated
bordered
style="--calendar-resources-width: 40px">
<template #day="{ scope }">
<div
v-for="interval in getSortedIntervals(scope.timestamp, scope.resource)"
:key="interval.$id"
class="q-pb-xs row"
@click="createReservationFromInterval(interval)">
<q-badge
multi-line
:class="!interval.user ? 'cursor-pointer' : null"
class="col-12 q-pa-sm"
:transparent="interval.user != undefined"
:color="interval.user ? 'secondary' : 'primary'"
:outline="!interval.user"
:id="interval.$id">
{{
interval.user
? useAuthStore().getUserNameById(interval.user)
: 'Available'
}}
<br />
{{ formatTime(interval.start) }} to
<br />
{{ formatTime(interval.end) }}
</q-badge>
</div>
</template>
</q-calendar-scheduler>
</div>
</q-page>
</template>
<style lang="sass">
.q-calendar-scheduler__resource
background-color: $primary
color: white
font-weight: bold
</style>

40
app/pages/signup.vue Normal file
View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
// NOTE: Password-based registration removed (magic link + OTP only).
// This page is a stub — registration is handled by admin invitation.
definePageMeta({ public: true, layout: false });
</script>
<template>
<q-layout>
<q-page-container>
<q-page class="flex bg-image flex-center">
<q-card v-bind:style="$q.screen.lt.sm ? { width: '80%' } : { width: '30%' }">
<q-card-section>
<q-img fit="scale-down" src="/oysqn_logo.png" />
</q-card-section>
<q-card-section>
<div class="text-center q-pt-sm">
<div class="text-h6">Sign Up</div>
<div class="text-body2 q-mt-md">
Account registration is managed by the club administrator.
Please contact your club admin to request access.
</div>
</div>
</q-card-section>
<q-card-section class="text-center">
<q-btn flat color="primary" label="Back to Login" to="/login" />
</q-card-section>
</q-card>
</q-page>
</q-page-container>
</q-layout>
</template>
<style>
.bg-image {
background-image: url('~/assets/oys_lighthouse.jpg');
background-repeat: no-repeat;
background-position-x: center;
background-size: cover;
}
</style>

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
definePageMeta({ public: true, layout: false });
</script>
<template>
<q-layout>
<q-page-container>
<q-page padding>
<h1>Website Terms and Conditions of Use</h1>
<h2>1. Terms</h2>
<p>
By accessing this Website, accessible from https://undock.ca, you are
agreeing to be bound by these Website Terms and Conditions of Use and
agree that you are responsible for the agreement with any applicable
local laws. If you disagree with any of these terms, you are
prohibited from accessing this site. The materials contained in this
Website are protected by copyright and trade mark law.
</p>
<h2>2. Use License</h2>
<p>
Permission is granted to temporarily download one copy of the
materials on undock.ca's Website for personal, non-commercial
transitory viewing only. This is the grant of a license, not a
transfer of title, and under this license you may not:
</p>
<ul>
<li>modify or copy the materials;</li>
<li>use the materials for any commercial purpose or for any public display;</li>
<li>attempt to reverse engineer any software contained on undock.ca's Website;</li>
<li>remove any copyright or other proprietary notations from the materials; or</li>
<li>transferring the materials to another person or "mirror" the materials on any other server.</li>
</ul>
<h2>3. Disclaimer</h2>
<p>
All the materials on undock.ca's Website are provided "as is". undock.ca makes no
warranties, may it be expressed or implied, therefore negates all other warranties.
</p>
<h2>4. Limitations</h2>
<p>
undock.ca or its suppliers will not be hold accountable for any damages that will
arise with the use or inability to use the materials on undock.ca's Website.
</p>
<h2>5. Revisions and Errata</h2>
<p>
The materials appearing on undock.ca's Website may include technical, typographical,
or photographic errors. undock.ca will not promise that any of the materials in this
Website are accurate, complete, or current.
</p>
<h2>6. Links</h2>
<p>
undock.ca has not reviewed all of the sites linked to its Website and is not
responsible for the contents of any such linked site.
</p>
<h2>7. Site Terms of Use Modifications</h2>
<p>
undock.ca may revise these Terms of Use for its Website at any time without prior notice.
</p>
<h2>8. Your Privacy</h2>
<p>Please read our <a href="/privacy-policy">Privacy Policy.</a></p>
<h2>9. Governing Law</h2>
<p>
Any claim related to undock.ca's Website shall be governed by the laws of ca without
regards to its conflict of law provisions.
</p>
</q-page>
</q-page-container>
</q-layout>
</template>

View File

@@ -0,0 +1,15 @@
import { useAuthStore } from '~/stores/auth';
import { initAppwriteClient } from '~/utils/appwrite';
export default defineNuxtPlugin(async () => {
const config = useRuntimeConfig();
const endpoint = config.public.appwriteEndpoint as string;
const projectId = config.public.appwriteProjectId as string;
if (!endpoint || !projectId) {
console.error('Appwrite config missing — check NUXT_PUBLIC_APPWRITE_ENDPOINT and NUXT_PUBLIC_APPWRITE_PROJECT_ID');
return;
}
initAppwriteClient(endpoint, projectId);
const authStore = useAuthStore();
await authStore.init();
});

111
app/stores/auth.ts Normal file
View File

@@ -0,0 +1,111 @@
import { defineStore } from 'pinia';
import { ID, account, functions, teams } from '~/utils/appwrite';
import { ExecutionMethod, type Models } from 'appwrite';
import { computed, ref } from 'vue';
import { useBoatStore } from './boat';
import { useReservationStore } from './reservation';
export const useAuthStore = defineStore('auth', () => {
const currentUser = ref<Models.User<Models.Preferences> | null>(null);
const currentUserTeams = ref<Models.TeamList<Models.Preferences> | null>(
null
);
const userNames = ref<Record<string, string>>({});
async function init() {
try {
currentUser.value = await account.get();
currentUserTeams.value = await teams.list();
await useBoatStore().fetchBoats();
await useReservationStore().fetchUserReservations();
} catch {
currentUser.value = null;
currentUserTeams.value = null;
}
}
const currentUserTeamNames = computed(() =>
currentUserTeams.value
? currentUserTeams.value.teams.map((team) => team.name)
: []
);
const hasRequiredRole = (requiredRoles: string[]): boolean => {
return requiredRoles.some((role) =>
currentUserTeamNames.value.includes(role)
);
};
async function createTokenSession(email: string) {
return await account.createEmailToken(ID.unique(), email);
}
async function createMagicURLSession(email: string) {
return await account.createMagicURLToken(
ID.unique(),
email,
window.location.origin + '/auth/callback'
);
}
async function tokenLogin(userId: string, token: string) {
await account.createSession(userId, token);
await init();
}
async function magicURLLogin(userId: string, secret: string) {
await account.updateMagicURLSession(userId, secret);
await init();
}
function getUserNameById(id: string | undefined | null): string {
if (!id) return 'No User';
try {
if (!userNames.value[id]) {
userNames.value[id] = 'Loading...';
functions
.createExecution(
'userinfo',
'',
false,
'/userinfo/' + id,
ExecutionMethod.GET
)
.then((res) => {
if (res.responseBody) {
userNames.value[id] = JSON.parse(res.responseBody).name;
} else {
console.error(res, id);
}
});
}
} catch (e) {
console.error('Failed to get username. Error: ' + e);
}
return userNames.value[id] ?? 'Unknown';
}
function logout() {
return account.deleteSession('current').then(() => {
currentUser.value = null;
});
}
async function updateName(name: string) {
await account.updateName(name);
currentUser.value = await account.get();
}
return {
currentUser,
getUserNameById,
hasRequiredRole,
updateName,
createTokenSession,
createMagicURLSession,
tokenLogin,
magicURLLogin,
logout,
init,
};
});

29
app/stores/boat.ts Normal file
View File

@@ -0,0 +1,29 @@
import { defineStore } from 'pinia';
import { AppwriteIds, databases } from '~/utils/appwrite';
import { ref } from 'vue';
import type { Boat } from '~/utils/boat.types';
export { type Boat } from '~/utils/boat.types';
export const useBoatStore = defineStore('boat', () => {
const boats = ref<Boat[]>([]);
async function fetchBoats() {
try {
const response = await databases.listDocuments(
AppwriteIds.databaseId,
AppwriteIds.collection.boat
);
boats.value = response.documents as unknown as Boat[];
} catch (error) {
console.error('Failed to fetch boats', error);
}
}
const getBoatById = (id: string | null | undefined): Boat | null => {
if (!id) return null;
return boats.value?.find((b) => b.$id === id) || null;
};
return { boats, fetchBoats, getBoatById };
});

163
app/stores/interval.ts Normal file
View File

@@ -0,0 +1,163 @@
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import type { Boat } from '~/utils/boat.types';
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
import { today } from '@quasar/quasar-ui-qcalendar';
import type { Interval } from '~/utils/schedule.types';
import { AppwriteIds, databases } from '~/utils/appwrite';
import { ID, Query } from 'appwrite';
import { useReservationStore } from './reservation';
import type { LoadingTypes } from '~/utils/misc';
import { useRealtimeStore } from './realtime';
export const useIntervalStore = defineStore('interval', () => {
const intervals = ref(new Map<string, Interval>());
const dateStatus = ref(new Map<string, LoadingTypes>());
const selectedDate = ref<string>(today());
const reservationStore = useReservationStore();
const realtimeStore = useRealtimeStore();
realtimeStore.register(
`databases.${AppwriteIds.databaseId}.collections.${AppwriteIds.collection.interval}.documents`,
(response) => {
const payload = response.payload as unknown as Interval;
if (!payload.$id) return;
if (
response.events.includes('databases.*.collections.*.documents.*.delete')
) {
intervals.value.delete(payload.$id);
} else {
intervals.value.set(payload.$id, payload);
}
}
);
const getIntervals = (date: Timestamp | string, boat?: Boat) => {
const searchDate = typeof date === 'string' ? date : date.date;
const dayStart = new Date(searchDate + 'T00:00');
const dayEnd = new Date(searchDate + 'T23:59');
if (dateStatus.value.get(searchDate) === undefined) {
dateStatus.value.set(searchDate, 'pending');
fetchIntervals(searchDate);
}
return computed(() => {
return Array.from(intervals.value.values()).filter((interval) => {
const intervalStart = new Date(interval.start);
const intervalEnd = new Date(interval.end);
const isWithinDay = intervalStart < dayEnd && intervalEnd > dayStart;
const matchesBoat = boat ? boat.$id === interval.resource : true;
return isWithinDay && matchesBoat;
});
});
};
const getAvailableIntervals = (date: Timestamp | string, boat?: Boat) => {
return computed(() =>
getIntervals(date, boat).value.filter((interval) => {
return !reservationStore.isResourceTimeOverlapped(
interval.resource,
new Date(interval.start),
new Date(interval.end)
);
})
);
};
async function fetchInterval(id: string): Promise<Interval> {
return (await databases.getDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.interval,
id
)) as Interval;
}
async function fetchIntervals(dateString: string) {
try {
const response = await databases.listDocuments(
AppwriteIds.databaseId,
AppwriteIds.collection.interval,
[
Query.greaterThanEqual(
'end',
new Date(dateString + 'T00:00').toISOString()
),
Query.lessThanEqual(
'start',
new Date(dateString + 'T23:59').toISOString()
),
Query.limit(50),
]
);
response.documents.forEach((d) =>
intervals.value.set(d.$id, d as unknown as Interval)
);
dateStatus.value.set(dateString, 'loaded');
console.info(`Loaded ${response.documents.length} intervals from server`);
} catch (error) {
console.error('Failed to fetch intervals', error);
dateStatus.value.set(dateString, 'error');
}
}
const createInterval = async (interval: Interval) => {
try {
const response = await databases.createDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.interval,
ID.unique(),
interval
);
intervals.value.set(response.$id, response as unknown as Interval);
} catch (e) {
console.error('Error creating Interval: ' + e);
}
};
const updateInterval = async (interval: Interval) => {
try {
if (interval.$id) {
const response = await databases.updateDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.interval,
interval.$id,
{ ...interval, $id: undefined }
);
intervals.value.set(response.$id, response as unknown as Interval);
console.info(`Saved Interval: ${interval.$id}`);
} else {
console.error('Update interval called without an ID');
}
} catch (e) {
console.error('Error updating Interval: ' + e);
}
};
const deleteInterval = async (id: string) => {
try {
await databases.deleteDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.interval,
id
);
intervals.value.delete(id);
console.info(`Deleted interval: ${id}`);
} catch (e) {
console.error('Error deleting Interval: ' + e);
}
};
return {
getIntervals,
getAvailableIntervals,
fetchIntervals,
fetchInterval,
createInterval,
updateInterval,
deleteInterval,
selectedDate,
intervals,
};
});

View File

@@ -0,0 +1,101 @@
import type { Ref } from 'vue';
import { ref } from 'vue';
import type { IntervalTemplate } from '~/utils/schedule.types';
import { defineStore } from 'pinia';
import { AppwriteIds, databases } from '~/utils/appwrite';
import type { Models } from 'appwrite';
import { ID } from 'appwrite';
import { arrayToTimeTuples } from '~/utils/schedule';
export const useIntervalTemplateStore = defineStore('intervalTemplate', () => {
const intervalTemplates = ref<IntervalTemplate[]>([]);
const getIntervalTemplates = (): Ref<IntervalTemplate[]> => {
if (!intervalTemplates.value) fetchIntervalTemplates();
return intervalTemplates;
};
async function fetchIntervalTemplates() {
try {
const response = await databases.listDocuments(
AppwriteIds.databaseId,
AppwriteIds.collection.intervalTemplate
);
intervalTemplates.value = response.documents.map((d): IntervalTemplate => {
const doc = d as unknown as { timeTuple: string[] } & Models.Document;
return {
...doc,
timeTuples: arrayToTimeTuples(doc.timeTuple),
} as unknown as IntervalTemplate;
});
} catch (error) {
console.error('Failed to fetch timeblock templates', error);
}
}
const createIntervalTemplate = async (template: IntervalTemplate) => {
try {
const response = await databases.createDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.intervalTemplate,
ID.unique(),
{ name: template.name, timeTuple: template.timeTuples.flat(2) }
);
intervalTemplates.value.push(response as unknown as IntervalTemplate);
} catch (e) {
console.error('Error creating IntervalTemplate: ' + e);
}
};
const deleteIntervalTemplate = async (id: string) => {
try {
await databases.deleteDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.intervalTemplate,
id
);
intervalTemplates.value = intervalTemplates.value.filter(
(template) => template.$id !== id
);
} catch (e) {
console.error('Error deleting IntervalTemplate: ' + e);
}
};
const updateIntervalTemplate = async (
template: IntervalTemplate,
id: string
) => {
try {
const response = await databases.updateDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.intervalTemplate,
id,
{
name: template.name,
timeTuple: template.timeTuples.flat(2),
}
);
intervalTemplates.value = intervalTemplates.value.map((b) =>
b.$id !== id
? b
: ({
...response,
timeTuples: arrayToTimeTuples(
(response as unknown as { timeTuple: string[] }).timeTuple
),
} as unknown as IntervalTemplate)
);
} catch (e) {
console.error('Error updating IntervalTemplate: ' + e);
}
};
return {
getIntervalTemplates,
fetchIntervalTemplates,
createIntervalTemplate,
deleteIntervalTemplate,
updateIntervalTemplate,
};
});

View File

@@ -0,0 +1,21 @@
import { defineStore } from 'pinia';
export interface MemberProfile {
firstName: string;
lastName: string;
certs: string[];
slackID: string;
userID: string;
}
const getSampleData = () => ({
firstName: 'Billy',
lastName: 'Crystal',
certs: ['j27', 'capri25'],
});
export const useMemberProfileStore = defineStore('memberProfile', {
state: () => ({
...getSampleData(),
}),
});

20
app/stores/realtime.ts Normal file
View File

@@ -0,0 +1,20 @@
import { defineStore } from 'pinia';
import { client } from '~/utils/appwrite';
import { ref } from 'vue';
import type { RealtimeResponseEvent } from 'appwrite';
export const useRealtimeStore = defineStore('realtime', () => {
const subscriptions = ref<Map<string, () => void>>(new Map());
const register = (
channel: string,
fn: (response: RealtimeResponseEvent<unknown>) => void
) => {
if (subscriptions.value.has(channel)) return;
subscriptions.value.set(channel, client.subscribe(channel, fn));
};
return {
register,
};
});

123
app/stores/reference.ts Normal file
View File

@@ -0,0 +1,123 @@
import { defineStore } from 'pinia';
export interface ReferenceEntry {
id: number;
title: string;
category: string;
tags?: string[];
subtitle?: string;
content: string;
}
function getSampleData(): ReferenceEntry[] {
return [
{
id: 1,
title: 'J/27 Background',
category: 'general',
tags: ['j27', 'info'],
subtitle: 'Fast Fun Racer or Getaway Weekend Cruiser',
content: `Its hard to imagine that a modern 27 foot sailboat with a classic look, superb
stability, and easy to manage rig is such a fast boat. 123-126 PHRF. No longer do you
have to substitute speed for comfort, or own separate boats for racing and cruising. The
8' long cockpit seats 4 to 5 comfortably. Below deck you can sleep 5. And with head and
stove, the J/27 is the perfect weekend cruiser.
Fun and Fast. There are some impressive victories to back this up, but that doesn't
tell the whole story. The J/27 is fun and responsive. Nothing is more exhilarating than
popping the J/27's kite in a good breeze for a downhill sleigh ride. 15+ knots planing
off the wave-tops is easy. And most importantly, this off-wind speed doesn't sacrifice
upwind performance. Going to windward in the J/27 is a dream, it has the solid, balanced
"feel" of a traditional keelboat. The J/27 points higher and goes faster than many 30-35
footers!
One-Design Racing. Even more fun is sailing a one-design race around the buoys. The
J/27's close-windedness makes it very tactical, as even 5 degree wind shifts bring
significant gains. Then off wind, you quickly learn to play gibe angles as the boat's
acceleration gains you valuable ground on the competition. The J/27 is remarkably agile
and responsive in lighter winds, which is unusual for a boat that feels so solid.
All-Day Comfort. Sailing past larger boats is always satisfying... especially when it's
effortless and you can't be written off as being wet and uncomfortable. Design is the
difference. It's all done from a cockpit which holds several people more than is possible
on other 27-footers. Correctly angled backrests and decks at elbow level provide restful
and secure seating. Harken mainsheet, vang, traveler, and backstay systems; four Barient
winches; a beautiful double spreader, tapered, fractional rig spar by Hall . . . make
control and adjustment easy for crew members no matter what the wind.
Get-away Weekend Cruiser. Take a break from the pace of life on land and spend time with
family and friends sailing the J/27. It's a fun boat to sail, so everyone becomes involved.
The visibility, when steering with a responsive tiller gives the inexperienced that sense
of control not found when spinning a tiny wheel on small cruisers with large trunk cabins.
The J/27 has a comfortable, open interior in teak with off-white surfaces. A main structural
fiberglass bulkhead with oval opening separates the spacious double V-berth and head area
from the main cabin. The main settee berth converts to a double. Aft of the galley to
starboard is a comfortable quarter berth. Enough room below for a family of four or a
couple for a nice weekend romp to your favorite sailing anchorage.
Durable and Stable. The J/27's secure big boat feel is created by concentrating 1530
pounds of lead very low in the keel while using high strength to eight ratio laminates
in the hull. Unidirectional E-glass on either side of pre-sealed Baltek CK57 aircraft
grade, Lloyd's approved, end grain balsa sandwich construction means superior torsion and
impact resistance. Light ends, low freeboard, and the low center of gravity of a lead keel
coupled with low wetted surface and a generous sailplan of 362 sq. ft. achieves exceptional
sail area and stability relative to displacement. Hence, sparkling performance in both
light and heavy air...something that doesn't happen with iron keels and box-like hulls.
Strong Class Strict Rules. The J/27 Class Association, owner driven and over 190 boats
strong, sail in North American, Midwinter, and Regional championships. A superb J/27 Class
Newsletter keeps you up-to-date on Class activities, latest results, maintenance tips,
cruising points of interest, and "go-fasts". And the J/27 Class Rules have sail limitations
to help insure equal performance and resale value. The Class supports both the active
racer and cruising sailor in addition to fleets throughout the U.S.`,
},
{
id: 2,
title: 'Capri25 Background',
category: 'general',
tags: ['capri25', 'info'],
subtitle: 'The Capri 25 (by Catalina) is nothing like a Catalina 25',
content: `The Capri 25 (by Catalina) is nothing like a Catalina 25 ... The Capri
is five inches shorter on deck, three feet shorter on the waterline, and weighs
almost 1,400 pounds less than the Catalina, so we suppose you could call her a Catalina
"Lite," especially since her towing weight is over a ton less, so you can use a smaller,
lighter towing vehicle on the highway. Besides her lower weight, she has slightly more
sail area and a sleeker fin keel, so she is also faster—way faster. In fact, her average
PHRF rating is 171, which is, amazingly, 3 seconds per mile less than the legendary J/24,
and a whopping 54 seconds less than the Catalina 25. Needless to say, part of her weight
loss is accomplished by the omission of cabin furniture and other niceties like the
Catalina's on-deck anchor locker. Other weight saving is achieved by eliminating 600
pounds of ballast, and by using a then-new material, Coremat, to replace some of the
hull and deck laminate. Best features: If you like round-the-buoys racing and/or
socializing in a one-design fleet, this may be the boat for you. She has a bit more space
below than a J/24, and six inches more headroom, but otherwise her character is in the
same range. Worst features: Nothing significant noticed."`,
},
{
id: 3,
title: 'Outboard Engine Operation',
subtitle: 'An overview of how outboard engines work.',
category: 'howto',
tags: ['manuals', 'howto', 'engine'],
content: 'Lorem ipsum dolor met.',
},
] as ReferenceEntry[];
}
export const useReferenceStore = defineStore('reference', {
state: () => ({
allItems: getSampleData(),
}),
getters: {
getCategory(state) {
return (category: string) => {
return state.allItems.filter((c) => c.category === category);
};
},
},
actions: {},
});

283
app/stores/reservation.ts Normal file
View File

@@ -0,0 +1,283 @@
import { defineStore } from 'pinia';
import type { Reservation } from '~/utils/schedule.types';
import type { ComputedRef } from 'vue';
import { computed, reactive } from 'vue';
import { AppwriteIds, databases } from '~/utils/appwrite';
import { ID, Query } from 'appwrite';
import { date, useQuasar } from 'quasar';
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
import { parseDate, today } from '@quasar/quasar-ui-qcalendar';
import type { LoadingTypes } from '~/utils/misc';
import { useAuthStore } from './auth';
import { isPast } from '~/utils/schedule';
import { useRealtimeStore } from './realtime';
export const useReservationStore = defineStore('reservation', () => {
const reservations = reactive<Map<string, Reservation>>(new Map());
const datesLoaded = reactive<Record<string, LoadingTypes>>({});
const userReservations = reactive<Map<string, Reservation>>(new Map());
const authStore = useAuthStore();
const $q = useQuasar();
const realtimeStore = useRealtimeStore();
realtimeStore.register(
`databases.${AppwriteIds.databaseId}.collections.${AppwriteIds.collection.reservation}.documents`,
(response) => {
const payload = response.payload as unknown as Reservation;
if (payload.$id) {
if (
response.events.includes(
'databases.*.collections.*.documents.*.delete'
)
) {
reservations.delete(payload.$id);
userReservations.delete(payload.$id);
} else {
reservations.set(payload.$id, payload);
if (payload.user === authStore.currentUser?.$id)
userReservations.set(payload.$id, payload);
}
}
}
);
const fetchReservationsForDateRange = async (
start: string = today(),
end: string = start
) => {
const startDate = new Date(start < end ? start : end + 'T00:00');
const endDate = new Date(start < end ? end : start + 'T23:59');
if (getUnloadedDates(startDate, endDate).length === 0) return;
setDateLoaded(startDate, endDate, 'pending');
try {
const response = await databases.listDocuments(
AppwriteIds.databaseId,
AppwriteIds.collection.reservation,
[
Query.greaterThanEqual('end', startDate.toISOString()),
Query.lessThanEqual('start', endDate.toISOString()),
]
);
response.documents.forEach((d) =>
reservations.set(d.$id, d as unknown as Reservation)
);
setDateLoaded(startDate, endDate, 'loaded');
} catch (error) {
console.error('Failed to fetch reservations', error);
setDateLoaded(startDate, endDate, 'error');
}
};
const getReservationById = async (id: string) => {
try {
const response = await databases.getDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.reservation,
id
);
return response as unknown as Reservation;
} catch (error) {
console.error('Failed to fetch reservation: ', error);
}
};
const createOrUpdateReservation = async (
reservation: Reservation
): Promise<Reservation> => {
let response;
try {
if (reservation.$id) {
response = await databases.updateDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.reservation,
reservation.$id,
reservation
);
} else {
response = await databases.createDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.reservation,
ID.unique(),
reservation
);
}
reservations.set(response.$id, response as unknown as Reservation);
userReservations.set(response.$id, response as unknown as Reservation);
console.info('Reservation booked: ', response);
return response as unknown as Reservation;
} catch (e) {
console.error('Error creating Reservation: ' + e);
throw e;
}
};
const deleteReservation = async (
reservation: string | Reservation | null | undefined
) => {
if (!reservation) return false;
const id = typeof reservation === 'string' ? reservation : reservation.$id;
if (!id) return false;
const status = $q.notify({
color: 'secondary',
textColor: 'white',
message: 'Deleting Reservation',
spinner: true,
closeBtn: 'Dismiss',
position: 'top',
timeout: 0,
group: false,
});
try {
await databases.deleteDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.reservation,
id
);
reservations.delete(id);
userReservations.delete(id);
console.info(`Deleted reservation: ${id}`);
status({
color: 'warning',
message: 'Reservation Deleted',
spinner: false,
icon: 'delete',
timeout: 4000,
});
} catch (e) {
console.error('Error deleting reservation: ' + e);
status({
color: 'negative',
message: 'Failed to Delete Reservation',
spinner: false,
icon: 'error',
});
}
};
const setDateLoaded = (start: Date, end: Date, state: LoadingTypes) => {
if (start > end) return [];
let curDate = start;
while (curDate < end) {
datesLoaded[(parseDate(curDate) as Timestamp).date] = state;
curDate = date.addToDate(curDate, { days: 1 });
}
};
const getUnloadedDates = (start: Date, end: Date): string[] => {
if (start > end) return [];
let curDate = start;
const unloaded = [];
while (curDate < end) {
const parsedDate = (parseDate(curDate) as Timestamp).date;
if (datesLoaded[parsedDate] === undefined) unloaded.push(parsedDate);
curDate = date.addToDate(curDate, { days: 1 });
}
return unloaded;
};
const getReservationsByDate = (
searchDate: string,
boat?: string
): ComputedRef<Reservation[]> => {
if (!datesLoaded[searchDate]) {
fetchReservationsForDateRange(searchDate);
}
const dayStart = new Date(searchDate + 'T00:00');
const dayEnd = new Date(searchDate + 'T23:59');
return computed(() => {
return Array.from(reservations.values()).filter((reservation) => {
const reservationStart = new Date(reservation.start);
const reservationEnd = new Date(reservation.end);
const isWithinDay =
reservationStart < dayEnd && reservationEnd > dayStart;
const matchesBoat = boat ? boat === reservation.resource : true;
return isWithinDay && matchesBoat;
});
});
};
const getConflictingReservations = (
resource: string,
start: Date,
end: Date
): Reservation[] => {
return Array.from(reservations.values()).filter(
(entry) =>
entry.resource === resource &&
new Date(entry.start) < end &&
new Date(entry.end) > start
);
};
const isResourceTimeOverlapped = (
resource: string,
start: Date,
end: Date
): boolean => {
return getConflictingReservations(resource, start, end).length > 0;
};
const isReservationOverlapped = (res: Reservation): boolean => {
return isResourceTimeOverlapped(
res.resource,
new Date(res.start),
new Date(res.end)
);
};
const fetchUserReservations = async () => {
if (!authStore.currentUser) return;
try {
const response = await databases.listDocuments(
AppwriteIds.databaseId,
AppwriteIds.collection.reservation,
[Query.equal('user', authStore.currentUser.$id)]
);
response.documents.forEach((d) =>
userReservations.set(d.$id, d as unknown as Reservation)
);
} catch (error) {
console.error('Failed to fetch reservations for user: ', error);
}
};
const sortedUserReservations = computed((): Reservation[] =>
[...userReservations.values()].sort(
(a, b) => new Date(b.start).getTime() - new Date(a.start).getTime()
)
);
const futureUserReservations = computed((): Reservation[] => {
if (!sortedUserReservations.value) return [];
return sortedUserReservations.value.filter((b) => !isPast(b.end));
});
const pastUserReservations = computed((): Reservation[] => {
if (!sortedUserReservations.value) return [];
return sortedUserReservations.value?.filter((b) => isPast(b.end));
});
return {
getReservationsByDate,
getReservationById,
createOrUpdateReservation,
deleteReservation,
fetchReservationsForDateRange,
isReservationOverlapped,
isResourceTimeOverlapped,
getConflictingReservations,
fetchUserReservations,
sortedUserReservations,
futureUserReservations,
pastUserReservations,
userReservations,
};
});

41
app/utils/appwrite.ts Normal file
View File

@@ -0,0 +1,41 @@
import { Client, Account, Databases, Functions, ID, Teams } from 'appwrite';
const client = new Client();
function initAppwriteClient(endpoint: string, projectId: string) {
client.setEndpoint(endpoint).setProject(projectId);
}
type AppwriteIDConfig = {
databaseId: string;
collection: {
boat: string;
reservation: string;
interval: string;
intervalTemplate: string;
// task, taskTags, skillTags — parked; collections not yet created in bab_prod
};
function: {
userinfo: string;
};
};
const AppwriteIds: AppwriteIDConfig = {
databaseId: 'bab_prod',
collection: {
boat: 'boat',
reservation: 'reservation',
interval: 'interval',
intervalTemplate: 'intervalTemplate',
},
function: {
userinfo: 'userinfo',
},
};
const account = new Account(client);
const databases = new Databases(client);
const functions = new Functions(client);
const teams = new Teams(client);
export { client, account, databases, functions, teams, ID, AppwriteIds, initAppwriteClient };

20
app/utils/boat.types.ts Normal file
View File

@@ -0,0 +1,20 @@
import type { Models } from 'appwrite';
export interface Boat extends Models.Document {
$id: string;
name: string;
displayName?: string;
class?: string;
year?: number;
imgSrc?: string;
iconSrc?: string;
bookingAvailable: boolean;
requiredCerts: string[];
maxPassengers: number;
defects: {
type: string;
severity: string;
description: string;
detail?: string;
}[];
}

7
app/utils/misc.ts Normal file
View File

@@ -0,0 +1,7 @@
export function getNewId(): string {
return [...Array(20)]
.map(() => Math.floor(Math.random() * 16).toString(16))
.join('');
}
export type LoadingTypes = 'loaded' | 'pending' | 'error' | undefined;

112
app/utils/navlinks.ts Normal file
View File

@@ -0,0 +1,112 @@
import { useAuthStore } from '~/stores/auth';
export type Link = {
name: string;
to?: string;
icon: string;
front_links?: boolean;
enabled?: boolean;
color?: string;
sublinks?: Link[];
requiredRoles?: string[];
};
export const links: Link[] = [
{
name: 'Home',
to: '/',
icon: 'home',
front_links: false,
enabled: true,
},
{
name: 'Profile',
to: '/profile',
icon: 'account_circle',
front_links: false,
enabled: true,
},
{
name: 'Boats',
to: '/boat',
icon: 'sailing',
front_links: true,
enabled: true,
},
{
name: 'Schedule',
to: '/schedule',
icon: 'calendar_month',
front_links: true,
enabled: true,
sublinks: [
{ name: 'My View', to: '/schedule/list', icon: 'list', front_links: false, enabled: true },
{ name: 'Book', to: '/schedule/book', icon: 'more_time', front_links: false, enabled: true },
{ name: 'Calendar', to: '/schedule/view', icon: 'calendar_month', front_links: false, enabled: true },
],
},
{
name: 'Certifications',
to: '/certification',
icon: 'verified',
front_links: true,
enabled: false,
},
{
name: 'Checklists',
to: '/checklist',
icon: 'checklist',
front_links: true,
enabled: false,
},
{
name: 'Reference',
to: '/reference',
icon: 'info_outline',
front_links: true,
enabled: false,
},
{
name: 'Manage',
icon: 'tune',
enabled: true,
requiredRoles: ['Schedule Admins'],
color: 'negative',
sublinks: [
{
name: 'Schedule',
to: '/schedule/manage',
icon: 'edit_calendar',
front_links: false,
enabled: true,
color: 'accent',
requiredRoles: ['Schedule Admins'],
},
],
},
];
export function useNavLinks() {
const authStore = useAuthStore();
function hasRole(roles: string[] | undefined) {
if (roles === undefined) return true;
return authStore.hasRequiredRole(roles);
}
const enabledLinks = links
.filter((link) => link.enabled)
.map((link) => {
if (link.sublinks) {
return {
...link,
sublinks: link.sublinks.filter(
(sublink) => sublink.enabled && hasRole(sublink.requiredRoles)
),
};
}
return link;
});
return { enabledLinks };
}

75
app/utils/schedule.ts Normal file
View File

@@ -0,0 +1,75 @@
import { date } from 'quasar';
import type { Boat } from '~/utils/boat.types';
import type { Interval, IntervalTemplate, TimeTuple } from '~/utils/schedule.types';
export function arrayToTimeTuples(arr: string[]) {
const timeTuples: TimeTuple[] = [];
for (let i = 0; i < arr.length; i += 2) {
timeTuples.push([arr[i]!, arr[i + 1]!]);
}
return timeTuples;
}
export function timeTuplesOverlapped(tuples: TimeTuple[]): Interval[] {
return intervalsOverlapped(
tuples.map((tuples) => {
return {
resource: '',
start: '01/01/2001 ' + tuples[0],
end: '01/01/2001 ' + tuples[1],
};
})
).map((t) => {
return { ...t, start: t.start.split(' ')[1]!, end: t.end.split(' ')[1]! };
});
}
export function intervalsOverlapped(blocks: Interval[]): Interval[] {
return Array.from(
new Set(
blocks
.sort((a, b) => Date.parse(a.start) - Date.parse(b.start))
.reduce((acc: Interval[], block, i, arr) => {
if (i > 0 && block.start < arr[i - 1]!.end)
acc.push(arr[i - 1]!, block);
return acc;
}, [])
)
);
}
export function copyTimeTuples(tuples: TimeTuple[]): TimeTuple[] {
return tuples.map((t) => Object.assign([], t));
}
export function copyIntervalTemplate(template: IntervalTemplate): IntervalTemplate {
return {
...template,
timeTuples: copyTimeTuples(template.timeTuples || []),
};
}
export function buildInterval(resource: Boat, time: TimeTuple, blockDate: string): Interval {
return {
resource: resource.$id,
start: new Date(blockDate + 'T' + time[0]).toISOString(),
end: new Date(blockDate + 'T' + time[1]).toISOString(),
};
}
export const isPast = (itemDate: Date | string): boolean => {
if (!(itemDate instanceof Date)) {
itemDate = new Date(itemDate);
}
return itemDate < new Date();
};
export function formatDate(inputDate: string | undefined): string {
if (!inputDate) return '';
return date.formatDate(new Date(inputDate), 'ddd MMM Do hh:mm A');
}
export function formatTime(inputDate: string | undefined): string {
if (!inputDate) return '';
return date.formatDate(new Date(inputDate), 'hh:mm A');
}

View File

@@ -0,0 +1,28 @@
import type { Models } from 'appwrite';
export type StatusTypes = 'tentative' | 'confirmed' | 'pending' | undefined;
export type Reservation = Interval & {
user: string;
status?: StatusTypes;
reason: string;
comment: string;
members?: string[];
guests?: string[];
};
// 24 hrs in advance only 2 weekday, and 1 weekend slot
// Within 24 hrs, any available slot
export type TimeTuple = [start: string, end: string];
export type Interval = Partial<Models.Document> & {
resource: string;
start: string;
end: string;
user?: string;
};
export type IntervalTemplate = Partial<Models.Document> & {
name: string;
timeTuples: TimeTuple[];
};

1
app/utils/version.ts Normal file
View File

@@ -0,0 +1 @@
export const APP_VERSION = '0.0.0';