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

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>