lots of changes

This commit is contained in:
2023-04-19 16:26:13 +10:00
parent 190493179f
commit fb9944ef14
26 changed files with 165 additions and 1765 deletions

View File

@@ -1,3 +0,0 @@
{
}

View File

@@ -41,7 +41,7 @@ class UserConductor extends Conductor
$data = $model->toArray(); $data = $model->toArray();
if ($user === null || ($user->hasPermission('admin/users') === false && strcasecmp($user->id, $model->id) !== 0)) { if ($user === null || ($user->hasPermission('admin/users') === false && strcasecmp($user->id, $model->id) !== 0)) {
$fields = ['id', 'username']; $fields = ['id', 'username', 'display_name'];
$data = arrayLimitKeys($data, $fields); $data = arrayLimitKeys($data, $fields);
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

View File

@@ -1,6 +1,5 @@
@import "variables.scss"; @import "variables.scss";
@import "utils.scss"; @import "utils.scss";
@import "data-table.scss";
@import "tinymce.scss"; @import "tinymce.scss";
@import "prism.css"; @import "prism.css";

View File

@@ -1,62 +0,0 @@
@import "vue3-easy-data-table/dist/style.css";
.vue3-easy-data-table {
border-radius: 12px;
.vue3-easy-data-table__header tr {
background-color: var(--easy-table-header-background-color);
}
.vue3-easy-data-table__header th {
background-color: transparent;
}
.vue3-easy-data-table__body td {
white-space: nowrap;
// overflow: hidden;
text-overflow: ellipsis;
}
.vue3-easy-data-table__main {
border-top-left-radius: 12px;
border-top-right-radius: 12px;
}
.vue3-easy-data-table__footer {
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
}
td.easy-data-table-cell-center {
text-align: center !important;
}
th.easy-data-table-cell-center .header {
justify-content: center !important;
}
.action-wrapper {
a {
color: $font-color;
}
}
}
:root {
--easy-table-border: #{1px solid $border-color};
--easy-table-row-border: #{1px solid $border-color};
--easy-table-header-font-size: #{calc($font-size / 1.1)};
--easy-table-header-background-color: #{$secondary-background-color};
--easy-table-header-item-padding: 20px 20px;
--easy-table-body-row-font-size: #{calc($font-size / 1.2)};
--easy-table-body-item-padding: 20px 20px;
--easy-table-body-row-hover-background-color: #e5f3fd;
--easy-table-footer-font-size: #{calc($font-size / 1.2)};
--easy-table-footer-background-color: #{$secondary-background-color};
--easy-table-footer-padding: 20px 20px;
--easy-table-footer-height: auto;
--easy-table-message-font-size: #{calc($font-size / 1.2)};
}

View File

@@ -1,70 +0,0 @@
@import "@vuepic/vue-datepicker/dist/main.css";
.dp__menu {
border-radius: 12px;
padding: 1rem;
box-shadow: 4px 4px 12px rgba(0, 0, 0, 0.2);
}
.dp__input {
padding: 0.75rem 1rem;
border: 1px solid $border-color;
border-radius: 12px;
padding: map-get($spacer, 2) map-get($spacer, 3) map-get($spacer, 2) #{calc(
map-get($spacer, 4) * 1.2
)};
&::placeholder {
opacity: 1 !important;
}
}
.dp__action_row {
flex-direction: column;
.dp__selection_preview {
width: auto;
font-size: 0.9rem;
}
.dp__action_buttons {
width: 100%;
display: flex;
justify-content: space-between;
margin-top: map-get($spacer, 2);
.dp__action {
padding: map-get($spacer, 1) map-get($spacer, 3);
font-size: 1rem;
color: white;
font-weight: 800;
border-width: 2px;
border-style: solid;
border-radius: 24px;
transition: background-color 0.1s, color 0.1s;
cursor: pointer;
background-color: $secondary-color;
border-color: $secondary-color;
&:hover {
background-color: #fff;
text-decoration: none;
color: $secondary-color;
}
&.dp__select {
background-color: $primary-color;
border-color: $primary-color;
&:hover {
background-color: #fff;
color: $primary-color;
}
}
}
}
}
.dp__theme_light {
--dp-success-color: #{$primary-color};
}

View File

@@ -78,6 +78,10 @@
flex: 1; flex: 1;
} }
.flex-grow-1 {
flex-grow: 1;
}
.flex-0 { .flex-0 {
flex: 0 !important; flex: 0 !important;
} }

View File

@@ -1,124 +0,0 @@
import { defineStore } from "pinia";
import { clamp } from "../helpers/utils";
export interface ProgressStore {
spinner: number;
status: number;
opacity: number;
queue: number;
timeoutID: number | null;
}
export const useProgressStore = defineStore({
id: "progress",
state: (): ProgressStore => ({
spinner: 0,
status: 0,
opacity: 0,
queue: 0,
timeoutID: null,
}),
actions: {
start() {
if (this.queue == 0 && this.opacity == 0) {
this.set(0);
const work = () => {
window.setTimeout(() => {
if (this.status < 1) {
this._trickle();
work();
}
}, 200);
};
work();
if (this.opacity == 0) {
if (this.timeoutID != null) {
window.clearTimeout(this.timeoutID);
}
this.timeoutID = window.setTimeout(() => {
this._show();
this.timeoutID = null;
}, 2000);
}
if (this.spinner == 0) {
this.spinner = 1;
}
}
++this.queue;
},
set(number: number) {
const n = clamp(number, 0.08, 1);
this.status = n;
},
finish() {
if (this.queue > 0) {
--this.queue;
}
},
_trickle() {
const n = this.status;
if (this.queue == 0) {
if (this.opacity == 0 && this.timeoutID != null) {
this._hide();
window.clearTimeout(this.timeoutID);
this.timeoutID = null;
} else if (this.timeoutID == null) {
this.timeoutID = window.setTimeout(() => {
this.set(1);
this.timeoutID = null;
this.timeoutID = window.setTimeout(() => {
this._hide();
this.timeoutID = null;
window.setTimeout(() => {
this.status = 0;
}, 150);
}, 500);
}, 500);
}
}
if (n > 0 && n < 1) {
let amount = 0;
if (n >= 0 && n < 0.2) {
amount = 0.1;
} else if (n >= 0.2 && n < 0.5) {
amount = 0.04;
} else if (n >= 0.5 && n < 0.8) {
amount = 0.02;
} else if (n >= 0.8 && n < 0.99) {
amount = 0.005;
} else {
amount = 0;
}
this.set(clamp(n + amount, 0, 0.994));
}
},
_show() {
this.opacity = 1;
},
_hide() {
this.opacity = 0;
if (this.spinner == 1) {
this.spinner = 0;
}
},
},
});

