Visual improvements

This commit is contained in:
2024-05-24 08:11:47 -04:00
parent ce696a5a04
commit 68a2b8ffff
13 changed files with 441 additions and 162 deletions

View File

@@ -53,7 +53,7 @@ module.exports = configure(function (/* ctx */) {
browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'],
node: 'node16',
},
vueRouterMode: 'hash', // available values: 'hash', 'history'
vueRouterMode: 'history', // available values: 'hash', 'history'
// vueRouterBase,
// vueDevtools,
// vueOptionsAPI: false,

View File

@@ -14,25 +14,25 @@ import type { Router } from 'vue-router';
const client = new Client();
// appwrite.io SaaS
// client
// .setEndpoint('https://api.bab.toal.ca/v1')
// .setProject('653ef6f76baf06d68034');
// const appDatabaseId = '654ac5044d1c446feb71';
let APPWRITE_API_ENDPOINT, APPWRITE_API_PROJECT;
// Private self-hosted appwrite
if (process.env.APPWRITE_API_ENDPOINT && process.env.APPWRITE_API_PROJECT)
client
.setEndpoint(process.env.APPWRITE_API_ENDPOINT)
.setProject(process.env.APPWRITE_API_PROJECT);
else if (process.env.DEV) {
client
.setEndpoint('http://localhost:4000/api/v1')
.setProject('65ede55a213134f2b688');
if (process.env.APPWRITE_API_ENDPOINT && process.env.APPWRITE_API_PROJECT) {
APPWRITE_API_ENDPOINT = process.env.APPWRITE_API_ENDPOINT;
APPWRITE_API_PROJECT = process.env.APPWRITE_API_PROJECT;
} else if (process.env.DEV) {
APPWRITE_API_ENDPOINT = 'http://localhost:4000/api/v1';
APPWRITE_API_PROJECT = '65ede55a213134f2b688';
} else {
client.setEndpoint('https://appwrite.oys.undock.ca/v1').setProject('bab');
APPWRITE_API_ENDPOINT = 'https://appwrite.oys.undock.ca/v1';
APPWRITE_API_PROJECT = 'bab';
}
//TODO move this to config file
client.setEndpoint(APPWRITE_API_ENDPOINT).setProject(APPWRITE_API_PROJECT);
const pwresetUrl = process.env.DEV
? 'http://localhost:4000/pwreset'
: 'https://oys.undock.ca/pwreset';
const AppwriteIds = process.env.DEV
? {
databaseId: '65ee1cbf9c2493faf15f',
@@ -121,20 +121,36 @@ async function login(email: string, password: string) {
console.log('Redirecting to index page');
appRouter.replace({ name: 'index' });
} catch (error: unknown) {
notification({
type: 'negative',
message: 'Login failed.',
timeout: 2000,
});
if (error instanceof AppwriteException) {
if (error.type === 'user_session_already_exists') {
appRouter.replace({ name: 'index' });
notification({
type: 'positive',
message: 'Already Logged in!',
timeout: 2000,
spinner: false,
icon: 'check_circle',
});
return;
}
Dialog.create({
title: 'Login Error!',
message: error.message,
persistent: true,
});
}
notification({
type: 'negative',
message: 'Login failed.',
timeout: 2000,
});
}
}
async function resetPassword(email: string) {
await account.createRecovery(email, pwresetUrl);
}
export {
client,
account,
@@ -145,4 +161,5 @@ export {
AppwriteIds,
login,
logout,
resetPassword,
};

View File

@@ -31,8 +31,7 @@
clickable
v-ripple
:to="sublink.to"
class="q-ml-md"
v-if="hasRole(sublink.requiredRoles)">
class="q-ml-md">
<q-item-section avatar>
<q-icon :name="sublink.icon" />
</q-item-section>
@@ -57,17 +56,8 @@
<script lang="ts" setup>
import { defineComponent } from 'vue';
import { enabledLinks } from 'src/router/navlinks.js';
import { useAuthStore } from 'src/stores/auth';
import { logout } from 'boot/appwrite';
const authStore = useAuthStore();
function hasRole(roles: string[] | undefined) {
if (roles === undefined) return true;
const hasRole = authStore.hasRequiredRole(roles);
return hasRole;
}
defineProps(['drawer']);
defineEmits(['drawer-toggle']);

View File

@@ -71,7 +71,7 @@
">
{{ getUserName(reservation.user) || 'loading...' }}
<br />
<q-chip icon="key">{{ reservation.reason }}</q-chip>
<q-chip>{{ reservation.reason }}</q-chip>
</div>
</div>
</template>

View File

@@ -3,47 +3,54 @@
<q-page-container>
<q-page class="flex bg-image flex-center">
<q-card
v-bind:style="$q.screen.lt.sm ? { width: '80%' } : { width: '30%' }"
>
v-bind:style="$q.screen.lt.sm ? { width: '80%' } : { width: '30%' }">
<q-card-section>
<q-img fit="scale-down" src="~assets/oysqn_logo.png" />
<q-img
fit="scale-down"
src="~assets/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-card-section>
<q-form class="q-gutter-md">
<q-form>
<q-card-section class="q-gutter-md">
<q-input
v-model="email"
label="E-Mail"
type="email"
color="darkblue"
filled
></q-input>
filled></q-input>
<q-input
v-model="password"
label="Password"
type="password"
color="darkblue"
filled
></q-input>
<q-btn
type="button"
@click="doLogin"
label="Login"
color="primary"
></q-btn>
<!-- <q-btn
filled></q-input>
<q-card-actions>
<q-btn
type="button"
@click="doLogin"
label="Login"
color="primary"></q-btn>
<q-space />
<q-btn
flat
color="secondary"
to="/pwreset">
Reset password
</q-btn>
<!-- <q-btn
type="button"
@click="register"
color="secondary"
label="Register"
flat
></q-btn> -->
</q-form>
</q-card-section>
</q-card-actions>
</q-card-section>
</q-form>
<!-- <q-card-section><GoogleOauthComponent /></q-card-section> -->
</q-card>
</q-page>

190
src/pages/ResetPassword.vue Normal file
View File

@@ -0,0 +1,190 @@
<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="~assets/oysqn_logo.png" />
</q-card-section>
<q-card-section>
<div class="text-center q-pt-sm">
<div class="col text-h6">Reset Password</div>
</div>
</q-card-section>
<q-form v-if="!isPasswordResetLink()">
<q-card-section class="q-ma-sm">
<q-input
v-model="email"
label="E-Mail"
type="email"
color="darkblue"
@keydown.enter.prevent="resetPw"
filled></q-input>
<div class="text-caption q-py-md">
Enter your e-mail address. If we have an account with that
address on file, you will be e-mailed a link to reset your
password.
</div>
<q-card-actions>
<q-btn
type="button"
@click="resetPw"
label="Send Reset Link"
color="primary"></q-btn>
<!-- <q-btn
type="button"
@click="register"
color="secondary"
label="Register"
flat
></q-btn> -->
</q-card-actions>
</q-card-section>
</q-form>
<q-form
@submit="submitNewPw"
v-else-if="validResetLink()">
<q-card-section class="q-ma-sm">
<q-input
v-model="password"
label="New Password"
type="password"
color="darkblue"
:rules="[validatePasswordStrength]"
lazy-rules
filled></q-input>
<q-input
v-model="confirmPassword"
label="Confirm New Password"
type="password"
color="darkblue"
:rules="[validatePasswordStrength]"
lazy-rules
filled></q-input>
<div class="text-caption q-py-md">Enter a new password.</div>
</q-card-section>
<q-card-actions>
<q-btn
type="submit"
label="Reset Password"
color="primary"></q-btn>
<!-- <q-btn
type="button"
@click="register"
color="secondary"
label="Register"
flat
></q-btn> -->
</q-card-actions>
</q-form>
<q-card
v-else
class="text-center">
<span class="text-h5">Invalid reset link.</span>
</q-card>
<!-- <q-card-section><GoogleOauthComponent /></q-card-section> -->
</q-card>
</q-page>
</q-page-container>
</q-layout>
</template>
<style>
.bg-image {
background-image: url('/src/assets/oys_lighthouse.jpg');
background-repeat: no-repeat;
background-position-x: center;
background-size: cover;
/* background-image: linear-gradient(
135deg,
#ed232a 0%,
#ffffff 75%,
#14539a 100%
); */
}
</style>
<script setup lang="ts">
import { ref } from 'vue';
import { account, resetPassword } from 'boot/appwrite';
import { useRouter } from 'vue-router';
import { Dialog } from 'quasar';
// import GoogleOauthComponent from 'src/components/GoogleOauthComponent.vue';
const email = ref('');
const router = useRouter();
const password = ref('');
const confirmPassword = ref('');
const validatePasswordStrength = (val: string) => {
const hasUpperCase = /[A-Z]/.test(val);
const hasLowerCase = /[a-z]/.test(val);
const hasNumbers = /[0-9]/.test(val);
const hasNonAlphas = /[\W_]/.test(val);
const isValidLength = val.length >= 8;
return (
(hasUpperCase &&
hasLowerCase &&
hasNumbers &&
hasNonAlphas &&
isValidLength) ||
'Password must be at least 8 characters long and include uppercase, lowercase, number, and special character.'
);
};
const validatePasswordsMatch = (val: string) => {
return val === password.value || 'Passwords do not match.';
};
function isPasswordResetLink() {
const query = router.currentRoute.value.query;
return query && query.secret && query.userId && query.expire;
}
function validResetLink(): boolean {
const query = router.currentRoute.value.query;
const expire = query.expire ? new Date(query.expire + 'Z') : null;
return Boolean(
query && expire && query.secret && query.userId && new Date() < expire
);
}
function submitNewPw() {
const query = router.currentRoute.value.query;
if (
validatePasswordStrength(password.value) === true &&
validatePasswordsMatch(confirmPassword.value) === true
) {
account
.updateRecovery(
query.userId as string,
query.secret as string,
password.value
)
.then(() => {
Dialog.create({ message: 'Password Changed!' });
router.replace('/login');
})
.catch((e) =>
Dialog.create({
message: 'Password change failed! Error: ' + e.message,
})
);
}
}
function resetPw() {
resetPassword(email.value)
.then(() => router.replace('/login'))
.finally(() =>
Dialog.create({
message:
'If your address is in our system, you should receive an e-mail shortly.',
})
);
}
</script>

View File

@@ -1,88 +1,98 @@
<template>
<div class="q-pa-md row q-gutter-sm">
<div class="q-pa-xs row q-gutter-xs">
<q-card
flat
bordered
class="col-md-4 col-sm-8 col-xs-12">
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">New Booking</div>
<div class="text-caption text-grey-8">for: {{ bookingForm.name }}</div>
</q-card-section>
<q-separator />
<q-item
clickable
@click="boatSelect = true">
<q-item-section avatar>
<q-list class="q-px-xs">
<q-separator />
<q-item
class="q-px-none"
clickable
@click="boatSelect = true">
<!-- <q-item-section avatar>
<q-icon
color="primary"
name="event" />
</q-item-section>
<q-item-section>
<q-card v-if="bookingForm.boat">
<q-card-section>
<q-img
:src="bookingForm.boat?.imgSrc"
:fit="'scale-down'">
<div class="row absolute-top">
<div class="col text-h6 text-left">
{{ bookingForm.boat?.name }}
</q-item-section> -->
<q-item-section>
<q-card v-if="bookingForm.boat">
<q-card-section>
<q-img
:src="bookingForm.boat?.imgSrc"
:fit="'scale-down'">
<div class="row absolute-top">
<div class="col text-h7 text-left">
{{ bookingForm.boat?.name }}
</div>
<div class="col text-right text-caption">
{{ bookingForm.boat?.class }}
</div>
</div>
<div class="col text-right">
{{ bookingForm.boat?.class }}
</div>
</div>
</q-img>
</q-card-section>
<q-separator />
<q-card-section class="q-py-none">
<q-list
dense
class="row">
<q-item>
<q-item-section avatar>
<q-badge
outline
color="primary"
label="Start" />
</q-item-section>
<q-item-section>
{{
date.formatDate(
new Date(bookingForm.startDate as string),
'ddd MMM Do @ hh:mm A'
)
}}
</q-item-section>
</q-item>
<q-item class="q-ma-none">
<q-item-section avatar>
<q-badge
outline
color="primary"
label="End" />
</q-item-section>
<q-item-section>
{{
date.formatDate(
new Date(bookingForm.endDate as string),
'ddd MMM Do @ hh:mm A'
)
}}
</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
<div v-else>Tap to Select a Boat / Time</div>
</q-item-section>
</q-item>
<q-list dense>
<q-item>
<q-item-section avatar>
</q-img>
</q-card-section>
<q-separator />
<q-card-section horizontal>
<q-card-section>
<q-list
dense
class="row">
<q-item>
<q-item-section avatar>
<q-badge
color="primary"
label="Start" />
</q-item-section>
<q-item-section class="text-caption">
{{
date.formatDate(
new Date(bookingForm.startDate as string),
'ddd MMM Do @ hh:mm A'
)
}}
</q-item-section>
</q-item>
<q-item class="q-ma-none">
<q-item-section avatar>
<q-badge
color="primary"
label="End" />
</q-item-section>
<q-item-section class="text-caption">
{{
date.formatDate(
new Date(bookingForm.endDate as string),
'ddd MMM Do @ hh:mm A'
)
}}
</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
</q-card-section>
</q-card-section>
</q-card>
<q-field
readonly
filled
v-else>
Tap to Select a Boat / Time
</q-field>
</q-item-section>
</q-item>
<q-item class="q-px-none">
<!-- <q-item-section avatar>
<q-icon
color="primary"
name="category" />
</q-item-section>
</q-item-section> -->
<q-item-section>
<q-select
filled
@@ -91,6 +101,21 @@
label="Reason for sail" />
</q-item-section>
</q-item>
<q-item class="q-px-none">
<!-- <q-item-section avatar>
<q-icon
color="primary"
name="text" />
</q-item-section> -->
<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
@@ -113,7 +138,7 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import { useAuthStore } from 'src/stores/auth';
import { Boat, useBoatStore } from 'src/stores/boat';
import { date, useQuasar } from 'quasar';
@@ -132,6 +157,7 @@ interface BookingForm {
reason: string;
members: { name: string }[];
guests: { name: string }[];
comment?: string;
}
const reason_options = ['Open Sail', 'Private Sail', 'Racing', 'Other'];
@@ -147,6 +173,7 @@ const newForm = {
reason: 'Open Sail',
members: [],
guests: [],
comment: '',
};
const bookingForm = ref<BookingForm>({ ...newForm });
const router = useRouter();
@@ -162,6 +189,16 @@ watch(interval, (new_interval) => {
bookingForm.value.endDate = new_interval?.end;
});
const bookingDuration = computed((): number => {
if (bookingForm.value.startDate && bookingForm.value.endDate) {
const start = new Date(bookingForm.value.startDate).getTime();
const end = new Date(bookingForm.value.endDate).getTime();
const delta = Math.abs(end - start) / 1000;
return Math.floor(delta / 3600) % 24;
}
return 0;
});
const onReset = () => {
bookingForm.value = { ...newForm };
};
@@ -180,6 +217,7 @@ const onSubmit = () => {
user: auth.currentUser.$id,
status: 'confirmed',
reason: booking.reason,
comment: booking.comment,
};
console.log(reservation);
// TODO: Fix this. It will always look successful

View File

@@ -1,44 +1,46 @@
<template>
<q-page padding>
<div class="subcontent">
<navigation-bar @today="onToday" @prev="onPrev" @next="onNext" />
<div class="row justify-center">
<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="week"
bordered
animated
cell-width="150px"
day-min-height="50px"
@change="onChange"
@moved="onMoved"
@click-date="onClickDate"
@click-time="onClickTime"
@click-interval="onClickInterval"
@click-head-day="onClickHeadDay"
>
<template #day="{ scope }">
<div v-for="event in boatReservationEvents(scope)" :key="event.id">
<div v-if="event.start !== undefined" class="booking-event">
<span class="title q-calendar__ellipsis">
{{ useAuthStore().getUserNameById(event.user) }}
<q-tooltip>{{
event.start +
' - ' +
boatStore.getBoatById(event.resource)?.name
}}</q-tooltip>
</span>
</div>
<q-card class="subcontent">
<navigation-bar
@today="onToday"
@prev="onPrev"
@next="onNext" />
<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'"
bordered
animated
day-min-height="50px"
@change="onChange"
@moved="onMoved"
@click-date="onClickDate"
@click-time="onClickTime"
@click-interval="onClickInterval"
@click-head-day="onClickHeadDay">
<template #day="{ scope }">
<div
v-for="event in boatReservationEvents(scope)"
:key="event.id">
<div
v-if="event.start !== undefined"
class="booking-event">
<span class="title q-calendar__ellipsis">
{{ useAuthStore().getUserNameById(event.user) }} @
{{ renderTime(event.start) }}
<q-tooltip>
{{ renderTime(event.start) + ' - ' + renderTime(event.end) }}
</q-tooltip>
</span>
</div>
</template>
</q-calendar-scheduler>
</div>
</div>
</div>
</template>
</q-calendar-scheduler>
</q-card>
</q-page>
</template>
@@ -53,10 +55,12 @@ import { QCalendarScheduler } from '@quasar/quasar-ui-qcalendar';
import { Timestamp } from '@quasar/quasar-ui-qcalendar';
import { Boat, useBoatStore } from 'src/stores/boat';
import NavigationBar from 'src/components/scheduling/NavigationBar.vue';
import { useQuasar } from 'quasar';
const selectedDate = ref(today());
const boatStore = useBoatStore();
const calendar = ref();
const $q = useQuasar();
interface DayScope {
timestamp: Timestamp;
@@ -68,6 +72,10 @@ interface DayScope {
droppable: boolean;
}
const renderTime = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleTimeString();
};
onMounted(() => boatStore.fetchBoats());
// Method declarations

View File

@@ -1,3 +1,5 @@
import { useAuthStore } from 'src/stores/auth';
export const links = [
{
name: 'Home',
@@ -81,11 +83,20 @@ export const links = [
},
];
const authStore = useAuthStore();
function hasRole(roles: string[] | undefined) {
if (roles === undefined) return true;
const hasRole = authStore.hasRequiredRole(roles);
return hasRole;
}
export const enabledLinks = links
.filter((link) => link.enabled)
.map((link) => {
if (link.sublinks) {
link.sublinks = link.sublinks.filter((sublink) => sublink.enabled);
link.sublinks = link.sublinks.filter(
(sublink) => sublink.enabled && hasRole(sublink.requiredRoles)
);
}
return link;
});

View File

@@ -124,6 +124,22 @@ const routes: RouteRecordRaw[] = [
publicRoute: true,
},
},
{
path: '/pwreset',
component: () => import('pages/ResetPassword.vue'),
name: 'pwreset',
meta: {
publicRoute: true,
},
},
{
path: '/login',
component: () => import('pages/LoginPage.vue'),
name: 'login',
meta: {
publicRoute: true,
},
},
{
path: '/terms-of-service',
component: () => import('pages/TermsOfServicePage.vue'),

View File

@@ -38,7 +38,7 @@ export const useAuthStore = defineStore('auth', () => {
}
async function login(email: string, password: string) {
await account.createEmailPasswordSession(email, password);
init();
await init();
}
async function googleLogin() {

View File

@@ -145,6 +145,7 @@ export function getSampleReservations(): Reservation[] {
reservationDate: now,
reason: entry.reason,
status: entry.status as StatusTypes,
comment: '',
};
});
}

View File

@@ -9,6 +9,7 @@ export type Reservation = Partial<Models.Document> & {
resource: string; // Boat ID
status?: StatusTypes;
reason: string;
comment: string;
};
// 24 hrs in advance only 2 weekday, and 1 weekend slot