16 Commits

Author SHA1 Message Date
a11b2a0568 fix: reactivity bug with ListReservationsPage
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m32s
2024-06-21 23:44:34 -04:00
ff8e54449a feat: add realtime updates of interval and reservation 2024-06-21 23:13:30 -04:00
64a59e856f feat: rudimentary realtime update of intervals 2024-06-20 23:36:05 -04:00
5e8c5a1631 feat: enable websocket proxy for dev 2024-06-20 23:14:20 -04:00
e97949cab3 fix: Improve reactivity in intervals 2024-06-20 21:52:00 -04:00
b7a3608e67 fix: dev targets 2024-06-19 23:02:01 -04:00
bbb544c029 chore: bump version
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m26s
2024-06-19 19:13:33 -04:00
da42f6ed22 chore: Update gitignore
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m8s
2024-06-17 16:31:29 -04:00
8016e20451 fix: remove dotenv files from repo 2024-06-17 16:30:59 -04:00
64ee8f4fea chore: Change actions to only run on devel branch
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m13s
2024-06-17 16:25:44 -04:00
17e8d7dc37 chore: manually bump version 2024-06-17 16:20:20 -04:00
a409b0a5c7 refactor: Configuration improvement
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m13s
2024-06-17 15:37:45 -04:00
6ec4a1e025 feat: Re-enable profile page and allow editing name
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 4m48s
2024-06-15 10:28:38 -04:00
d063b0cf0d fix: (auth) token login fix 2024-06-15 00:05:41 -04:00
643d74e29d feat: (auth) switch to OTP code via e-mail
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m24s
2024-06-14 16:23:48 -04:00
1526a10630 feat: (auth) Add ability to signup with e-mail 2024-06-14 15:19:29 -04:00
26 changed files with 568 additions and 289 deletions

View File

@@ -1,2 +0,0 @@
VITE_APPWRITE_API_ENDPOINT='http://localhost:4000/api/v1'
VITE_APPWRITE_API_PROJECT='65ede55a213134f2b688'

View File

@@ -1,2 +0,0 @@
VITE_APPWRITE_API_ENDPOINT='https://appwrite.oys.undock.ca/v1'
VITE_APPWRITE_API_PROJECT='bab'

View File

@@ -3,7 +3,7 @@ run-name: ${{ gitea.actor }} is building a BAB App artifact 🚀
on:
push:
branches:
- main
- devel
jobs:
build:

2
.gitignore vendored
View File

@@ -34,4 +34,4 @@ yarn-error.log*
*.sln
# local .env files
.env.local*
.env*

View File