View File

@@ -1,280 +0,0 @@
<template>
<div
class="sm-carousel"
@mouseover="handleMouseOver"
@mouseleave="handleMouseLeave">
<div ref="slides" class="sm-carousel-slides">
<slot></slot>
</div>
<div class="sm-carousel-slide-prev" @click="handleClickSlidePrev">
<ion-icon name="chevron-back-outline" />
</div>
<div class="sm-carousel-slide-next" @click="handleClickSlideNext">
<ion-icon name="chevron-forward-outline" />
</div>
<div class="sm-carousel-slide-indicators">
<div
v-for="(indicator, index) in slideElements"
:key="index"
:class="[
'sm-carousel-slide-indicator-item',
{ highlighted: currentSlide == index },
]"
@click="handleClickIndicator(index)"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, Ref, ref } from "vue";
/**
* Reference to slides element.
*/
const slides: Ref<HTMLElement | null> = ref(null);
/**
* The list of slide elements.
*/
let slideElements: Ref<NodeList | null> = ref(null);
/**
* Index of the current slide.
*/
let currentSlide = ref(0);
/**
* The maximum number of slides.
*/
let maxSlide = ref(0);
/**
* The window interval reference to slide the carousel.
*/
let intervalRef: number | null = null;
/**
* The active mutation observer.
*/
const mutationObserver: Ref<MutationObserver | null> = ref(null);
/**
* Handle the user moving the mouse over the carousel.
*/
const handleMouseOver = () => {
stopAutoSlide();
};
/**
* Handle the user moving the mouse leaving the carousel.
*/
const handleMouseLeave = () => {
startAutoSlide();
};
/**
* Handle the user clicking the previous slider indicator.
*/
const handleClickSlidePrev = () => {
if (currentSlide.value == 0) {
currentSlide.value = maxSlide.value;
} else {
currentSlide.value--;
}
updateSlidePositions();
};
/**
* Handle the user clicking the next slider indicator.
*/
const handleClickSlideNext = () => {
if (currentSlide.value == maxSlide.value) {
currentSlide.value = 0;
} else {
currentSlide.value++;
}
updateSlidePositions();
};
/**
* Handle the user clicking a slider indicator.
*
* @param {number} index The slide to move to.
*/
const handleClickIndicator = (index: number) => {
currentSlide.value = index;
updateSlidePositions();
};
/**
* Handle slides added/removed from the carousel and update the data/indicators.
*/
const handleCarouselUpdate = () => {
if (slides.value != null) {
slideElements.value =
slides.value.querySelectorAll(".sm-carousel-slide");
maxSlide.value = slideElements.value.length - 1;
}
updateSlidePositions();
};
/**
* Update the style transform of each slide.
*/
const updateSlidePositions = () => {
if (slideElements.value != null) {
slideElements.value.forEach((slide, index) => {
(slide as HTMLElement).style.transform = `translateX(${
100 * (index - currentSlide.value)
}%)`;
});
}
};
/**
* Start the carousel slider.
*/
const startAutoSlide = () => {
if (intervalRef == null) {
intervalRef = window.setInterval(() => {
handleClickSlideNext();
}, 7000);
}
};
/**
* Stop the carousel slider.
*/
const stopAutoSlide = () => {
if (intervalRef != null) {
window.clearInterval(intervalRef);
intervalRef = null;
}
};
/**
* Connect the mutation observer to the slider.
*/
const connectMutationObserver = () => {
if (slides.value != null) {
mutationObserver.value = new MutationObserver(handleCarouselUpdate);
mutationObserver.value.observe(slides.value, {
attributes: false,
childList: true,
characterData: true,
subtree: true,
});
}
};
/**
* Disconnect the mutation observer from the slider.
*/
const disconnectMutationObserver = () => {
if (mutationObserver.value) {
mutationObserver.value.disconnect();
}
};
onMounted(() => {
connectMutationObserver();
handleCarouselUpdate();
startAutoSlide();
});
onUnmounted(() => {
stopAutoSlide();
disconnectMutationObserver();
});
</script>
<style lang="scss">
.sm-carousel {
position: relative;
height: 28rem;
background: #eee;
overflow: hidden;
&:hover {
.sm-carousel-slide-prev,
.sm-carousel-slide-next,
.sm-carousel-slide-indicators {
opacity: 1;
}
}
.sm-carousel-slide-prev,
.sm-carousel-slide-next {
position: absolute;
top: 50%;
font-size: 300%;
-webkit-transform: translateY(-50%) scale(1);
transform: translateY(-50%) scale(1);
cursor: pointer;
color: #fff;
transform-origin: center center;
transition: transform 0.2s ease-in-out, opacity 0.2s ease-in-out;
opacity: 0.75;
ion-icon {
filter: drop-shadow(0px 0px 2px rgba(0, 0, 0, 1));
}
&:hover {
-webkit-transform: translateY(-50%) scale(1.25);
transform: translateY(-50%) scale(1.25);
}
}
.sm-carousel-slide-prev {
left: 1rem;
filter: drop-shadow(0px 0px 2px rgba(0, 0, 0, 1));
}
.sm-carousel-slide-next {
right: 1rem;
filter: drop-shadow(0px 0px 2px rgba(0, 0, 0, 1));
}
.sm-carousel-slide-indicators {
position: absolute;
display: flex;
justify-content: center;
align-items: center;
bottom: 0.25rem;
width: 100%;
height: 2rem;
opacity: 0.75;
transition: opacity 0.2s ease-in-out;
.sm-carousel-slide-indicator-item {
height: 12px;
width: 12px;
border: 1px solid white;
border-radius: 50%;
cursor: pointer;
font-size: 80%;
margin: 0 calc(#{map-get($spacer, 1)} / 3);
color: #fff;
filter: drop-shadow(0px 0px 2px rgba(0, 0, 0, 1));
&.highlighted {
background-color: white;
}
}
}
}
@media only screen and (max-width: 400px) {
.sm-carousel {
.sm-carousel-slide-prev,
.sm-carousel-slide-next {
font-size: 150%;
}
}
}
</style>

View File

@@ -1,194 +0,0 @@
<template>
<div class="sm-carousel-slide" :style="styleObject">
<div class="sm-carousel-slide-body">
<div class="sm-carousel-slide-content">
<div class="sm-carousel-slide-content-inner">
<h3>{{ title }}</h3>
<p v-if="content">{{ content }}</p>
<div class="sm-carousel-slide-body-buttons">
<SMButton
v-if="url"
:to="url"
:label="cta"
type="secondary-outline" />
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import SMButton from "./SMButton.vue";
const props = defineProps({
title: {
type: String,
default: "",
required: true,
},
content: {
type: String,
default: "",
required: true,
},
image: {
type: String,
default: "",
required: true,
},
url: {
type: [String, Object],
default: "",
required: false,
},
cta: {
type: String,
default: "View",
required: false,
},
});
/**
* Carousel slide styles.
*/
let styleObject = {
backgroundImage: `url('${props.image}')`,
};
</script>
<style lang="scss">
.sm-carousel-slide {
position: absolute;
transition: all 0.5s;
width: 100%;
height: 100%;
background-position: center;
background-repeat: no-repeat;
background-size: cover;
overflow: hidden;
.sm-carousel-slide-loading {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
.sm-carousel-slide-body {
display: flex;
align-items: center;
height: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
.sm-carousel-slide-content {
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(0, 0, 0, 0.75);
width: auto;
height: auto;
max-width: 800px;
padding: 2rem 3rem 1.5rem 3rem;
margin-bottom: 2rem;
border-radius: 12px;
margin-left: 3rem;
margin-right: 3rem;
}
h3 {
color: #fff;
font-size: 200%;
max-width: 600px;
margin: 0 0 0.5rem 0;
text-shadow: 0 0 8px rgba(0, 0, 0, 1);
text-align: left;
}
p {
display: inline-block;
color: #fff;
max-width: 600px;
text-shadow: 0 0 8px rgba(0, 0, 0, 1);
text-align: left;
}
.sm-carousel-slide-body-buttons {
margin-top: 2rem;
text-align: right;
max-width: 600px;
}
.secondary-outline {
border-color: #fff;
color: #fff;
&:hover {
color: #333;
}
}
}
}
@media only screen and (max-width: 768px) {
.sm-carousel-slide {
.sm-carousel-slide-body {
padding: 0;
.sm-carousel-slide-content {
width: 100%;
max-width: 100%;
height: 100%;
margin: 0;
padding-left: 5rem;
padding-right: 5rem;
border-radius: 0;
.sm-carousel-slide-content-inner {
overflow: hidden;
}
}
h3 {
font-size: 120%;
}
p {
font-size: 90%;
}
.sm-carousel-slide-body-buttons {
text-align: center;
}
}
}
}
@media only screen and (max-width: 640px) {
.sm-carousel-slide .sm-carousel-slide-body {
h3,
p {
text-align: center;
}
}
}
@media only screen and (max-width: 400px) {
.sm-carousel-slide .sm-carousel-slide-body {
.sm-carousel-slide-content {
padding-left: 3rem;
padding-right: 3rem;
}
h3 {
font-size: 175%;
}
p {
display: none;
}
}
}
</style>

View File

@@ -1,86 +0,0 @@
<template>
<div>
<div
class="sm-progress-container"
:style="{ opacity: `${progressStore.opacity || 0}` }">
<div
class="sm-progress"
:style="{
width: `${(progressStore.status || 0) * 100}%`,
}"></div>
</div>
<div class="sm-spinner">
<div
class="sm-spinner-icon"
:style="{ opacity: `${progressStore.spinner || 0}` }"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { useProgressStore } from "../store/ProgressStore";
const progressStore = useProgressStore();
</script>
<style lang="scss">
.sm-progress-container {
position: fixed;
background-color: $border-color;
height: 2px;
top: 0;
left: 0;
right: 0;
z-index: 2000;
transition: opacity 0.2s ease-in-out;
.sm-progress {
background-color: $primary-color-dark;
width: 0%;
height: 100%;
transition: width 0.2s ease-in-out;
box-shadow: 0 0 10px $primary-color-dark, 0 0 4px $primary-color-dark;
opacity: 1;
}
}
.sm-spinner {
position: fixed;
top: 10px;
right: 10px;
opacity: 0.5;
.sm-spinner-icon {
width: 18px;
height: 18px;
box-sizing: border-box;
border: solid 2px transparent;
border-top-color: #29d;
border-left-color: #29d;
border-radius: 50%;
transition: opacity 0.2s ease-in-out;
-webkit-animation: sm-progress-spinner 500ms linear infinite;
animation: sm-progress-spinner 500ms linear infinite;
}
}
@-webkit-keyframes sm-progress-spinner {
0% {
-webkit-transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
}
}
@keyframes sm-progress-spinner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@@ -1,147 +0,0 @@
<template>
<SMContainer
:class="[
'flex-0',
'sm-breadcrumbs-container',
{ closed: computedRouteCrumbs.length == 0 },
]">
<ul class="sm-breadcrumbs">
<li><router-link :to="{ name: 'home' }">Home</router-link></li>
<li
v-for="(routeItem, index) of computedRouteCrumbs"
:key="routeItem.name">
<router-link
v-if="index != computedRouteCrumbs.length - 1"
:to="{ name: routeItem.name }"
>{{ routeItem.meta?.title || routeItem.name }}</router-link
><span v-else>{{
routeItem.meta?.title || routeItem.name
}}</span>
</li>
</ul>
</SMContainer>
</template>
<script setup lang="ts">
import { computed, ComputedRef } from "vue";
import { RouteRecordRaw, useRoute } from "vue-router";
import { routes } from "../router";
import { useApplicationStore } from "../store/ApplicationStore";
const applicationStore = useApplicationStore();
/**
* Return a list of routes from the current page back to the root
*/
const computedRouteCrumbs: ComputedRef<RouteRecordRaw[]> = computed(() => {
const currentPageName = useRoute().name;
if (currentPageName == "home") {
return [];
}
const findMatch = (list: RouteRecordRaw[]): RouteRecordRaw[] | null => {
let found: RouteRecordRaw[] | null = null;
let index: RouteRecordRaw | null = null;
let child: RouteRecordRaw[] | null = null;
list.every((entry: RouteRecordRaw) => {
if (index == null && "path" in entry && entry.path == "") {
index = entry;
}
if (child == null && entry.children) {
child = findMatch(entry.children);
}
if (index != null && child != null) {
child.unshift(index);
found = child;
return false;
}
if ("name" in entry && entry.name == currentPageName) {
found = [entry];
if (entry.path == "") {
return false;
}
}
if (found != null && index != null) {
found.unshift(index);
return false;
}
return true;
});
return found || child;
};
let itemList = findMatch(routes);
if (itemList) {
if (applicationStore.dynamicTitle.length > 0) {
let meta = {};
if ("meta" in itemList[itemList.length - 1]) {
meta = itemList[itemList.length - 1]["meta"];
}
meta["title"] = applicationStore.dynamicTitle;
itemList[itemList.length - 1]["meta"] = meta;
}
}
return itemList || [];
});
</script>
<style lang="scss">
.sm-breadcrumbs-container.closed .sm-breadcrumbs {
opacity: 0;
transition: opacity 0s;
transition-delay: 0s;
}
.sm-breadcrumbs {
height: 3.25rem;
display: flex;
max-width: 1200px;
width: 100%;
margin: 0 auto;
padding: 0 1rem 0 0;
list-style-type: none;
font-size: 75%;
color: $secondary-color-dark;
align-items: center;
opacity: 1;
transition: opacity 0.25s ease-in-out;
transition-delay: 0.5s;
li {
display: flex;
align-items: center;
margin: 0;
overflow: hidden;
span {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
&:not(:last-child):after {
display: inline-block;
content: "";
width: 4px;
height: 4px;
border-top: 2px solid #000;
border-right: 2px solid #000;
margin: 0 0.6rem;
transform: rotate(45deg);
}
}
}
</style>

View File

@@ -42,7 +42,7 @@ const slots = useSlots();
flex-direction: column; flex-direction: column;
padding: 0 16px; padding: 0 16px;
max-width: 1200px; max-width: 1200px;
// align-items: center; align-items: center;
margin: 0 auto; margin: 0 auto;
&.full { &.full {

View File

@@ -1,134 +0,0 @@
<template>
<div :class="['form-group', { 'has-error': error }]">
<label v-if="label" :class="{ required: required }">{{ label }}</label>
<datepicker
v-model="date"
text-input
auto-apply
:is-24="false"
:month-change-on-scroll="false"
:preview-format="computedFormat"
:format="computedFormat"
:placeholder="props.placeholder"
:range="range"
:enable-time-picker="computedEnableTime"
@update:model-value="onUpdate"
@blur="onBlur"
@change="onChange" />
<div class="form-group-error">{{ error }}</div>
<div v-if="slots.default" class="form-group-info">
<slot></slot>
</div>
<div v-if="help" class="form-group-help">
<!-- <font-awesome-icon v-if="helpIcon" :icon="helpIcon" /> -->
{{ help }}
</div>
</div>
</template>
<script setup lang="ts">
import { watch, computed, useSlots, ref } from "vue";
import Datepicker from "@vuepic/vue-datepicker";
import { format } from "date-fns";
const props = defineProps({
modelValue: {
type: [String, Array],
default: null,
},
label: {
type: String,
default: "",
},
placeholder: {
type: String,
default: "",
},
required: {
type: Boolean,
default: false,
},
type: {
type: String,
default: "text",
},
error: {
type: String,
default: "",
},
help: {
type: String,
default: "",
},
helpIcon: {
type: String,
default: "",
},
range: {
type: Boolean,
default: false,
required: false,
},
enableTime: {
type: Boolean,
default: true,
required: false,
},
});
const emits = defineEmits(["update:modelValue", "blur", "change"]);
const slots = useSlots();
const onUpdate = (modelData) => {
let emitResult = null;
if (Array.isArray(modelData) == false) {
emitResult = format(modelData, "yyyy-MM-dd HH:mm:ss");
} else {
emitResult = modelData.map((item, index) => {
if (index == 0) {
item.setHours(0, 0, 0, 0);
} else {
item.setHours(23, 59, 59, 999);
}
return format(item, "yyyy-MM-dd HH:mm:ss");
});
}
emits("update:modelValue", emitResult);
};
const onBlur = () => {
emits("blur");
};
const onChange = () => {
emits("change");
};
let date = ref("");
const initialContent = computed(() => {
return props.modelValue;
});
const computedFormat = computed(() => {
return props.enableTime == true && props.range == false
? "d/MM/yyyy h:mm aa"
: "d/MM/yyyy";
});
const computedEnableTime = computed(() => {
return props.enableTime == true && props.range == false;
});
watch(initialContent, (newContent) => {
if (
typeof date.value == "undefined" ||
(typeof date.value == "string" && date.value.length == 0)
) {
date.value = newContent === undefined ? "" : newContent;
}
});
</script>

View File

@@ -26,7 +26,7 @@
:type="props.type" :type="props.type"
class="input-control" class="input-control"
:disabled="disabled" :disabled="disabled"
v-bind="{ id: id }" v-bind="{ id: id, autofocus: props.autofocus }"
v-model="value" v-model="value"
@focus="handleFocus" @focus="handleFocus"
@blur="handleBlur" @blur="handleBlur"
@@ -101,6 +101,11 @@ const props = defineProps({
default: "", default: "",
required: false, required: false,
}, },
autofocus: {
type: Boolean,
default: false,
required: false,
},
}); });
const slots = useSlots(); const slots = useSlots();

