Progress?

This commit is contained in:
zervo 2024-12-05 19:31:18 +01:00
parent 4791a5ef1d
commit 211e93b685
32 changed files with 752 additions and 38 deletions

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Welcome to Vuetify 3</title>
<title>ScheduleTogether</title>
</head>
<body>
<div id="app"></div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

View file

@ -0,0 +1,4 @@
background.jpg - Vojta Kovařík @ pexels - Green Leafed Plant With Water Drops
background2.jpg - Pixabay @ pexels - Brown Leafed Tree on Open Field Under White Clouds and Blue Sky
background3.jpg - Stephan Seeber @ pexels - Scenic View Of Mountains During Dawn
background4.jpg - Roberto Nickson @ pexels - Brown Mountains

View file

@ -8,4 +8,4 @@
<script lang="ts" setup>
//
</script>
</script>

View file

@ -10,8 +10,11 @@ declare module 'vue' {
AppBar: typeof import('./components/AppBar.vue')['default']
AppFooter: typeof import('./components/AppFooter.vue')['default']
AppHeader: typeof import('./components/AppHeader.vue')['default']
copy: typeof import('./components/LoginForm copy.vue')['default']
HelloWorld: typeof import('./components/HelloWorld.vue')['default']
LoginForm: typeof import('./components/LoginForm.vue')['default']
NavBar: typeof import('./components/NavBar.vue')['default']
RegisterForm: typeof import('./components/RegisterForm.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}

View file

@ -1,35 +1,39 @@
<template>
<v-toolbar
class="ma-2"
:collapse="false"
density="comfortable"
floating
dark
rounded="xl"
elevation="8"
>
<v-app-bar-nav-icon @click.stop="drawer = !drawer"></v-app-bar-nav-icon>
<v-toolbar
class="ma-2"
:collapse="false"
density="comfortable"
color="grey-darken-4"
floating
dark
rounded="xl"
elevation="8"
style="width: calc(100% - 16px)"
>
<v-app-bar-nav-icon @click.stop="drawer = !drawer" />
<v-spacer></v-spacer>
<v-spacer />
<v-toolbar-title class="text-h5 text-center">ScheduleTogether</v-toolbar-title>
<v-toolbar-title class="text-h5 text-center">
ScheduleTogether
</v-toolbar-title>
<v-spacer></v-spacer>
<v-spacer />
<v-btn icon>
<v-icon>mdi-account</v-icon>
</v-btn>
</v-toolbar>
<v-btn icon>
<v-icon>mdi-account</v-icon>
</v-btn>
</v-toolbar>
<v-navigation-drawer
v-model="drawer"
:location="$vuetify.display.mobile ? 'bottom' : 'left'"
temporary
>
<v-list
:items="items"
></v-list>
</v-navigation-drawer>
<v-navigation-drawer
v-model="drawer"
:location="$vuetify.display.mobile ? 'bottom' : 'left'"
temporary
>
<v-list
:items="items"
/>
</v-navigation-drawer>
</template>
<script>

View file

@ -19,15 +19,15 @@
class="text-caption text-disabled"
style="position: absolute; right: 16px;"
>
&copy; 2016-{{ (new Date()).getFullYear() }} <span class="d-none d-sm-inline-block">Vuetify, LLC</span>
&copy; 2023-{{ (new Date()).getFullYear() }} <span class="d-none d-sm-inline-block">Zervo Network</span>
<a
class="text-decoration-none on-surface"
href="https://vuetifyjs.com/about/licensing/"
href="https://www.gnu.org/licenses/agpl-3.0.en.html"
rel="noopener noreferrer"
target="_blank"
>
MIT License
AGPL v3 license
</a>
</div>
</v-footer>

View file

@ -0,0 +1,69 @@
<template>
<div class="text-h4 text-center">
Sign in
</div>
<br>
<v-form
v-model="form"
@submit.prevent="onSubmit"
>
<v-text-field
v-model="email"
:readonly="loading"
:rules="[required]"
variant="outlined"
class="mb-2"
label="Email"
clearable
/>
<v-text-field
v-model="password"
:readonly="loading"
:rules="[required]"
variant="outlined"
label="Password"
:append-inner-icon="passwordvis ? 'mdi-eye-outline' : 'mdi-eye-off-outline'"
:type="passwordvis ? 'text' : 'password'"
placeholder="Enter your password"
clearable
@click:append-inner="() => (passwordvis = !passwordvis)"
/>
<br>
<v-btn
:disabled="!form"
:loading="loading"
color="primary"
size="large"
type="submit"
variant="elevated"
block
>
Sign In
</v-btn>
</v-form>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const form = ref<boolean>(false);
const email = ref<string | null>(null);
const password = ref<string | null>(null);
const passwordvis = ref<boolean>(false);
const loading = ref<boolean>(false);
function onSubmit () {
if (!form.value) return
loading.value = true
setTimeout(() => (loading.value = false), 2000)
}
function required (v: never) {
return !!v || 'Field is required'
}
</script>

View file

@ -0,0 +1,128 @@
<template>
<div class="text-h4 text-center">
Sign up
</div>
<br>
<v-form
v-model="form"
@submit.prevent="onSubmit"
>
<v-text-field
v-model="email"
:readonly="loading"
:rules="[required,checkemail]"
variant="outlined"
class="mb-2"
label="Email"
clearable
/>
<v-text-field
v-model="username"
:readonly="loading"
:rules="[required,checkuser]"
variant="outlined"
class="mb-2"
label="Username"
clearable
/>
<v-text-field
v-model="password"
:readonly="loading"
:rules="[required,checkpass]"
variant="outlined"
label="Password"
:append-inner-icon="passwordvis ? 'mdi-eye-outline' : 'mdi-eye-off-outline'"
:type="passwordvis ? 'text' : 'password'"
placeholder="Enter your password"
clearable
@click:append-inner="() => (passwordvis = !passwordvis)"
/>
<v-text-field
v-model="passwordagain"
:readonly="loading"
:rules="[required,passmatch]"
variant="outlined"
label="Repeat Password"
:append-inner-icon="passwordvis ? 'mdi-eye-outline' : 'mdi-eye-off-outline'"
:type="passwordvis ? 'text' : 'password'"
placeholder="Enter password again"
clearable
@click:append-inner="() => (passwordvis = !passwordvis)"
/>
<br>
<v-btn
:disabled="!form"
:loading="loading"
color="primary"
size="large"
type="submit"
variant="elevated"
block
>
Register
</v-btn>
</v-form>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const form = ref<boolean>(false);
const email = ref<string | null>(null);
const username = ref<string | null>(null);
const password = ref<string | null>(null);
const passwordagain = ref<string | null>(null);
const passwordvis = ref<boolean>(false);
const loading = ref<boolean>(false);
function onSubmit () {
if (!form.value) return
loading.value = true
setTimeout(() => (loading.value = false), 2000)
}
function required (v: never) {
return !!v || 'Field is required'
}
function checkpass (v: string) {
if (v.length < 5) {
return 'Must be at least 5 characters long'
}
else if (v.length > 128) {
return 'Can be no longer than 128 characters'
}
else if (!v.toLowerCase().match(/^(?=.*[A-Za-z])(?=.*\d).+$/)) {
return 'Must contain letters and numbers'
}
return true
}
function passmatch (v: never) {
return v==password.value || 'Passwords must match'
}
function checkemail (v: string) {
return (v.toLowerCase()
.match(
/^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i
) && v.length < 100) || 'Must be a valid email address'
}
function checkuser (v: string) {
if (v.length < 3 || v.length > 18) {
return 'Must be between 3-18 characters long'
}
else if (!v.toLowerCase().match(/^[A-Za-z0-9_.-]+$/)) {
return 'Can only contain letters a-Z, numbers and . - _'
}
return true
}
</script>

View file

@ -0,0 +1,29 @@
const configUrl = `${window.location.origin}/config.json`; // Get the base URL dynamically
let apiUrl: string | null = null;
export const getApiUrl = (): string => {
if (apiUrl) {
// If the API URL has already been fetched, return it immediately
return apiUrl;
}
try {
const xhr = new XMLHttpRequest();
xhr.open('GET', configUrl, false); // Synchronous XHR request
xhr.send();
if (xhr.status !== 200) {
throw new Error(`Network response was not ok: ${xhr.status}`);
}
const configData = JSON.parse(xhr.responseText);
apiUrl = configData.apiUrl; // Extract the API URL
return apiUrl || "";
} catch (error) {
throw error;
}
}
// Call the function to initialize the apiUrl
getApiUrl();

View file

@ -0,0 +1,56 @@
import { useAuthStore } from '@/stores'
import { getApiUrl } from './config-loader';
export const fetchWrapper = {
get: request('GET'),
post: request('POST'),
put: request('PUT'),
delete: request('DELETE')
}
function request(method: string) {
return (url: string, body: object | undefined = undefined) => {
const requestOptions: RequestInit = {
body: undefined,
method,
headers: getAuthHeader(url) || {} as Record<string, string>
};
if (body) {
(requestOptions.headers as Record<string, string>)['Content-Type'] = 'application/json';
requestOptions.body = JSON.stringify(body);
}
return fetch(url, requestOptions).then(handleResponse);
}
}
async function handleResponse(response: Response) {
const isJson = response.headers?.get('content-type')?.includes('application/json');
const data = isJson ? await response.json() : null;
// check for error response
if (!response.ok) {
const { user } = useAuthStore();
if ([401, 403].includes(response.status) && user) {
// auto logout if 401 Unauthorized or 403 Forbidden response
//logout();
}
// get error message from body or default to response status
const error = (data && data.error) || response.status;
return Promise.reject(error);
}
return data;
}
function getAuthHeader(url: string): Record<string, string> {
const { auth } = useAuthStore();
const isLoggedIn: boolean = !!auth?.token;
const apiUrl: string = getApiUrl();
const isApiUrl = url.startsWith(apiUrl);
if (isLoggedIn && isApiUrl) {
return { Authorization: `Bearer ${auth.token}`}
} else {
return {};
}
}

View file

@ -0,0 +1,158 @@
<template>
<ul class="bgcircles">
<li />
<li />
<li />
<li />
<li />
<li />
<li />
<li />
<li />
<li />
<li />
</ul>
<v-app>
<v-main>
<v-container
fluid
class="fill-height"
>
<v-row
align="center"
justify="center"
>
<v-col
class="d-flex justify-center"
>
<router-view />
</v-col>
</v-row>
</v-container>
</v-main>
<AppFooter />
</v-app>
</template>
<script lang="ts" setup>
//
</script>
<style>
/* Original background effect by Mohammed Abdul Mohaiman*/
.bgcircles{
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
.bgcircles li{
position: absolute;
display: block;
list-style: none;
width: 20px;
height: 20px;
background: rgba(255, 255, 255, 0.2);
animation: bganimate 25s linear infinite;
bottom: -150px;
}
.bgcircles li:nth-child(1){
left: 25%;
width: 80px;
height: 80px;
animation-delay: 0s;
}
.bgcircles li:nth-child(2){
left: 10%;
width: 20px;
height: 20px;
animation-delay: 2s;
animation-duration: 12s;
}
.bgcircles li:nth-child(3){
left: 70%;
width: 20px;
height: 20px;
animation-delay: 4s;
}
.bgcircles li:nth-child(4){
left: 40%;
width: 60px;
height: 60px;
animation-delay: 0s;
animation-duration: 18s;
}
.bgcircles li:nth-child(5){
left: 65%;
width: 20px;
height: 20px;
animation-delay: 0s;
}
.bgcircles li:nth-child(6){
left: 75%;
width: 110px;
height: 110px;
animation-delay: 3s;
}
.bgcircles li:nth-child(7){
left: 35%;
width: 150px;
height: 150px;
animation-delay: 7s;
}
.bgcircles li:nth-child(8){
left: 50%;
width: 25px;
height: 25px;
animation-delay: 15s;
animation-duration: 45s;
}
.bgcircles li:nth-child(9){
left: 20%;
width: 15px;
height: 15px;
animation-delay: 2s;
animation-duration: 35s;
}
.bgcircles li:nth-child(10){
left: 85%;
width: 150px;
height: 150px;
animation-delay: 0s;
animation-duration: 11s;
}
@keyframes bganimate {
0%{
transform: translateY(0) rotate(0deg);
opacity: 1;
border-radius: 0;
}
100%{
transform: translateY(-1000px) rotate(720deg);
opacity: 0;
border-radius: 50%;
}
}
</style>

View file

@ -0,0 +1,24 @@
<template>
<v-app>
<v-img
cover
src="/images/background2.jpg"
>
<AppBar />
<v-main>
<div
class="ma-2 mt-1"
>
<router-view />
</div>
</v-main>
</v-img>
<AppFooter />
</v-app>
</template>
<script lang="ts" setup>
//
</script>

View file

@ -1,11 +1,22 @@
<template>
<v-app>
<AppBar />
<v-main>
<router-view />
<v-container
fluid
class="fill-height"
>
<v-row
align="center"
justify="center"
>
<v-col
class="d-flex justify-center"
>
<router-view />
</v-col>
</v-row>
</v-container>
</v-main>
<AppFooter />
</v-app>
</template>

View file

@ -0,0 +1,19 @@
<template>
<v-sheet
class="pa-4"
color="grey-darken-4"
elevation="18"
rounded="xl"
>
<HelloWorld />
</v-sheet>
</template>
<script lang="ts" setup>
//
</script>
<route lang="yaml">
meta:
layout: appdefault
</route>

View file

@ -1,7 +1,16 @@
<template>
<HelloWorld />
<v-sheet
class="pa-6"
rounded="lg"
elevation="18"
>
<div class="text-h4">
Please wait ...
</div>
</v-sheet>
</template>
<script lang="ts" setup>
//
</script>
import router from '@/router';
router.push('login')
</script>

View file

@ -0,0 +1,32 @@
<template>
<div
class="d-flex flex-column"
>
<v-sheet
class="pa-6"
color="grey-darken-4"
elevation="18"
rounded="xl"
fluid
:width="500"
:max-width="$vuetify.display.mobile ? 350 : 500"
>
<LoginForm />
<br>
<div class="text-body-1 text-center">
Don't have an account? <router-link :to="'register'">Sign up</router-link> instead.
</div>
</v-sheet>
</div>
</template>
<script lang="ts" setup>
//
</script>
<route lang="yaml">
meta:
layout: action
</route>

View file

@ -0,0 +1,32 @@
<template>
<div
class="d-flex flex-column"
>
<v-sheet
class="pa-6"
color="grey-darken-4"
elevation="18"
rounded="xl"
fluid
:width="500"
:max-width="$vuetify.display.mobile ? 350 : 500"
>
<RegisterForm />
<br>
<div class="text-body-1 text-center">
Already have an account? <router-link :to="'login'">Sign in</router-link> instead.
</div>
</v-sheet>
</div>
</template>
<script lang="ts" setup>
//
</script>
<route lang="yaml">
meta:
layout: action
</route>

View file

@ -0,0 +1,65 @@
// Alert
import type { AlertData } from '@/types';
import { defineStore } from 'pinia'
export const useAlertStore = defineStore('alert', {
state: () => ({
alerts: [] as Array<AlertData>,
}),
actions: {
addAlert(message: string, type: string, timeout: number = 0) {
// If more than 10 alerts, remove oldest
if (this.alerts.length > 10) {
this.alerts.shift();
}
// Define alert and assign unique ID
const alert: AlertData = { message, type, timeout, id: Date.now(), timeoutId: null}
this.alerts.push(alert);
// Register timeout for alert
if (timeout > 0) {
const timeoutId = setTimeout(() => {
this.removeSpecific(alert.id);
}, timeout);
alert.timeoutId = timeoutId;
}
},
success(message: string, timeout = 0) {
// add green alert (success)
this.addAlert(message, 'alert-success', timeout);
},
error(message: string, timeout = 0) {
// add red alert (error)
this.addAlert(message, 'alert-danger', timeout);
},
removeOldest() {
const oldestAlert = this.alerts.shift();
// Remove timeout for this alert (avoid errors)
if (oldestAlert && oldestAlert.timeoutId) {
clearTimeout(oldestAlert.timeoutId);
}
},
removeSpecific(alertId: number) {
// Find alert by index
const index = this.alerts.findIndex((alert) => alert.id === alertId);
if (index !== -1) {
const alert = this.alerts[index];
this.alerts.splice(index, 1);
// Remove timeout for this alert (avoid errors)
if (alert.timeoutId) {
clearTimeout(alert.timeoutId);
}
}
},
clear() {
for (const alert of this.alerts) {
if (alert.timeoutId) {
clearTimeout(alert.timeoutId)
}
}
this.alerts = [];
}
}
})

View file

@ -0,0 +1,37 @@
// Utilities
import { defineStore } from 'pinia'
import type { Auth, DetailedUser } from '@/types'
import { fetchWrapper } from '@/helpers/fetch-wrapper'
import { getApiUrl } from '@/helpers/config-loader';
import { useAlertStore } from './alert';
export const useAuthStore = defineStore('auth', {
state: () => ({
auth: JSON.parse(localStorage.getItem('auth') || "{}") as Auth,
user: JSON.parse(localStorage.getItem('this_user') || "{}") as DetailedUser,
}),
actions: {
async login(username: string, password: string) {
try {
const data = await fetchWrapper.post(`${getApiUrl()}/account/signin`, { username, password})
const token: string = data.token || ""
if (token == null) {
throw new Error("Could not sign in, didn't receive token");
}
this.auth = {
token
}
const user: DetailedUser = await fetchWrapper.get(`${getApiUrl()}/users/self`);
this.user = user;
localStorage.setItem('this_user', JSON.stringify(user));
} catch (error) {
const alertStore = useAlertStore();
alertStore.error((error as Error).message, 5000);
}
}
}
})

View file

@ -2,3 +2,6 @@
import { createPinia } from 'pinia'
export default createPinia()
export * from './auth';
export * from './alert';

View file

@ -19,5 +19,8 @@ declare module 'vue-router/auto-routes' {
*/
export interface RouteNamedMap {
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
'/dashboard': RouteRecordInfo<'/dashboard', '/dashboard', Record<never, never>, Record<never, never>>,
'/login': RouteRecordInfo<'/login', '/login', Record<never, never>, Record<never, never>>,
'/register': RouteRecordInfo<'/register', '/register', Record<never, never>, Record<never, never>>,
}
}

View file

@ -0,0 +1,7 @@
export type AlertData = {
message: string,
type: string,
timeout: number,
id: number,
timeoutId: number | null,
}

View file

@ -0,0 +1,3 @@
export type Auth = {
token: string;
}

View file

@ -0,0 +1,3 @@
export * from './auth';
export * from './user';
export * from './alert'

View file

@ -0,0 +1,10 @@
export type DetailedUser = {
id: number,
uuid: string,
username: string,
firstName: string,
lastName: string,
email: string,
verified: boolean,
friends: Array<string>
}

View file

@ -3,6 +3,7 @@
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"allowJs": true,
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",

View file

@ -1,4 +1,7 @@
{
"compilerOptions": {
"allowJs": true,
},
"files": [],
"references": [
{

View file

@ -8,6 +8,7 @@
"playwright.config.*"
],
"compilerOptions": {
"allowJs": true,
"composite": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",