@@ -1,6 +1,6 @@
{
"name": "oys_bab",
"version": "0.6.1",
"version": "0.6.2",
"description": "Manage a Borrow a Boat program for a Yacht Club",
"productName": "OYS Borrow a Boat",
"author": "Patrick Toal <ptoal@takeflight.ca>",

View File

@@ -108,12 +108,19 @@ module.exports = configure(function ({ dev }) {
secure: false,
rewrite: (path) => path.replace(/^\/api/, ''),
},
'/function': {
target: 'https://6640382951eacb568371.f.appwrite.toal.ca/',
'/api/v1/realtime': {
target: 'wss://apidev.bab.toal.ca',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
secure: false,
rewrite: (path) => path.replace(/^\/function/, ''),
ws: true,
},
// '/function': {
// target: 'https://6640382951eacb568371.f.appwrite.toal.ca/',
// changeOrigin: true,
// secure: false,
// rewrite: (path) => path.replace(/^\/function/, ''),
// },
},
// For reverse-proxying via haproxy
// hmr: {

View File

@@ -14,56 +14,72 @@ import type { Router } from 'vue-router';
const client = new Client();
console.log(import.meta.env);
const VITE_APPWRITE_API_ENDPOINT = import.meta.env.VITE_APPWRITE_API_ENDPOINT;
const VITE_APPWRITE_API_PROJECT = import.meta.env.VITE_APPWRITE_API_PROJECT;
const API_ENDPOINT = import.meta.env.VITE_APPWRITE_API_ENDPOINT;
const API_PROJECT = import.meta.env.VITE_APPWRITE_API_PROJECT;
if (VITE_APPWRITE_API_ENDPOINT && VITE_APPWRITE_API_PROJECT) {
client
.setEndpoint(VITE_APPWRITE_API_ENDPOINT)
.setProject(VITE_APPWRITE_API_PROJECT);
if (API_ENDPOINT && API_PROJECT) {
client.setEndpoint(API_ENDPOINT).setProject(API_PROJECT);
} else {
console.error(
'Must configure VITE_APPWRITE_API_ENDPOINT and VITE_APPWRITE_API_PROJECT'
);
}
console.log(process.env);
const pwresetUrl = process.env.DEV
? 'http://localhost:4000/pwreset'
: 'https://oys.undock.ca/pwreset';
type AppwriteIDConfig = {
databaseId: string;
collection: {
boat: string;
reservation: string;
skillTags: string;
task: string;
taskTags: string;
interval: string;
intervalTemplate: string;
};
function: {
userinfo: string;
};
};
const AppwriteIds = process.env.DEV
? {
databaseId: '65ee1cbf9c2493faf15f',
collection: {
boat: 'boat',
reservation: 'reservation',
skillTags: 'skillTags',
task: 'task',
taskTags: 'taskTags',
interval: 'interval',
intervalTemplate: 'intervalTemplate',
},
function: {
userinfo: 'userinfo',
},
}
: {
databaseId: 'bab_prod',
collection: {
boat: 'boat',
reservation: 'reservation',
skillTags: 'skillTags',
task: 'task',
taskTags: 'taskTags',
interval: 'interval',
intervalTemplate: 'intervalTemplate',
},
function: {
userinfo: '664038294b5473ef0c8d',
},
};
let AppwriteIds = <AppwriteIDConfig>{};
console.log(API_ENDPOINT);
if (
API_ENDPOINT === 'https://apidev.bab.toal.ca/v1' ||
API_ENDPOINT === 'http://localhost:4000/api/v1'
) {
AppwriteIds = {
databaseId: '65ee1cbf9c2493faf15f',
collection: {
boat: 'boat',
reservation: 'reservation',
skillTags: 'skillTags',
task: 'task',
taskTags: 'taskTags',
interval: 'interval',
intervalTemplate: 'intervalTemplate',
},
function: {
userinfo: 'userinfo',
},
};
} else if (API_ENDPOINT === 'https://appwrite.oys.undock.ca/v1') {
AppwriteIds = {
databaseId: 'bab_prod',
collection: {
boat: 'boat',
reservation: 'reservation',
skillTags: 'skillTags',
task: 'task',
taskTags: 'taskTags',
interval: 'interval',
intervalTemplate: 'intervalTemplate',
},
function: {
userinfo: '664038294b5473ef0c8d',
},
};
}
const account = new Account(client);
const databases = new Databases(client);
@@ -120,6 +136,7 @@ async function login(email: string, password: string) {
});
appRouter.replace({ name: 'index' });
} catch (error: unknown) {
console.log(error);
if (error instanceof AppwriteException) {
if (error.type === 'user_session_already_exists') {
appRouter.replace({ name: 'index' });
@@ -147,7 +164,7 @@ async function login(email: string, password: string) {
}
async function resetPassword(email: string) {
await account.createRecovery(email, pwresetUrl);
await account.createRecovery(email, window.location.origin + '/pwreset');
}
export {

View File

@@ -0,0 +1,62 @@
<template>
<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>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
const password = ref('');
const confirmPassword = ref('');
const newPassword = defineModel();
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.';
};
watch([password, confirmPassword], ([newpw, newpw1]) => {
if (
validatePasswordStrength(newpw) === true &&
validatePasswordsMatch(newpw1) === true
) {
newPassword.value = newpw;
} else {
newPassword.value = '';
}
});
</script>

View File

@@ -184,7 +184,7 @@ function getEvents(scope: ResourceIntervalScope) {
scope.resource.$id
);
return resourceEvents.map((event) => {
return resourceEvents.value.map((event) => {
return {
left: scope.timeStartPosX(parsed(event.start)),
width: scope.timeDurationWidth(

View File

@@ -3,7 +3,7 @@
<q-card
v-for="boat in boats"
:key="boat.id"
class="mobile-card q-ma-sm">
class="q-ma-sm">
<q-card-section>
<q-img
:src="boat.imgSrc"

View File

@@ -38,7 +38,7 @@
v-for="block in getAvailableIntervals(
scope.timestamp,
boats[scope.columnIndex]
)"
).value"
:key="block.$id">
<div
class="timeblock"
@@ -207,7 +207,7 @@ function selectBlock(event: MouseEvent, scope: DayBodyScope, block: Interval) {
const boatReservations = computed((): Record<string, Reservation[]> => {
return reservationStore
.getReservationsByDate(selectedDate.value)
.reduce((result, reservation) => {
.value.reduce((result, reservation) => {
if (!result[reservation.resource]) result[reservation.resource] = [];
result[reservation.resource].push(reservation);
return result;

View File

@@ -14,7 +14,7 @@
<div class="col text-h6">Log in</div>
</div>
</q-card-section>
<q-form>
<q-form @keydown.enter.prevent="doTokenLogin">
<q-card-section class="q-gutter-md">
<q-input
v-model="email"
@@ -23,41 +23,36 @@
color="darkblue"
filled></q-input>
<q-input
v-model="password"
label="Password"
type="password"
v-if="userId"
v-model="token"
label="6-digit code"
type="number"
color="darkblue"
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-card-actions>
</q-card-section>
</q-form>
<q-card-section>
<q-card-section class="q-pa-none">
<div class="row justify-center q-ma-sm">
<q-btn
type="button"
@click="doTokenLogin"
color="primary"
label="Login with E-mail"
style="width: 300px" />
</div>
<div class="row justify-center q-ma-sm">
<GoogleOauthComponent />
</div>
<div class="row justify-center q-ma-sm">
<DiscordOauthComponent />
</div>
<div class="row justify-center">
<q-btn
flat
color="secondary"
to="/pwreset"
label="Forgot Password?" />
</div>
</q-card-section>
</q-card>
</q-page>
@@ -82,16 +77,76 @@
<script setup lang="ts">
import { ref } from 'vue';
import { login } from 'boot/appwrite';
import GoogleOauthComponent from 'src/components/GoogleOauthComponent.vue';
import DiscordOauthComponent from 'src/components/DiscordOauthComponent.vue';
import { Dialog, Notify } from 'quasar';
import { useAuthStore } from 'src/stores/auth';
import { useRouter } from 'vue-router';
import { AppwriteException } from 'appwrite';
const email = ref('');
const password = ref('');
const token = ref('');
const userId = ref();
const router = useRouter();
console.log('version:' + process.env.VUE_APP_VERSION);
const doLogin = async () => {
login(email.value, password.value);
const doTokenLogin = async () => {
const authStore = useAuthStore();
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 (e) {
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',
});
router.replace({ name: 'index' });
} catch (error: unknown) {
if (error instanceof AppwriteException) {
if (error.type === 'user_session_already_exists') {
useRouter().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,
});
}
}
};
</script>

View File

@@ -1,15 +1,45 @@
<template>
<toolbar-component pageTitle="Member Profile" />
<q-page padding>
<q-list bordered>
<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>
{{ authStore.currentUser?.name }}
<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 />
@@ -17,15 +47,27 @@
<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
>
<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>
@@ -36,6 +78,21 @@
<script setup lang="ts">
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
import { useAuthStore } from 'src/stores/auth';
import { ref } from 'vue';
const authStore = useAuthStore();
const newName = ref();
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>

View File

@@ -34,58 +34,27 @@
@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>
<div v-else-if="validResetLink()">
<q-form
@submit="submitNewPw"
@keydown.enter.prevent="resetPw">
<NewPasswordComponent v-model="newPassword" />
<q-card-actions>
<q-btn
type="submit"
label="Reset Password"
color="primary"></q-btn>
</q-card-actions>
</q-form>
</div>
<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>
@@ -112,38 +81,11 @@ 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';
import NewPasswordComponent from 'components/NewPasswordComponent.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;
}
const newPassword = ref();
function validResetLink(): boolean {
const query = router.currentRoute.value.query;
@@ -153,27 +95,34 @@ function validResetLink(): boolean {
);
}
function isPasswordResetLink() {
const query = router.currentRoute.value.query;
return query && query.secret && query.userId && query.expire;
}
function submitNewPw() {
const query = router.currentRoute.value.query;
if (
validatePasswordStrength(password.value) === true &&
validatePasswordsMatch(confirmPassword.value) === true
) {
if (newPassword.value) {
account
.updateRecovery(
query.userId as string,
query.secret as string,
password.value
newPassword.value
)
.then(() => {
Dialog.create({ message: 'Password Changed!' });
router.replace('/login');
Dialog.create({ message: 'Password Changed!' }).onOk(() =>
router.replace('/login')
);
})
.catch((e) =>
Dialog.create({
message: 'Password change failed! Error: ' + e.message,
})
);
} else {
Dialog.create({
message: 'Invalid password. Try again',
});
}
}

86
src/pages/SignupPage.vue Normal file
View File

@@ -0,0 +1,86 @@
<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">Sign Up</div>
</div>
</q-card-section>
<q-form>
<q-card-section class="q-gutter-md">
<q-input
v-model="email"
label="E-Mail"
type="email"
color="darkblue"
:rules="['email']"
filled></q-input>
<NewPasswordComponent v-model="password" />
<q-card-actions>
<q-space />
<q-btn
type="button"
@click="doRegister"
label="Sign Up"
color="primary"></q-btn>
</q-card-actions>
</q-card-section>
</q-form>
</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 { useAuthStore } from 'src/stores/auth';
import NewPasswordComponent from 'src/components/NewPasswordComponent.vue';
import { Dialog } from 'quasar';
import { useRouter } from 'vue-router';
const email = ref('');
const password = ref('');
const router = useRouter();
console.log('version:' + process.env.VUE_APP_VERSION);
const doRegister = async () => {
if (email.value && password.value) {
try {
await useAuthStore().register(email.value, password.value);
Dialog.create({
message: 'Account Created! Now log-in with your e-mail / password.',
}).onOk(() => router.replace('/login'));
} catch (e) {
console.log(e);
Dialog.create({
message: 'An error occurred. Please ask for support in Discord',
});
}
}
};
</script>

View File

@@ -92,7 +92,7 @@ const currentUser = useAuthStore().currentUser;
const getSortedIntervals = (timestamp: Timestamp, boat?: Boat): Interval[] => {
return getAvailableIntervals(timestamp, boat)
.concat(boatReservationEvents(timestamp, boat))
.value.concat(boatReservationEvents(timestamp, boat))
.sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
};
// Method declarations
@@ -134,16 +134,16 @@ const createReservationFromInterval = (interval: Interval | Reservation) => {
function handleSwipe({ ...event }) {
event.direction === 'right' ? calendar.value?.prev() : calendar.value?.next();
}
function boatReservationEvents(
const boatReservationEvents = (
timestamp: Timestamp,
resource: Boat | undefined
) {
if (!resource) return [];
): Reservation[] => {
if (!resource) return [] as Reservation[];
return reservationStore.getReservationsByDate(
getDate(timestamp),
(resource as Boat).$id
);
}
).value;
};
function onToday() {
calendar.value.moveToToday();
}

View File

@@ -22,7 +22,7 @@
class="q-pa-none">
<q-card
clas="q-ma-md"
v-if="!futureUserReservations.length">
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>
@@ -41,7 +41,7 @@
</q-card>
<div v-else>
<div
v-for="reservation in futureUserReservations"
v-for="reservation in reservationStore.futureUserReservations"
:key="reservation.$id">
<ReservationCardComponent :modelValue="reservation" />
</div>
@@ -51,7 +51,7 @@
name="past"
class="q-pa-none">
<div
v-for="reservation in pastUserReservations"
v-for="reservation in reservationStore.pastUserReservations"
:key="reservation.$id">
<ReservationCardComponent :modelValue="reservation" />
</div>
@@ -63,7 +63,7 @@ import { useReservationStore } from 'src/stores/reservation';
import ReservationCardComponent from 'src/components/scheduling/ReservationCardComponent.vue';
import { onMounted, ref } from 'vue';
const { futureUserReservations, pastUserReservations } = useReservationStore();
const reservationStore = useReservationStore();
onMounted(() => useReservationStore().fetchUserReservations());

View File

@@ -26,7 +26,9 @@
cell-width="150px">
<template #day="{ scope }">
<div
v-if="filteredIntervals(scope.timestamp, scope.resource).length"
v-if="
filteredIntervals(scope.timestamp, scope.resource).value.length
"
style="
display: flex;
flex-wrap: wrap;
@@ -35,10 +37,8 @@
font-size: 12px;
">
<template
v-for="block in sortedIntervals(
scope.timestamp,
scope.resource
)"
v-for="block in sortedIntervals(scope.timestamp, scope.resource)
.value"
:key="block.id">
<q-chip class="cursor-pointer">
{{ date.formatDate(block.start, 'HH:mm') }} -
@@ -163,7 +163,7 @@ import {
} from '@quasar/quasar-ui-qcalendar';
import { Boat, useBoatStore } from 'src/stores/boat';
import { useIntervalStore } from 'src/stores/interval';
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import type {
Interval,
IntervalTemplate,
@@ -208,8 +208,10 @@ const filteredIntervals = (date: Timestamp, boat: Boat) => {
};
const sortedIntervals = (date: Timestamp, boat: Boat) => {
return filteredIntervals(date, boat).sort(
(a, b) => Date.parse(a.start) - Date.parse(b.start)
return computed(() =>
filteredIntervals(date, boat).value.sort(
(a, b) => Date.parse(a.start) - Date.parse(b.start)
)
);
};
@@ -293,7 +295,7 @@ function onDrop(
overlapped.value = boatsToApply
.map((boat) =>
intervalsOverlapped(
existingIntervals.concat(
existingIntervals.value.concat(
intervalsFromTemplate(boat, templateId, date)
)
)

View File

@@ -49,6 +49,10 @@ export default route(function (/* { store, ssrContext } */) {
return next('/login');
}
if (to.name === 'login' && currentUser) {
return next('/');
}
if (requiredRoles) {
if (!currentUser) {
return next('/login');

View File

@@ -24,7 +24,7 @@ export const links = <Link[]>[
to: '/profile',
icon: 'account_circle',
front_links: false,
enabled: false,
enabled: true,
},
{
name: 'Boats',

View File

@@ -168,14 +168,14 @@ const routes: RouteRecordRaw[] = [
publicRoute: true,
},
},
// {
// path: '/register',
// component: () => import('pages/RegisterPage.vue'),
// name: 'register'
// meta: {
// accountRoute: true,
// }
// },
{
path: '/signup',
component: () => import('pages/SignupPage.vue'),
name: 'signup',
meta: {
publicRoute: true,
},
},
// Always leave this as last one,
// but you can also remove it
{

View File

@@ -45,22 +45,31 @@ export const useAuthStore = defineStore('auth', () => {
await init();
}
async function createTokenSession(email: string) {
return await account.createEmailToken(ID.unique(), email);
}
async function googleLogin() {
account.createOAuth2Session(
await account.createOAuth2Session(
OAuthProvider.Google,
'https://undock.ca',
'https://undock.ca/#/login'
'https://oys.undock.ca',
'https://oys.undock.ca/login'
);
currentUser.value = await account.get();
await init();
}
async function discordLogin() {
account.createOAuth2Session(
await account.createOAuth2Session(
OAuthProvider.Discord,
'https://undock.ca',
'https://undock.ca/#/login'
'https://oys.undock.ca',
'https://oys.undock.ca/login'
);
currentUser.value = await account.get();
await init();
}
async function tokenLogin(userId: string, token: string) {
await account.createSession(userId, token);
await init();
}
function getUserNameById(id: string | undefined | null): string {
@@ -94,14 +103,22 @@ export const useAuthStore = defineStore('auth', () => {
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,
register,
updateName,
login,
googleLogin,
discordLogin,
createTokenSession,
tokenLogin,
logout,
init,
};

View File

@@ -2,25 +2,44 @@ import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import { Boat } from './boat';
import { Timestamp, today } from '@quasar/quasar-ui-qcalendar';
import { Interval, IntervalRecord } from './schedule.types';
import { Interval } from './schedule.types';
import { AppwriteIds, databases } from 'src/boot/appwrite';
import { ID, Query } from 'appwrite';
import { useReservationStore } from './reservation';
import { LoadingTypes } from 'src/utils/misc';
import { useRealtimeStore } from './realtime';
export const useIntervalStore = defineStore('interval', () => {
// TODO: Implement functions to dynamically pull this data.
const intervals = ref<Map<string, Interval>>(new Map());
const intervalDates = ref<IntervalRecord>({});
const reservationStore = useReservationStore();
const intervals = ref(new Map<string, Interval>()); // Intervals by DocID
const dateStatus = ref(new Map<string, LoadingTypes>()); // State of load by date
const selectedDate = ref<string>(today());
const getIntervals = (date: Timestamp | string, boat?: Boat): Interval[] => {
const reservationStore = useReservationStore();
const realtimeStore = useRealtimeStore();
realtimeStore.register(
`databases.${AppwriteIds.databaseId}.collections.${AppwriteIds.collection.interval}.documents`,
(response) => {
const payload = response.payload 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 (!intervalDates.value[searchDate]) {
intervalDates.value[searchDate] = 'pending';
if (dateStatus.value.get(searchDate) === undefined) {
dateStatus.value.set(searchDate, 'pending');
fetchIntervals(searchDate);
}
return computed(() => {
@@ -32,22 +51,19 @@ export const useIntervalStore = defineStore('interval', () => {
const matchesBoat = boat ? boat.$id === interval.resource : true;
return isWithinDay && matchesBoat;
});
}).value;
});
};
const getAvailableIntervals = (
date: Timestamp | string,
boat?: Boat
): Interval[] => {
return computed(() => {
return getIntervals(date, boat).filter((interval) => {
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)
);
});
}).value;
})
);
};
async function fetchInterval(id: string): Promise<Interval> {
@@ -78,11 +94,11 @@ export const useIntervalStore = defineStore('interval', () => {
response.documents.forEach((d) =>
intervals.value.set(d.$id, d as Interval)
);
intervalDates.value[dateString] = 'loaded';
dateStatus.value.set(dateString, 'loaded');
console.info(`Loaded ${response.documents.length} intervals from server`);
} catch (error) {
console.error('Failed to fetch intervals', error);
intervalDates.value[dateString] = 'error';
dateStatus.value.set(dateString, 'error');
}
}
@@ -140,5 +156,6 @@ export const useIntervalStore = defineStore('interval', () => {
updateInterval,
deleteInterval,
selectedDate,
intervals,
};
});

21
src/stores/realtime.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defineStore } from 'pinia';
import { client } from 'src/boot/appwrite';
import { Interval } from './schedule.types';
import { ref } from 'vue';
import { RealtimeResponseEvent } from 'appwrite';
export const useRealtimeStore = defineStore('realtime', () => {
const subscriptions = ref<Map<string, () => void>>(new Map());
const register = (
channel: string,
fn: (response: RealtimeResponseEvent<Interval>) => void
) => {
if (subscriptions.value.has(channel)) return; // Already subscribed. But maybe different callback fn?
subscriptions.value.set(channel, client.subscribe(channel, fn));
};
return {
register,
};
});

View File

@@ -1,6 +1,6 @@
import { defineStore } from 'pinia';
import type { Reservation } from './schedule.types';
import { computed, ref, watch } from 'vue';
import { ComputedRef, computed, reactive } from 'vue';
import { AppwriteIds, databases } from 'src/boot/appwrite';
import { ID, Query } from 'appwrite';
import { date, useQuasar } from 'quasar';
@@ -8,15 +8,37 @@ import { Timestamp, parseDate, today } from '@quasar/quasar-ui-qcalendar';
import { LoadingTypes } from 'src/utils/misc';
import { useAuthStore } from './auth';
import { isPast } from 'src/utils/schedule';
import { useRealtimeStore } from './realtime';
export const useReservationStore = defineStore('reservation', () => {
const reservations = ref<Map<string, Reservation>>(new Map());
const datesLoaded = ref<Record<string, LoadingTypes>>({});
const userReservations = ref<Map<string, Reservation>>(new Map());
// TODO: Come up with a better way of storing reservations by date & reservations for user
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 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);
}
}
}
);
// Fetch reservations for a specific date range
const fetchReservationsForDateRange = async (
start: string = today(),
@@ -40,7 +62,7 @@ export const useReservationStore = defineStore('reservation', () => {
);
response.documents.forEach((d) =>
reservations.value.set(d.$id, d as Reservation)
reservations.set(d.$id, d as Reservation)
);
setDateLoaded(startDate, endDate, 'loaded');
} catch (error) {
@@ -81,8 +103,8 @@ export const useReservationStore = defineStore('reservation', () => {
reservation
);
}
reservations.value.set(response.$id, response as Reservation);
userReservations.value.set(response.$id, response as Reservation);
reservations.set(response.$id, response as Reservation);
userReservations.set(response.$id, response as Reservation);
console.info('Reservation booked: ', response);
return response as Reservation;
} catch (e) {
@@ -95,14 +117,8 @@ export const useReservationStore = defineStore('reservation', () => {
reservation: string | Reservation | null | undefined
) => {
if (!reservation) return false;
let id;
if (typeof reservation === 'string') {
id = reservation;
} else if ('$id' in reservation && typeof reservation.$id === 'string') {
id = reservation.$id;
} else {
return false;
}
const id = typeof reservation === 'string' ? reservation : reservation.$id;
if (!id) return false;
const status = $q.notify({
color: 'secondary',
@@ -120,8 +136,8 @@ export const useReservationStore = defineStore('reservation', () => {
AppwriteIds.collection.reservation,
id
);
reservations.value.delete(id);
userReservations.value.delete(id);
reservations.delete(id);
userReservations.delete(id);
console.info(`Deleted reservation: ${id}`);
status({
color: 'warning',
@@ -146,7 +162,7 @@ export const useReservationStore = defineStore('reservation', () => {
if (start > end) return [];
let curDate = start;
while (curDate < end) {
datesLoaded.value[(parseDate(curDate) as Timestamp).date] = state;
datesLoaded[(parseDate(curDate) as Timestamp).date] = state;
curDate = date.addToDate(curDate, { days: 1 });
}
};
@@ -157,8 +173,7 @@ export const useReservationStore = defineStore('reservation', () => {
const unloaded = [];
while (curDate < end) {
const parsedDate = (parseDate(curDate) as Timestamp).date;
if (datesLoaded.value[parsedDate] === undefined)
unloaded.push(parsedDate);
if (datesLoaded[parsedDate] === undefined) unloaded.push(parsedDate);
curDate = date.addToDate(curDate, { days: 1 });
}
return unloaded;
@@ -168,15 +183,15 @@ export const useReservationStore = defineStore('reservation', () => {
const getReservationsByDate = (
searchDate: string,
boat?: string
): Reservation[] => {
if (!datesLoaded.value[searchDate]) {
): 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.value.values()).filter((reservation) => {
return Array.from(reservations.values()).filter((reservation) => {
const reservationStart = new Date(reservation.start);
const reservationEnd = new Date(reservation.end);
@@ -185,7 +200,7 @@ export const useReservationStore = defineStore('reservation', () => {
const matchesBoat = boat ? boat === reservation.resource : true;
return isWithinDay && matchesBoat;
});
}).value;
});
};
// Get conflicting reservations for a resource within a time range
@@ -194,7 +209,7 @@ export const useReservationStore = defineStore('reservation', () => {
start: Date,
end: Date
): Reservation[] => {
return Array.from(reservations.value.values()).filter(
return Array.from(reservations.values()).filter(
(entry) =>
entry.resource === resource &&
new Date(entry.start) < end &&
@@ -229,7 +244,7 @@ export const useReservationStore = defineStore('reservation', () => {
[Query.equal('user', authStore.currentUser.$id)]
);
response.documents.forEach((d) =>
userReservations.value.set(d.$id, d as Reservation)
userReservations.set(d.$id, d as Reservation)
);
} catch (error) {
console.error('Failed to fetch reservations for user: ', error);
@@ -237,7 +252,7 @@ export const useReservationStore = defineStore('reservation', () => {
};
const sortedUserReservations = computed((): Reservation[] =>
[...userReservations.value?.values()].sort(
[...userReservations.values()].sort(
(a, b) => new Date(b.start).getTime() - new Date(a.start).getTime()
)
);
@@ -252,27 +267,6 @@ export const useReservationStore = defineStore('reservation', () => {
return sortedUserReservations.value?.filter((b) => isPast(b.end));
});
// Ensure reactivity for computed properties when Map is modified
watch(
reservations,
() => {
sortedUserReservations.value;
futureUserReservations.value;
pastUserReservations.value;
},
{ deep: true }
);
watch(
userReservations,
() => {
sortedUserReservations.value;
futureUserReservations.value;
pastUserReservations.value;
},
{ deep: true }
);
return {
getReservationsByDate,
getReservationById,

View File

@@ -1,5 +1,4 @@
import { Models } from 'appwrite';
import { LoadingTypes } from 'src/utils/misc';
export type StatusTypes = 'tentative' | 'confirmed' | 'pending' | undefined;
export type Reservation = Interval & {
@@ -29,7 +28,3 @@ export type IntervalTemplate = Partial<Models.Document> & {
name: string;
timeTuples: TimeTuple[];
};
export interface IntervalRecord {
[key: string]: LoadingTypes;
}