View File

@@ -0,0 +1,26 @@
<template>
<div class="loading-container">
<SMLoadingIcon v-bind="{ large: props.large }" />
</div>
</template>
<script setup lang="ts">
import SMLoadingIcon from "./SMLoadingIcon.vue";
const props = defineProps({
large: {
type: Boolean,
default: false,
required: false,
},
});
</script>
<style lang="scss">
.loading-container {
display: flex;
flex-grow: 1;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -21,7 +21,7 @@ const props = defineProps({
.loading-icon-balls { .loading-icon-balls {
display: inline-block; display: inline-block;
position: relative; position: relative;
width: 3em; width: 2.5em;
height: 0.5em; height: 0.5em;
div { div {
@@ -34,41 +34,22 @@ const props = defineProps({
animation-timing-function: cubic-bezier(0, 1, 1, 0); animation-timing-function: cubic-bezier(0, 1, 1, 0);
} }
div:nth-child(1) { div:nth-child(1) {
left: 0.3em; left: 0em;
animation: sm-loading-icon1 0.6s infinite; animation: sm-loading-icon1 0.6s infinite;
} }
div:nth-child(2) { div:nth-child(2) {
left: 0.3em; left: 0em;
animation: sm-loading-icon2 0.6s infinite; animation: sm-loading-icon2 0.6s infinite;
} }
div:nth-child(3) { div:nth-child(3) {
left: 1.2em; left: 1em;
animation: sm-loading-icon2 0.6s infinite; animation: sm-loading-icon2 0.6s infinite;
} }
div:nth-child(4) { div:nth-child(4) {
left: 2.1em; left: 2em;
animation: sm-loading-icon3 0.6s infinite; animation: sm-loading-icon3 0.6s infinite;
} }
&.large {
div {
width: 1.5em;
height: 1.5em;
}
div:nth-child(1) {
left: 0em;
}
div:nth-child(2) {
left: 0em;
}
div:nth-child(3) {
left: 3em;
}
div:nth-child(4) {
left: 6em;
}
}
@keyframes sm-loading-icon1 { @keyframes sm-loading-icon1 {
0% { 0% {
transform: scale(0); transform: scale(0);
@@ -86,6 +67,35 @@ const props = defineProps({
} }
} }
@keyframes sm-loading-icon2 { @keyframes sm-loading-icon2 {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(1em, 0);
}
}
&.large {
width: 7.5em;
height: 1.5em;
div {
width: 1.5em;
height: 1.5em;
}
div:nth-child(2) {
animation: sm-loading-large-icon2 0.6s infinite;
}
div:nth-child(3) {
left: 3em;
animation: sm-loading-large-icon2 0.6s infinite;
}
div:nth-child(4) {
left: 6em;
}
@keyframes sm-loading-large-icon2 {
0% { 0% {
transform: translate(0, 0); transform: translate(0, 0);
} }
@@ -94,4 +104,5 @@ const props = defineProps({
} }
} }
} }
}
</style> </style>

View File

@@ -1,33 +1,33 @@
<template> <template>
<SMContainer <SMContainer
:full="true" :full="true"
:class="['sm-navbar-container', { 'sm-nav-active': showToggle }]" :class="['navbar-container', { 'nav-active': showToggle }]"
@click="handleClickNavBar"> @click="handleClickNavBar">
<template #inner> <template #inner>
<nav class="sm-navbar"> <nav class="navbar">
<div id="sm-nav-head"> <div id="nav-head">
<router-link :to="{ name: 'home' }" id="sm-logo-link"> <router-link :to="{ name: 'home' }" id="logo-link">
<img <img
class="sm-nav-logo dark:d-none" class="nav-logo dark:d-none"
src="/assets/logo.png" src="/assets/logo.png"
width="270" width="270"
height="40" height="40"
alt="STEMMechanics" /> alt="STEMMechanics" />
<img <img
class="sm-nav-logo light:d-none" class="nav-logo light:d-none"
src="/assets/logo-dark.png" src="/assets/logo-dark.png"
width="270" width="270"
height="40" height="40"
alt="STEMMechanics" /> alt="STEMMechanics" />
</router-link> </router-link>
<div class="sm-nav-right"> <div class="nav-right">
<SMButton <SMButton
type="primary" type="primary"
size="medium" size="medium"
:to="{ name: 'workshops' }" :to="{ name: 'workshops' }"
label="Find Workshops" /> label="Find Workshops" />
<label <label
id="sm-nav-toggle" id="nav-toggle"
@click.stop="handleClickToggleMenu" @click.stop="handleClickToggleMenu"
><img ><img
src="/assets/hamburger.svg" src="/assets/hamburger.svg"
@@ -37,7 +37,7 @@
/></label> /></label>
</div> </div>
</div> </div>
<div id="sm-nav"> <div id="nav">
<ul> <ul>
<template v-for="item in menuItems"> <template v-for="item in menuItems">
<li <li
@@ -63,32 +63,21 @@ import SMButton from "../components/SMButton.vue";
const userStore = useUserStore(); const userStore = useUserStore();
const showToggle = ref(false); const showToggle = ref(false);
const menuItems = [ const menuItems = [
{
name: "workshops",
label: "Workshops",
to: { name: "workshops" },
},
{ {
name: "blog", name: "blog",
label: "Blog", label: "Blog",
to: { name: "blog" }, to: { name: "blog" },
}, },
{
name: "workshops",
label: "Workshops",
to: { name: "workshops" },
},
{ {
name: "community", name: "community",
label: "Community", label: "Community",
to: { name: "blog" }, to: { name: "blog" },
}, },
{
name: "about",
label: "About",
to: { name: "blog" },
},
// {
// name: "courses",
// label: "Courses",
// to: "/courses",
// icon: "briefcase-outline",
// },
{ {
name: "contact", name: "contact",
label: "Contact", label: "Contact",
@@ -141,7 +130,7 @@ const handleClickNavBar = () => {
const handleClickBody = (event: MouseEvent) => { const handleClickBody = (event: MouseEvent) => {
const header = document.querySelector("header"); const header = document.querySelector("header");
const navbarContainer = document.querySelector(".sm-navbar-container"); const navbarContainer = document.querySelector(".navbar-container");
if ( if (
!header?.contains(event.target as Node) && !header?.contains(event.target as Node) &&
!navbarContainer?.contains(event.target as Node) !navbarContainer?.contains(event.target as Node)
@@ -163,26 +152,26 @@ onUnmounted(() => {
<style lang="scss"> <style lang="scss">
.page-home { .page-home {
.sm-navbar-container { .navbar-container {
background-color: rgba(255, 255, 255, 0.1); background-color: rgba(255, 255, 255, 0.1);
&:not(.sm-nav-active) { &:not(.nav-active) {
.sm-nav-logo.dark\:d-none { .nav-logo.dark\:d-none {
display: none !important; display: none !important;
} }
.sm-nav-logo.light\:d-none { .nav-logo.light\:d-none {
display: block !important; display: block !important;
} }
.sm-navbar #sm-nav-head #sm-nav-toggle { .navbar #nav-head #nav-toggle {
filter: invert(100%) saturate(0%) brightness(120%); filter: invert(100%) saturate(0%) brightness(120%);
} }
} }
} }
} }
.sm-navbar-container { .navbar-container {
position: relative; position: relative;
z-index: 100; z-index: 100;
-webkit-backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
@@ -190,32 +179,33 @@ onUnmounted(() => {
background-color: var(--navbar-color); background-color: var(--navbar-color);
box-shadow: var(--base-shadow); box-shadow: var(--base-shadow);
&.sm-nav-active { &.nav-active {
background-color: var(--navbar-color) !important; background-color: var(--navbar-color) !important;
#sm-nav { #nav {
max-height: 100vh; max-height: 100vh;
transition: max-height 0.4s linear;
} }
#sm-nav-toggle { #nav-toggle {
background-color: hsla(0, 0%, 50%, 0.1); background-color: hsla(0, 0%, 50%, 0.1);
} }
} }
.sm-navbar { .navbar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
width: 100%; width: 100%;
} }
#sm-nav-head { #nav-head {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
width: 100%; width: 100%;
#sm-logo-link { #logo-link {
padding-right: 18px; padding-right: 18px;
margin-top: -10px; margin-top: -10px;
-webkit-user-select: none; -webkit-user-select: none;
@@ -232,7 +222,7 @@ onUnmounted(() => {
} }
} }
.sm-nav-right { .nav-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 20px; gap: 20px;
@@ -242,18 +232,18 @@ onUnmounted(() => {
color: #fff; color: #fff;
} }
#sm-nav-toggle { #nav-toggle {
padding: 24px; padding: 24px;
cursor: pointer; cursor: pointer;
} }
} }
#sm-nav { #nav {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
font-weight: 800; font-weight: 800;
transition: max-height 0.4s linear; transition: max-height 0;
height: auto; height: auto;
max-height: 0; max-height: 0;
overflow: hidden; overflow: hidden;
@@ -290,13 +280,13 @@ onUnmounted(() => {
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.sm-navbar #sm-nav-head #sm-nav-toggle { .navbar #nav-head #nav-toggle {
filter: invert(100%) saturate(0%) brightness(120%); filter: invert(100%) saturate(0%) brightness(120%);
} }
} }
@media screen and (max-width: 650px) { @media screen and (max-width: 650px) {
.sm-nav-right { .nav-right {
.button { .button {
display: none; display: none;
} }

View File

@@ -1,541 +0,0 @@
<template>
<div
:class="[
'sm-input-group',
{
'sm-input-active': inputActive,
'sm-feedback-invalid': feedbackInvalid,
'sm-input-small': small,
},
computedClassType,
]">
<label v-if="label">{{ label }}</label>
<ion-icon
class="sm-invalid-icon"
name="alert-circle-outline"></ion-icon>
<input
v-if="
type == 'text' ||
type == 'email' ||
type == 'password' ||
type == 'email' ||
type == 'url' ||
type == 'daterange' ||
type == 'datetime'
"
:type="type"
:value="value"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@keydown="handleKeydown" />
<textarea
v-else-if="type == 'textarea'"
rows="5"
:value="value"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@keydown="handleKeydown"></textarea>
<div v-else-if="type == 'file'" class="sm-input-file-group">
<input
id="file"
type="file"
class="sm-file"
:accept="props.accept"
@change="handleChange" />
<label class="sm-button" for="file">Select file</label>
<div class="sm-file-name">
{{ modelValue?.name ? modelValue.name : modelValue }}
</div>
</div>
<select
v-else-if="type == 'select'"
:value="value"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@keydown="handleKeydown">
<option
v-for="(optionValue, key) in options"
:key="key"
:value="key"
:selected="key == value">
{{ optionValue }}
</option>
</select>
<div v-else-if="type == 'media'" class="sm-input-media">
<div class="sm-input-media-item">
<img v-if="mediaUrl.length > 0" :src="mediaUrl" />
<ion-icon v-else name="image-outline" />
</div>
<a
class="sm-button sm-button-small"
@click.prevent="handleMediaSelect"
>Select file</a
>
</div>
<div v-if="slots.default || feedbackInvalid" class="sm-input-help">
<span v-if="feedbackInvalid" class="sm-input-invalid">{{
feedbackInvalid
}}</span>
<span v-if="slots.default" class="sm-input-info">
<slot></slot>
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, inject, ref, useSlots, watch } from "vue";
import { openDialog } from "../components/SMDialog";
import { api } from "../helpers/api";
import { MediaResponse } from "../helpers/api.types";
import { toTitleCase } from "../helpers/string";
import { isEmpty } from "../helpers/utils";
import { isUUID } from "../helpers/uuid";
import SMDialogMedia from "../components/dialogs/SMDialogMedia.vue";
import { mediaGetVariantUrl } from "../helpers/media";
const props = defineProps({
modelValue: {
type: String,
default: "",
required: false,
},
label: {
type: String,
default: "",
required: false,
},
type: {
type: String,
default: "text",
},
small: {
type: Boolean,
default: false,
required: false,
},
feedbackInvalid: {
type: String,
default: "",
},
accept: {
type: String,
default: "",
},
options: {
type: Object,
default() {
return {};
},
},
control: {
type: [String, Object],
default: "",
},
form: {
type: Object,
default: () => {
return {};
},
required: false,
},
});
const emits = defineEmits(["update:modelValue", "focus", "blur", "keydown"]);
const slots = useSlots();
const mediaUrl = ref("");
const objForm = inject("form", props.form);
const objControl =
typeof props.control == "object"
? props.control
: !isEmpty(objForm) &&
typeof props.control == "string" &&
props.control != ""
? objForm.controls[props.control]
: null;
const label = ref(props.label);
const feedbackInvalid = ref(props.feedbackInvalid);
const value = ref(props.modelValue);
const inputActive = ref(value.value.length > 0 || props.type == "select");
/**
* Return the classname based on type
*/
const computedClassType = computed(() => {
return `sm-input-type-${props.type}`;
});
watch(
() => props.label,
(newValue) => {
label.value = newValue;
}
);
if (objControl) {
if (value.value.length > 0) {
objControl.value = value.value;
} else {
value.value = objControl.value;
}
if (label.value.length == 0 && typeof props.control == "string") {
label.value = toTitleCase(props.control);
}
inputActive.value = value.value?.length > 0 || props.type == "select";
watch(
() => objControl.validation.result.valid,
(newValue) => {
feedbackInvalid.value = newValue
? ""
: objControl.validation.result.invalidMessages[0];
},
{ deep: true }
);
watch(
() => objControl.value,
(newValue) => {
value.value = newValue;
},
{ deep: true }
);
}
watch(
() => props.modelValue,
(newValue) => {
value.value = newValue;
}
);
watch(
() => props.feedbackInvalid,
(newValue) => {
feedbackInvalid.value = newValue;
}
);
watch(
() => value.value,
async (newValue) => {
if (newValue) {
inputActive.value = newValue.length > 0;
if (props.type == "media") {
if (isUUID(newValue)) {
try {
const result = await api.get({
url: "/media/{id}",
params: {
id: newValue,
},
});
const data = result.data as MediaResponse;
if (data && data.medium) {
mediaUrl.value = mediaGetVariantUrl(
data.medium,
"small"
);
}
} catch (error) {
/* empty */
}
}
}
}
}
);
const handleChange = (event) => {
emits("update:modelValue", event.target.files[0]);
};
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement;
value.value = target.value;
emits("update:modelValue", target.value);
if (objControl) {
objControl.value = target.value;
feedbackInvalid.value = "";
}
};
const handleFocus = (event: Event) => {
inputActive.value = true;
if (event instanceof KeyboardEvent) {
if (event.key === undefined || event.key === "Tab") {
emits("blur", event);
}
}
emits("focus", event);
};
const handleBlur = async (event: Event) => {
if (objControl) {
await objControl.validate();
objControl.isValid();
}
const target = event.target as HTMLInputElement;
if (target.value.length == 0) {
inputActive.value = false;
}
emits("blur", event);
};
const handleKeydown = (event: Event) => {
emits("keydown", event);
};
const handleMediaSelect = async (event) => {
let result = await openDialog(SMDialogMedia);
if (result) {
mediaUrl.value = result.url;
emits("update:modelValue", result.id);
if (objControl) {
objControl.value = result.id;
feedbackInvalid.value = "";
}
}
};
</script>
<style lang="scss">
.sm-column .sm-input-group {
margin-bottom: 0;
}
.sm-input-group {
position: relative;
display: flex;
flex-direction: column;
margin-bottom: map-get($spacer, 4);
flex: 1;
width: 100%;
&.sm-input-small {
font-size: 80%;
&.sm-input-active {
label {
transform: translate(6px, -3px) scale(0.7);
}
input {
padding: calc(#{map-get($spacer, 1)} * 1.5) map-get($spacer, 2)
calc(#{map-get($spacer, 1)} / 2) map-get($spacer, 2);
}
}
input,
label {
padding: map-get($spacer, 1) map-get($spacer, 2);
}
}
&.sm-input-active {
label {
transform: translate(8px, -3px) scale(0.7);
color: $secondary-color-dark;
}
input {
padding: calc(#{map-get($spacer, 2)} * 1.5) map-get($spacer, 3)
calc(#{map-get($spacer, 2)} / 2) map-get($spacer, 3);
}
textarea {
padding: calc(#{map-get($spacer, 2)} * 2) map-get($spacer, 3)
calc(#{map-get($spacer, 2)} / 2) map-get($spacer, 3);
}
select {
padding: calc(#{map-get($spacer, 2)} * 2) map-get($spacer, 3)
calc(#{map-get($spacer, 2)} / 2) map-get($spacer, 3);
}
}
&.sm-feedback-invalid {
input,
select,
textarea {
border: 2px solid $danger-color;
}
.sm-invalid-icon {
display: block;
}
}
label {
position: absolute;
display: block;
padding: map-get($spacer, 2) map-get($spacer, 3);
line-height: 1.5;
transform-origin: top left;
transform: translate(0, 1px) scale(1);
transition: all 0.1s ease-in-out;
color: $secondary-color-dark;
pointer-events: none;
}
.sm-invalid-icon {
position: absolute;
display: none;
right: 0;
top: 2px;
padding: map-get($spacer, 2) map-get($spacer, 3);
color: $danger-color;
font-size: 120%;
}
&.sm-input-select {
.sm-invalid-icon {
display: none;
}
}
input,
select,
textarea {
box-sizing: border-box;
display: block;
width: 100%;
border: 1px solid var(--base-color-darker);
border-radius: 12px;
padding: 14px 18px;
color: var(--base-color-text);
margin-bottom: 8px;
background-color: var(--base-color-light);
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
textarea {
resize: none;
}
select {
padding-right: 2.5rem;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 0.75rem center;
background-size: 24px 18px;
}
&.sm-input-type-media {
label {
position: relative;
transform: none;
}
.sm-input-help {
text-align: center;
}
&.sm-feedback-invalid .sm-input-media .sm-input-media-item ion-icon {
border: 2px solid $danger-color;
}
&.sm-feedback-invalid .sm-invalid-icon {
// position: relative;
}
}
.sm-input-media {
text-align: center;
margin-bottom: map-get($spacer, 2);
.sm-input-media-item {
display: block;
margin-bottom: 0.5rem;
img {
max-width: 100%;
max-height: 100%;
}
ion-icon {
padding: 4rem;
font-size: 3rem;
border: 1px solid $border-color;
background-color: #fff;
}
}
.button {
display: inline-block;
}
}
.sm-input-help {
font-size: 75%;
margin: 0 map-get($spacer, 1);
color: $secondary-color-dark;
.sm-input-invalid {
color: $danger-color;
padding-right: map-get($spacer, 1);
}
}
.sm-input-file-group {
display: flex;
align-items: center;
margin-bottom: 0.5rem;
border: 1px solid transparent;
border-radius: 12px;
input {
opacity: 0;
width: 0.1px;
height: 0.1px;
position: absolute;
margin-left: -9999px;
}
label.button {
margin-right: map-get($spacer, 4);
border-top-left-radius: 10px;
border-bottom-left-radius: 10px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
margin: 0;
height: 3rem;
width: auto;
}
.sm-file-name {
display: block;
border: 1px solid $border-color;
border-top-right-radius: 12px;
border-bottom-right-radius: 12px;
flex: 1;
height: 3rem;
background-color: #fff;
line-height: 3rem;
padding: 0 1rem;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
</style>

View File

@@ -1,12 +0,0 @@
import { Directive } from "vue";
const bodyClass: Directive = {
mounted(el, binding) {
document.body.classList.add(binding.value as string);
},
unmounted(el, binding) {
document.body.classList.remove(binding.value as string);
},
};
export default bodyClass;

View File

@@ -90,6 +90,7 @@ export interface User {
first_name: string; first_name: string;
last_name: string; last_name: string;
phone: string; phone: string;
display_name: string;
} }
export interface UserResponse { export interface UserResponse {

View File

@@ -497,6 +497,13 @@ router.afterEach((to, from) => {
document.body.classList.remove(`page-${from.name}`); document.body.classList.remove(`page-${from.name}`);
} }
document.body.classList.add(`page-${to.name}`); document.body.classList.add(`page-${to.name}`);
window.setTimeout(() => {
const autofocusElement = document.querySelector("[autofocus]");
if (autofocusElement) {
autofocusElement.focus();
}
}, 10);
}); });
export default router; export default router;

View File

@@ -1,6 +1,6 @@
<template> <template>
<SMMastHead title="Blog" /> <SMMastHead title="Blog" />
<SMContainer> <SMContainer class="flex-grow-1">
<SMInput <SMInput
type="text" type="text"
label="Search articles" label="Search articles"
@@ -14,6 +14,10 @@
@click="handleClickSearch" @click="handleClickSearch"
/></template> /></template>
</SMInput> </SMInput>
<template v-if="pageLoading">
<SMLoading large />
</template>
<template v-else>
<SMPagination <SMPagination
v-if="postsTotal > postsPerPage" v-if="postsTotal > postsPerPage"
v-model="postsPage" v-model="postsPage"
@@ -34,7 +38,7 @@
)})`, )})`,
}"></div> }"></div>
<div class="info"> <div class="info">
{{ post.user.username }} - {{ post.user.display_name }} -
{{ computedDate(post.publish_at) }} {{ computedDate(post.publish_at) }}
</div> </div>
<h3 class="title">{{ post.title }}</h3> <h3 class="title">{{ post.title }}</h3>
@@ -43,6 +47,7 @@
</p> </p>
</router-link> </router-link>
</div> </div>
</template>
</SMContainer> </SMContainer>
</template> </template>
@@ -57,6 +62,7 @@ import SMMastHead from "../components/SMMastHead.vue";
import SMInput from "../components/SMInput.vue"; import SMInput from "../components/SMInput.vue";
import SMButton from "../components/SMButton.vue"; import SMButton from "../components/SMButton.vue";
import { excerpt } from "../helpers/string"; import { excerpt } from "../helpers/string";
import SMLoading from "../components/SMLoading.vue";
const message = ref(""); const message = ref("");
const pageLoading = ref(true); const pageLoading = ref(true);
@@ -159,7 +165,7 @@ handleLoad();
.title { .title {
margin: 16px 0; margin: 16px 0;
word-break: break-all; word-break: break-word;
} }
.content { .content {

View File

@@ -9,7 +9,7 @@
</p> </p>
</template> </template>
<template #body> <template #body>
<SMInput control="username"> <SMInput control="username" autofocus>
<router-link to="/forgot-username" <router-link to="/forgot-username"
>Forgot username?</router-link >Forgot username?</router-link
> >

View File

@@ -12,7 +12,7 @@
<template #body> <template #body>
<SMRow> <SMRow>
<SMColumn> <SMColumn>
<SMInput control="username" /> <SMInput control="username" autofocus />
</SMColumn> </SMColumn>
<SMColumn> <SMColumn>
<SMInput <SMInput

View File

@@ -1,6 +1,6 @@
<template> <template>
<SMMastHead title="Workshops" /> <SMMastHead title="Workshops" />
<SMContainer> <SMContainer class="flex-grow-1">
<SMToolbar class="align-items-start"> <SMToolbar class="align-items-start">
<SMInput <SMInput
v-model="filterKeywords" v-model="filterKeywords"
@@ -32,7 +32,11 @@
:message="formMessage" :message="formMessage"
class="mt-5" /> class="mt-5" />
<div v-if="postsTotal > 0" class="events"> <template v-if="pageLoading">
<SMLoading large />
</template>
<SMNoItems v-else-if="postsTotal == 0" />
<div v-else class="events">
<router-link <router-link
class="event-card" class="event-card"
v-for="event in events" v-for="event in events"
@@ -76,7 +80,6 @@
</div> </div>
</router-link> </router-link>
</div> </div>
<SMNoItems v-else />
</SMContainer> </SMContainer>
</template> </template>
@@ -92,6 +95,7 @@ import { SMDate } from "../helpers/datetime";
import SMMastHead from "../components/SMMastHead.vue"; import SMMastHead from "../components/SMMastHead.vue";
import SMContainer from "../components/SMContainer.vue"; import SMContainer from "../components/SMContainer.vue";
import SMNoItems from "../components/SMNoItems.vue"; import SMNoItems from "../components/SMNoItems.vue";
import SMLoading from "../components/SMLoading.vue";
interface EventData { interface EventData {
event: Event; event: Event;
@@ -99,7 +103,7 @@ interface EventData {
bannerType: string; bannerType: string;
} }
const loading = ref(true); const pageLoading = ref(true);
let events: Event[] = reactive([]); let events: Event[] = reactive([]);
const dateRangeError = ref(""); const dateRangeError = ref("");
@@ -166,7 +170,7 @@ const handleLoad = async () => {
dateRangeError.value = ""; dateRangeError.value = "";
} }
loading.value = true; pageLoading.value = true;
formMessage.value = ""; formMessage.value = "";
events = []; events = [];
@@ -244,7 +248,7 @@ const handleLoad = async () => {
"Could not load any events from the server."; "Could not load any events from the server.";
} }
} finally { } finally {
loading.value = false; pageLoading.value = false;
} }
}; };