This commit is contained in:
2023-02-27 14:52:01 +10:00
parent aeb7939c6e
commit c8e49ba49c
71 changed files with 958 additions and 1358 deletions

View File

@@ -6,17 +6,17 @@
<div
v-for="file of props.attachments"
:key="file.id"
class="attachment-row">
<div class="attachment-file-icon">
class="sm-attachment-row">
<div class="sm-attachment-file-icon">
<img
:src="getFileIconImagePath(file.title || file.name)"
height="48"
width="48" />
</div>
<a :href="file.url" class="attachment-file-name">{{
<a :href="file.url" class="sm-attachment-file-name">{{
file.title || file.name
}}</a>
<div class="attachment-file-size">
<div class="sm-attachment-file-size">
({{ bytesReadable(file.size) }})
</div>
</div>
@@ -25,6 +25,7 @@
<script setup lang="ts">
import { bytesReadable } from "../helpers/types";
import { getFileIconImagePath } from "../helpers/utils";
import SMContainer from "./SMContainer.vue";
const props = defineProps({
@@ -33,11 +34,6 @@ const props = defineProps({
required: true,
},
});
const getFileIconImagePath = (fileName: string): string => {
const ext = fileName.split(".").pop();
return `/img/fileicons/${ext}.png`;
};
</script>
<style lang="scss">
@@ -46,7 +42,7 @@ const getFileIconImagePath = (fileName: string): string => {
margin-top: map-get($spacer, 3);
}
.attachment-row {
.sm-attachment-row {
border-bottom: 1px solid $secondary-background-color;
display: flex;
align-items: center;
@@ -56,13 +52,13 @@ const getFileIconImagePath = (fileName: string): string => {
border-bottom: 0;
}
.attachment-file-icon {
.sm-attachment-file-icon {
display: flex;
width: 64px;
justify-content: center;
}
.attachment-file-size {
.sm-attachment-file-size {
font-size: 75%;
padding-left: 0.75rem;
color: $secondary-color-dark;

View File

@@ -1,51 +1,53 @@
<template>
<SMContainer
v-if="showBreadcrumbs"
:class="[
'flex-0',
'breadcrumbs-outer',
{ closed: breadcrumbs.length == 0 },
'sm-breadcrumbs-container',
{ closed: computedRouteCrumbs.length == 0 },
]">
<ul class="breadcrumbs">
<ul class="sm-breadcrumbs">
<li><router-link :to="{ name: 'home' }">Home</router-link></li>
<li v-for="(val, idx) of breadcrumbs" :key="val.name">
<li
v-for="(routeItem, index) of computedRouteCrumbs"
:key="routeItem.name">
<router-link
v-if="idx != breadcrumbs.length - 1"
:to="{ name: val.name }"
>{{ val.meta?.title || val.name }}</router-link
><span v-else>{{ val.meta?.title || val.name }}</span>
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, ref } from "vue";
import { useRoute } from "vue-router";
import { computed, ComputedRef } from "vue";
import { RouteRecordRaw, useRoute } from "vue-router";
import { routes } from "../router";
import { useApplicationStore } from "../store/ApplicationStore";
const applicationStore = useApplicationStore();
const showBreadcrumbs = ref(true);
const breadcrumbs = computed(() => {
/**
* 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) => {
let found = null;
let index = null;
let child = null;
const findMatch = (list: RouteRecordRaw[]): RouteRecordRaw[] | null => {
let found: RouteRecordRaw[] | null = null;
let index: RouteRecordRaw | null = null;
let child: RouteRecordRaw[] | null = null;
list.every((entry) => {
list.every((entry: RouteRecordRaw) => {
if (index == null && "path" in entry && entry.path == "") {
index = entry;
}
if (child == null && "children" in entry) {
if (child == null && entry.children) {
child = findMatch(entry.children);
}
@@ -74,32 +76,18 @@ const breadcrumbs = computed(() => {
};
let itemList = findMatch(routes);
if (itemList) {
if (applicationStore.dynamicTitle.length > 0) {
let meta = [];
if ("meta" in itemList) {
meta = itemList[itemList.length - 1];
}
meta["title"] = applicationStore.dynamicTitle;
itemList[itemList.length - 1]["meta"] = meta;
}
}
return itemList || [];
});
</script>
<style lang="scss">
.breadcrumbs-outer.closed .breadcrumbs {
.sm-breadcrumbs-container.closed .sm-breadcrumbs {
opacity: 0;
transition: opacity 0s;
transition-delay: 0s;
}
.breadcrumbs {
.sm-breadcrumbs {
height: 3.25rem;
display: flex;
max-width: 1200px;

View File

@@ -1,36 +1,26 @@
<template>
<a
v-if="href.length > 0 || typeof to == 'string'"
:href="href"
:disabled="disabled"
:class="[
'button',
'prevent-select',
classType,
{ 'button-block': block },
]"
:type="buttonType">
{{ label }}
<ion-icon v-if="icon" :icon="icon" />
</a>
<button
v-else-if="to == null"
v-if="isEmpty(to)"
:disabled="disabled"
:class="[
'button',
'prevent-select',
'sm-button',
classType,
{ 'button-block': block },
{ 'dropdown-button': dropdown },
{ 'sm-button-block': block },
{ 'sm-dropdown-button': dropdown },
]"
:type="buttonType"
@click="handleClick">
<ion-icon
v-if="icon && dropdown == null && iconLocation == 'before'"
:icon="icon" />
<span>{{ label }}</span>
<ion-icon v-if="icon && dropdown == null" :icon="icon" />
<ion-icon
v-if="icon && dropdown == null && iconLocation == 'after'"
:icon="icon" />
<ion-icon
v-if="dropdown != null"
name="caret-down-outline"
@click.stop="handleToggleDropdown" />
@click.stop="handleClickToggleDropdown" />
<ul
v-if="dropdown != null"
ref="dropdownMenu"
@@ -43,23 +33,29 @@
</li>
</ul>
</button>
<router-link
v-else
:to="to"
<a
v-else-if="!isEmpty(to) && typeof to == 'string'"
:href="to"
:disabled="disabled"
:class="[
'button',
'prevent-select',
classType,
{ 'button-block': block },
]">
:class="['sm-button', classType, { 'sm-button-block': block }]"
:type="buttonType">
{{ label }}
<ion-icon v-if="icon" :icon="icon" />
</a>
<router-link
v-else-if="!isEmpty(to) && typeof to == 'object'"
:to="to"
:disabled="disabled"
:class="['sm-button', classType, { 'sm-button-block': block }]">
<ion-icon v-if="icon && iconLocation == 'before'" :icon="icon" />
{{ label }}
<ion-icon v-if="icon && iconLocation == 'after'" :icon="icon" />
</router-link>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { Ref, ref } from "vue";
import { isEmpty } from "../helpers/utils";
const props = defineProps({
label: { type: String, default: "Button", required: false },
@@ -69,17 +65,20 @@ const props = defineProps({
default: "",
required: false,
},
iconLocation: {
type: String,
default: "before",
required: false,
validator: (value: string) => {
return ["before", "after"].includes(value);
},
},
to: {
type: [String, Object],
default: null,
required: false,
validator: (prop) => typeof prop === "object" || prop === null,
},
href: {
type: String,
default: "",
required: false,
},
disabled: {
type: Boolean,
default: false,
@@ -98,21 +97,26 @@ const props = defineProps({
},
});
const buttonType = props.type == "submit" ? "submit" : "button";
const buttonType: "submit" | "button" =
props.type == "submit" ? "submit" : "button";
const classType = props.type == "submit" ? "primary" : props.type;
const dropdownMenu = ref(null);
const dropdownMenu: Ref<HTMLElement | null> = ref(null);
const emits = defineEmits(["click"]);
const handleClick = () => {
emits("click", "");
};
const handleToggleDropdown = () => {
dropdownMenu.value.style.display = "block";
const handleClickToggleDropdown = () => {
if (dropdownMenu.value) {
dropdownMenu.value.style.display = "block";
}
};
const handleMouseLeave = () => {
dropdownMenu.value.style.display = "none";
if (dropdownMenu.value) {
dropdownMenu.value.style.display = "none";
}
};
const handleClickItem = (item: string) => {
@@ -121,16 +125,40 @@ const handleClickItem = (item: string) => {
</script>
<style lang="scss">
.button {
a.sm-button,
.sm-button {
cursor: pointer;
position: relative;
padding: map-get($spacer, 2) map-get($spacer, 4);
color: white;
font-weight: 800;
border-width: 2px;
border-style: solid;
border-radius: 24px;
transition: background-color 0.1s, color 0.1s;
background-color: $secondary-color;
border-color: $secondary-color;
min-width: 7rem;
text-align: center;
display: inline-block;
&.button-block {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
&.sm-button-block {
display: block;
width: 100%;
}
&.dropdown-button {
&.sm-button-small {
font-size: 85%;
font-weight: normal;
padding: map-get($spacer, 1) map-get($spacer, 3);
}
&.sm-dropdown-button {
padding: 0;
white-space: nowrap;
display: flex;
@@ -146,7 +174,6 @@ const handleClickItem = (item: string) => {
span {
flex: 1;
border-right: 1px solid $primary-color;
padding: 0;
padding-top: calc(#{map-get($spacer, 1)} / 1.5);
padding-bottom: calc(#{map-get($spacer, 1)} / 1.5);
padding-left: map-get($spacer, 3);
@@ -169,6 +196,91 @@ const handleClickItem = (item: string) => {
}
}
&:disabled {
cursor: not-allowed;
background-color: $secondary-color !important;
border-color: $secondary-color !important;
opacity: 0.5;
}
&:hover:not(:disabled) {
text-decoration: none;
color: $secondary-color;
}
&.primary {
background-color: $primary-color;
border-color: $primary-color;
&:hover:not(:disabled) {
color: $primary-color;
}
}
&.primary-outline {
background-color: transparent;
border-color: $primary-color;
color: $primary-color;
&:hover:not(:disabled) {
color: $primary-color;
}
}
&.secondary {
background-color: $secondary-color;
border-color: $secondary-color;
&:hover:not(:disabled) {
color: $secondary-color;
}
}
&.secondary-outline {
background-color: transparent;
border-color: $secondary-color;
color: $secondary-color;
&:hover:not(:disabled) {
color: $secondary-color;
}
}
&.danger {
background-color: $danger-color;
border-color: $danger-color;
&:hover:not(:disabled) {
color: $danger-color;
}
}
&.danger-outline {
background-color: transparent;
border-color: $danger-color;
color: $danger-color;
&:hover:not(:disabled) {
color: $danger-color;
}
}
&.outline {
background-color: transparent;
border-color: $outline-color;
color: $outline-color;
&:hover:not(:disabled) {
background-color: $outline-color;
border-color: $outline-color;
color: $outline-hover-color;
}
}
&:hover:not(:disabled) {
background-color: #fff;
}
ion-icon {
height: 1.2rem;
width: 1.2rem;
@@ -195,7 +307,7 @@ const handleClickItem = (item: string) => {
li {
padding: 12px 16px;
cursor: pointer;
transition: background 0.1s ease-in-out;
transition: background-color 0.1s ease-in-out;
}
li:hover {

View File

@@ -1,5 +1,5 @@
<template>
<div class="captcha-notice">
<div class="sm-captcha-notice">
This site is protected by reCAPTCHA and the Google
<a href="https://policies.google.com/privacy">Privacy Policy</a> and
<a href="https://policies.google.com/terms">Terms of Service</a> apply.
@@ -7,7 +7,7 @@
</template>
<style lang="scss">
.captcha-notice {
.sm-captcha-notice {
color: $secondary-color;
font-size: 65%;
line-height: 1.2rem;

View File

@@ -1,60 +1,81 @@
<template>
<div
class="carousel"
class="sm-carousel"
@mouseover="handleMouseOver"
@mouseleave="handleMouseLeave">
<div ref="slides" class="carousel-slides">
<div ref="slides" class="sm-carousel-slides">
<slot></slot>
</div>
<div class="carousel-slide-prev" @click="handleSlidePrev">
<div class="sm-carousel-slide-prev" @click="handleClickSlidePrev">
<ion-icon name="chevron-back-outline" />
</div>
<div class="carousel-slide-next" @click="handleSlideNext">
<div class="sm-carousel-slide-next" @click="handleClickSlideNext">
<ion-icon name="chevron-forward-outline" />
</div>
<div class="carousel-slide-indicators">
<div class="sm-carousel-slide-indicators">
<div
v-for="(indicator, index) in slideElements"
:key="index"
:class="[
'carousel-slide-indicator-item',
'sm-carousel-slide-indicator-item',
{ highlighted: currentSlide == index },
]"
@click="handleIndicator(index)"></div>
@click="handleClickIndicator(index)"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { onMounted, onUnmounted, Ref, ref } from "vue";
const slides = ref(null);
let slideElements = ref([]);
/**
* 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);
let intervalRef = null;
const mutationObserver = ref(null);
onMounted(() => {
connectMutationObserver();
handleUpdate();
startAutoSlide();
});
/**
* The window interval reference to slide the carousel.
*/
let intervalRef: number | null = null;
onUnmounted(() => {
stopAutoSlide();
disconnectMutationObserver();
});
/**
* 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();
};
const handleSlidePrev = () => {
/**
* Handle the user clicking the previous slider indicator.
*/
const handleClickSlidePrev = () => {
if (currentSlide.value == 0) {
currentSlide.value = maxSlide.value;
} else {
@@ -64,7 +85,10 @@ const handleSlidePrev = () => {
updateSlidePositions();
};
const handleSlideNext = () => {
/**
* Handle the user clicking the next slider indicator.
*/
const handleClickSlideNext = () => {
if (currentSlide.value == maxSlide.value) {
currentSlide.value = 0;
} else {
@@ -74,34 +98,55 @@ const handleSlideNext = () => {
updateSlidePositions();
};
const handleIndicator = (index) => {
/**
* Handle the user clicking a slider indicator.
*
* @param {number} index The slide to move to.
*/
const handleClickIndicator = (index: number) => {
currentSlide.value = index;
updateSlidePositions();
};
const handleUpdate = () => {
slideElements.value = slides.value.querySelectorAll(".carousel-slide");
maxSlide.value = slideElements.value.length - 1;
/**
* Handle slides added/removed from the carousel and update the data/indicators.
*/
const handleCarouselUpdate = () => {
if (slides.value != null) {
slideElements.value = slides.value.querySelectorAll(".carousel-slide");
maxSlide.value = slideElements.value.length - 1;
}
updateSlidePositions();
};
/**
* Update the style transform of each slide.
*/
const updateSlidePositions = () => {
slideElements.value.forEach((slide, index) => {
slide.style.transform = `translateX(${
100 * (index - currentSlide.value)
}%)`;
});
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(() => {
handleSlideNext();
handleClickSlideNext();
}, 7000);
}
};
/**
* Stop the carousel slider.
*/
const stopAutoSlide = () => {
if (intervalRef != null) {
window.clearInterval(intervalRef);
@@ -109,39 +154,60 @@ const stopAutoSlide = () => {
}
};
/**
* Connect the mutation observer to the slider.
*/
const connectMutationObserver = () => {
mutationObserver.value = new MutationObserver(handleUpdate);
if (slides.value != null) {
mutationObserver.value = new MutationObserver(handleCarouselUpdate);
mutationObserver.value.observe(slides.value, {
attributes: false,
childList: true,
characterData: true,
subtree: true,
});
mutationObserver.value.observe(slides.value, {
attributes: false,
childList: true,
characterData: true,
subtree: true,
});
}
};
/**
* Disconnect the mutation observer from the slider.
*/
const disconnectMutationObserver = () => {
mutationObserver.value.disconnect();
if (mutationObserver.value) {
mutationObserver.value.disconnect();
}
};
onMounted(() => {
connectMutationObserver();
handleCarouselUpdate();
startAutoSlide();
});
onUnmounted(() => {
stopAutoSlide();
disconnectMutationObserver();
});
</script>
<style lang="scss">
.carousel {
.sm-carousel {
position: relative;
height: 28rem;
background: #eee;
overflow: hidden;
&:hover {
.carousel-slide-prev,
.carousel-slide-next,
.carousel-slide-indicators {
.sm-carousel-slide-prev,
.sm-carousel-slide-next,
.sm-carousel-slide-indicators {
opacity: 1;
}
}
.carousel-slide-prev,
.carousel-slide-next {
.sm-carousel-slide-prev,
.sm-carousel-slide-next {
position: absolute;
top: 50%;
font-size: 300%;
@@ -153,7 +219,7 @@ const disconnectMutationObserver = () => {
transition: transform 0.2s ease-in-out, opacity 0.2s ease-in-out;
opacity: 0.75;
svg {
ion-icon {
filter: drop-shadow(0px 0px 2px rgba(0, 0, 0, 1));
}
@@ -163,17 +229,17 @@ const disconnectMutationObserver = () => {
}
}
.carousel-slide-prev {
.sm-carousel-slide-prev {
left: 1rem;
filter: drop-shadow(0px 0px 2px rgba(0, 0, 0, 1));
}
.carousel-slide-next {
.sm-carousel-slide-next {
right: 1rem;
filter: drop-shadow(0px 0px 2px rgba(0, 0, 0, 1));
}
.carousel-slide-indicators {
.sm-carousel-slide-indicators {
position: absolute;
display: flex;
justify-content: center;
@@ -184,7 +250,7 @@ const disconnectMutationObserver = () => {
opacity: 0.75;
transition: opacity 0.2s ease-in-out;
.carousel-slide-indicator-item {
.sm-carousel-slide-indicator-item {
height: 12px;
width: 12px;
border: 1px solid white;
@@ -203,9 +269,9 @@ const disconnectMutationObserver = () => {
}
@media only screen and (max-width: 400px) {
.carousel {
.carousel-slide-prev,
.carousel-slide-next {
.sm-carousel {
.sm-carousel-slide-prev,
.sm-carousel-slide-next {
font-size: 150%;
}
}

View File

@@ -1,17 +1,21 @@
<template>
<div
class="carousel-slide"
class="sm-carousel-slide"
:style="{ backgroundImage: `url('${imageUrl}')` }">
<div v-if="imageUrl.length == 0" class="carousel-slide-loading">
<div v-if="imageUrl.length == 0" class="sm-carousel-slide-loading">
<SMLoadingIcon />
</div>
<div v-else class="carousel-slide-body">
<div class="carousel-slide-content">
<div class="carousel-slide-content-inner">
<div v-else 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="carousel-slide-body-buttons">
<SMButton v-if="url" :to="url" :label="cta" />
<div class="sm-carousel-slide-body-buttons">
<SMButton
v-if="url"
:to="url"
:label="cta"
type="secondary-outline" />
</div>
</div>
</div>
@@ -22,7 +26,7 @@
<script setup lang="ts">
import { ref } from "vue";
import { api } from "../helpers/api";
import { ApiMedia } from "../helpers/api.types";
import { MediaResponse } from "../helpers/api.types";
import { imageLoad } from "../helpers/image";
import SMButton from "./SMButton.vue";
import SMLoadingIcon from "./SMLoadingIcon.vue";
@@ -57,25 +61,32 @@ const props = defineProps({
let imageUrl = ref("");
/**
* Load the slider data.
*/
const handleLoad = () => {
imageUrl.value = "";
api.get(`/media/${props.image}`).then((result) => {
const data = result.data as ApiMedia;
api.get({ url: "/media/{medium}", params: { medium: props.image } })
.then((result) => {
const data = result.data as MediaResponse;
if (data && data.medium) {
imageLoad(data.medium.url, (url) => {
imageUrl.value = url;
});
}
});
if (data && data.medium) {
imageLoad(data.medium.url, (url) => {
imageUrl.value = url;
});
}
})
.catch(() => {
/* empty */
});
};
handleLoad();
</script>
<style lang="scss">
.carousel-slide {
.sm-carousel-slide {
position: absolute;
transition: all 0.5s;
width: 100%;
@@ -85,19 +96,14 @@ handleLoad();
background-size: cover;
overflow: hidden;
.carousel-slide-loading {
.sm-carousel-slide-loading {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
svg {
color: rgba(0, 0, 0, 0.1);
font-size: 300%;
}
}
.carousel-slide-body {
.sm-carousel-slide-body {
display: flex;
align-items: center;
height: 100%;
@@ -105,7 +111,7 @@ handleLoad();
margin: 0 auto;
padding: 1rem;
.carousel-slide-content {
.sm-carousel-slide-content {
display: flex;
justify-content: center;
align-items: center;
@@ -136,17 +142,15 @@ handleLoad();
text-align: left;
}
.carousel-slide-body-buttons {
.sm-carousel-slide-body-buttons {
margin-top: 2rem;
text-align: right;
max-width: 600px;
}
.button {
display: inline-block;
box-shadow: 0 0 12px rgba(0, 0, 0, 0.5);
background: transparent;
.secondary-outline {
border-color: #fff;
color: #fff;
&:hover {
color: #333;

View File

@@ -1,83 +1,27 @@
<template>
<div :class="['container', { full: isFull }]" :style="styleObject">
<SMLoader :loading="loading">
<d-error-forbidden
v-if="pageError == 403 || !hasPermission()"></d-error-forbidden>
<d-error-internal
v-if="pageError >= 500 && hasPermission()"></d-error-internal>
<d-error-not-found v-if="pageError == 404 && hasPermission()"
>XX</d-error-not-found
>
<slot
v-if="
pageError < 300 && hasPermission() && slots.default
"></slot>
<div
v-if="pageError < 300 && hasPermission() && slots.inner"
class="container-inner">
<slot name="inner"></slot>
</div>
</SMLoader>
<div :class="['sm-container', { full: full }]">
<slot v-if="slots.default"></slot>
<div v-if="slots.inner" class="sm-container-inner">
<slot name="inner"></slot>
</div>
</div>
</template>
<script setup lang="ts">
import SMLoader from "./SMLoader.vue";
import DErrorForbidden from "./errors/Forbidden.vue";
import DErrorInternal from "./errors/Internal.vue";
import DErrorNotFound from "./errors/NotFound.vue";
import { useUserStore } from "../store/UserStore";
import { computed, useSlots } from "vue";
import { useSlots } from "vue";
const props = defineProps({
pageError: {
type: Number,
default: 200,
required: false,
},
permission: {
type: String,
default: "",
required: false,
},
loading: {
type: Boolean,
default: false,
required: false,
},
defineProps({
full: {
type: Boolean,
default: false,
required: false,
},
background: {
type: String,
default: "",
required: false,
},
});
const slots = useSlots();
const userStore = useUserStore();
let styleObject = {};
if (props.background != "") {
styleObject["backgroundImage"] = `url('${props.background}')`;
}
const hasPermission = () => {
return (
props.permission.length == 0 ||
userStore.permissions.includes(props.permission)
);
};
const isFull = computed(() => {
return props.pageError == 200 ? props.full : false;
});
</script>
<style lang="scss">
.container {
.sm-container {
display: flex;
flex-direction: column;
flex: 1;
@@ -95,7 +39,7 @@ const isFull = computed(() => {
padding-right: 0;
max-width: 100%;
.container-inner {
.sm-container-inner {
padding-left: 1rem;
padding-right: 1rem;
width: 100%;

View File

@@ -2,13 +2,13 @@
<div
:class="[
'sm-dialog',
{ 'dialog-narrow': narrow },
{ 'dialog-full': full },
{ 'dialog-noshadow': noShadow },
{ 'sm-dialog-narrow': narrow },
{ 'sm-dialog-full': full },
{ 'sm-dialog-noshadow': noShadow },
]">
<transition name="fade">
<div v-if="loading" class="dialog-loading-cover">
<div class="dialog-loading">
<div v-if="loading" class="sm-dialog-loading-cover">
<div class="sm-dialog-loading">
<SMLoadingIcon />
<span>{{ loadingMessage }}</span>
</div>
@@ -58,7 +58,7 @@ defineProps({
min-width: map-get($spacer, 5) * 12;
box-shadow: 4px 4px 20px rgba(0, 0, 0, 0.5);
&.dialog-noshadow {
&.sm-dialog-noshadow {
box-shadow: none !important;
}
@@ -70,16 +70,16 @@ defineProps({
font-size: 90%;
}
&.dialog-narrow {
&.sm-dialog-narrow {
min-width: auto;
max-width: map-get($spacer, 5) * 10;
}
&.dialog-full {
&.sm-dialog-full {
width: 100%;
}
.dialog-loading-cover {
.sm-dialog-loading-cover {
position: fixed;
display: flex;
justify-content: center;
@@ -93,10 +93,11 @@ defineProps({
background-color: rgba(255, 255, 255, 0.5);
z-index: 19000;
.dialog-loading {
.sm-dialog-loading {
display: flex;
flex-direction: column;
padding: map-get($spacer, 5) calc(map-get($spacer, 5) * 2);
align-items: center;
border: 1px solid transparent;
border-radius: 24px;
@@ -119,7 +120,7 @@ defineProps({
map-get($spacer, 4);
min-width: auto;
.button {
.sm-button {
display: block;
width: 100%;
text-align: center;

View File

@@ -1,7 +1,6 @@
<template>
<div class="sm-editor">
<Editor
id="tinymce"
ref="tinyeditor"
v-model="editorContent"
model-events="change blur focus"
@@ -15,49 +14,44 @@
</template>
<script setup lang="ts">
import "tinymce/tinymce";
import Editor from "@tinymce/tinymce-vue";
import "tinymce/themes/silver";
import "tinymce/tinymce";
import "tinymce/icons/default";
import "tinymce/models/dom";
import "tinymce/plugins/image";
import "tinymce/plugins/media";
import "tinymce/plugins/table";
import "tinymce/plugins/lists";
import "tinymce/plugins/advlist";
import "tinymce/plugins/link";
import "tinymce/plugins/autolink";
import "tinymce/plugins/lists";
import "tinymce/plugins/link";
import "tinymce/plugins/image";
import "tinymce/plugins/charmap";
import "tinymce/plugins/searchreplace";
import "tinymce/plugins/visualblocks";
import "tinymce/plugins/code";
import "tinymce/plugins/fullscreen";
import "tinymce/plugins/preview";
import "tinymce/plugins/anchor";
import "tinymce/plugins/insertdatetime";
import "tinymce/plugins/media";
import "tinymce/plugins/help";
import "tinymce/plugins/table";
import "tinymce/plugins/importcss";
import "tinymce/plugins/directionality";
import "tinymce/plugins/visualchars";
import "tinymce/plugins/template";
import "tinymce/plugins/codesample";
import "tinymce/plugins/pagebreak";
import "tinymce/plugins/nonbreaking";
import "tinymce/plugins/emoticons";
import "tinymce/plugins/autolink";
import "tinymce/plugins/autosave";
import "tinymce/plugins/charmap";
import "tinymce/plugins/code";
import "tinymce/plugins/codesample";
import "tinymce/plugins/directionality";
import "tinymce/plugins/emoticons";
import "tinymce/plugins/fullscreen";
import "tinymce/plugins/help";
import "tinymce/plugins/image";
import "tinymce/plugins/importcss";
import "tinymce/plugins/insertdatetime";
import "tinymce/plugins/link";
import "tinymce/plugins/lists";
import "tinymce/plugins/media";
import "tinymce/plugins/nonbreaking";
import "tinymce/plugins/pagebreak";
import "tinymce/plugins/preview";
import "tinymce/plugins/searchreplace";
import "tinymce/plugins/table";
import "tinymce/plugins/template";
import "tinymce/plugins/visualblocks";
import "tinymce/plugins/visualchars";
import "tinymce/plugins/wordcount";
import { ref, watch, computed } from "vue";
import { routes } from "../router";
import { computed, ref, watch } from "vue";
import { api } from "../helpers/api";
import { MediaCollection, MediaResponse } from "../helpers/api.types";
import { routes } from "../router";
interface PageList {
title: string;
@@ -173,11 +167,11 @@ const initialContent = computed(() => {
watch(initialContent, handleInitialContentChange);
const handleBlur = (event, editor) => {
const handleBlur = (event) => {
emits("blur", event);
};
const handleFocus = (event, editor) => {
const handleFocus = (event) => {
emits("focus", event);
};

View File

@@ -1,11 +1,10 @@
<template>
<a :href="computedHref" :target="props.target" rel="noopener"
<a :href="computedUrl" :target="props.target" rel="noopener"
><slot></slot
></a>
</template>
<script setup lang="ts">
// import axios from 'axios'
import { computed } from "vue";
import { useUserStore } from "../store/UserStore";
@@ -16,43 +15,28 @@ const props = defineProps({
},
target: {
type: String,
default: "",
default: "_self",
},
});
const userStore = useUserStore();
const computedHref = computed(() => {
/**
* Return the URL with a token param attached if the user is logged in and its a api media download request.
*/
const computedUrl = computed(() => {
const url = new URL(props.href);
if (url.pathname.startsWith("/api/") && userStore.token) {
return props.href + "?token=" + encodeURIComponent(userStore.token);
const path = url.pathname;
const mediumRegex = /^\/media\/[a-zA-Z0-9]+\/download$/;
if (mediumRegex.test(path) && userStore.token) {
if (url.search) {
return `${props.href}&token=${encodeURIComponent(userStore.token)}`;
} else {
return `${props.href}?token=${encodeURIComponent(userStore.token)}`;
}
}
return props.href;
});
// const handleClick = async (event) => {
// const url = new URL(props.href)
// if(url.pathname.startsWith('/api/')) {
// console.log('api')
// event.preventDefault()
// axios.get(props.href, {responseType: 'blob'})
// .then(response => {
// const blob = new Blob([response.data], { type: response.data.type })
// const href = URL.createObjectURL(blob)
// const link = document.createElement('a')
// link.setAttribute('href', href)
// link.setAttribute('target', props.target)
// document.body.appendChild(link)
// link.click()
// document.body.removeChild(link)
// URL.revokeObjectURL(href)
// }).catch(e => {
// console.log(e)
// })
// }
// console.log('finish')
// }
</script>

View File

@@ -24,6 +24,9 @@ const props = defineProps({
});
const emits = defineEmits(["submit"]);
/**
* Handle the user submitting the form.
*/
const handleSubmit = function () {
if (props.modelValue.validate()) {
emits("submit");

View File

@@ -1,9 +1,11 @@
<template>
<component :is="parsedContent"></component>
<component :is="computedContent"></component>
</template>
<script setup lang="ts">
import DOMPurify from "dompurify";
import { computed } from "vue";
import "../../../import-meta";
const props = defineProps({
html: {
@@ -13,14 +15,19 @@ const props = defineProps({
},
});
const parsedContent = computed(() => {
/**
* Return the html as a component, relative links as router-link and sanitized.
*/
const computedContent = computed(() => {
let html = "";
const regex = new RegExp(
`<a ([^>]*?)href="${import.meta.env.APP_URL}(.*?>.*?)</a>`,
"ig"
);
html = props.html.replace(regex, '<router-link $1to="$2</router-link>');
html = DOMPurify.sanitize(html);
return {
template: `<div class="content">${html}</div>`,

View File

@@ -1,15 +1,12 @@
<template>
<div class="heading">
<router-link
v-if="back != ''"
:to="{ name: back }"
class="heading-back">
<div class="sm-heading">
<router-link v-if="back != ''" :to="{ name: back }" class="sm-back">
<ion-icon name="arrow-back-outline" />{{ backLabel }}
</router-link>
<router-link v-if="close != ''" :to="{ name: close }" class="close">
<router-link v-if="close != ''" :to="{ name: close }" class="sm-close">
<ion-icon name="close-outline" />
</router-link>
<span v-if="closeBack" class="close" @click="handleBack">
<span v-if="closeBack" class="sm-close" @click="handleBack">
<ion-icon name="close-outline" />
</span>
<h1>{{ heading }}</h1>
@@ -50,20 +47,16 @@ const handleBack = () => {
</script>
<style lang="scss">
.heading {
.sm-heading {
position: relative;
.heading-back {
.sm-back {
position: absolute;
padding-top: 2rem;
font-size: 80%;
svg {
margin-right: 0.5rem;
}
}
.close {
.sm-close {
right: -10px;
top: -10px;
position: absolute;
@@ -76,10 +69,8 @@ const handleBack = () => {
}
}
// @media screen and (max-width: 768px) {
@media only screen and (max-width: 640px) {
.heading .close {
.sm-heading .sm-close {
right: 0;
top: -20px;
}

View File

@@ -43,7 +43,7 @@
class="file"
:accept="props.accept"
@change="handleChange" />
<label class="button" for="file">Select file</label>
<label class="sm-button" for="file">Select file</label>
<div class="file-name">
{{ modelValue?.name ? modelValue.name : modelValue }}
</div>
@@ -68,7 +68,11 @@
<img v-if="mediaUrl.length > 0" :src="mediaUrl" />
<ion-icon v-else name="image-outline" />
</div>
<a class="button" @click.prevent="handleMediaSelect">Select file</a>
<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">{{
@@ -82,7 +86,7 @@
</template>
<script setup lang="ts">
import { watch, computed, useSlots, ref, inject } from "vue";
import { computed, inject, ref, useSlots, watch } from "vue";
import { openDialog } from "vue3-promise-dialog";
import { toTitleCase } from "../helpers/string";
import { isEmpty } from "../helpers/utils";
@@ -141,7 +145,7 @@ const objControl =
: !isEmpty(objForm) &&
typeof props.control == "string" &&
props.control != ""
? objForm[props.control]
? objForm.controls[props.control]
: null;
const label = ref(props.label);

View File

@@ -22,6 +22,7 @@
border-radius: 50%;
background: #000;
animation-timing-function: cubic-bezier(0, 1, 1, 0);
box-shadow: 0 0 1px rgba(0, 0, 0, 1);
}
div:nth-child(1) {
left: 8px;

View File

@@ -1,360 +0,0 @@
<template>
<div
:class="[
'sm-input-group',
{ 'sm-input-active': inputActive, 'sm-has-error': error },
]">
<label v-if="label" :class="{ required: required, inline: inline }">{{
label
}}</label>
<ion-icon class="sm-error-icon" name="alert-circle-outline"></ion-icon>
<input
v-if="
type == 'text' ||
type == 'email' ||
type == 'password' ||
type == 'email' ||
type == 'url'
"
:type="type"
:value="modelValue"
:placeholder="placeholder"
@input="input"
@focus="handleFocus"
@blur="handleBlur"
@keydown="handleKeydown" />
<textarea
v-if="type == 'textarea'"
rows="5"
:value="modelValue"
:placeholder="placeholder"
@input="input"
@blur="handleBlur"
@keydown="handleBlur"></textarea>
<div v-if="type == 'file'" class="input-file-group">
<input
id="file"
type="file"
class="file"
:accept="props.accept"
@change="handleChange" />
<label class="button" for="file">Select file</label>
<div class="file-name">
{{ modelValue?.name ? modelValue.name : modelValue }}
</div>
</div>
<a v-if="type == 'link'" :href="href" target="_blank">{{
props.modelValue
}}</a>
<span v-if="type == 'static'">{{ props.modelValue }}</span>
<div v-if="type == 'media'" class="input-media-group">
<div class="input-media-display">
<img v-if="mediaUrl.length > 0" :src="mediaUrl" />
<ion-icon v-else name="image-outline" />
</div>
<div v-if="type == 'media'" class="form-group-error">
{{ error }}
</div>
<a class="button" @click.prevent="handleMediaSelect">Select file</a>
</div>
<div v-if="slots.default || error" class="sm-input-help">
<span v-if="type != 'media'" class="sm-input-error">{{
error
}}</span>
<span v-if="slots.default" class="sm-input-info">
<slot></slot>
</span>
</div>
<div v-if="help" class="form-group-help">
<ion-icon v-if="helpIcon" name="information-circle-outline" />
{{ help }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed, useSlots, ref, watch } from "vue";
import SMDialogMedia from "./dialogs/SMDialogMedia.vue";
import { openDialog } from "vue3-promise-dialog";
const props = defineProps({
modelValue: {
type: String,
default: "",
},
label: {
type: String,
default: "",
required: false,
},
placeholder: {
type: String,
default: "",
required: false,
},
required: {
type: Boolean,
default: false,
},
type: {
type: String,
default: "text",
},
error: {
type: String,
default: "",
},
help: {
type: String,
default: "",
},
helpIcon: {
type: String,
default: "",
},
accept: {
type: String,
default: "",
},
href: {
type: String,
default: "",
},
});
const emits = defineEmits(["update:modelValue", "blur"]);
const slots = useSlots();
const mediaUrl = ref("");
let inputActive = ref(false);
const handleChange = (event) => {
emits("update:modelValue", event.target.files[0]);
emits("blur", event);
};
const input = (event) => {
emits("update:modelValue", event.target.value);
};
const handleBlur = (event) => {
if (props.modelValue.length == 0) {
inputActive.value = false;
}
if (event.keyCode == undefined || event.keyCode == 9) {
emits("blur", event);
}
};
const handleFocus = (event) => {
inputActive.value = true;
if (event.keyCode == undefined || event.keyCode == 9) {
emits("blur", event);
}
};
const handleKeydown = (event) => {};
const handleMediaSelect = async (event) => {
let result = await openDialog(SMDialogMedia);
if (result) {
mediaUrl.value = result.url;
emits("update:modelValue", result.id);
}
};
const inline = computed(() => {
return ["static", "link"].includes(props.type);
});
const handleLoad = async () => {
if (props.type == "media" && props.modelValue.length > 0) {
try {
let result = await api.get(`/media/${props.modelValue}`);
mediaUrl.value = result.json.medium.url;
} catch (error) {
/* empty */
}
}
};
watch(
() => props.modelValue,
() => {
handleLoad();
}
);
</script>
<style lang="scss">
.sm-input-group {
position: relative;
display: flex;
flex-direction: column;
margin-bottom: map-get($spacer, 4);
&.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);
}
}
&.sm-has-error {
input,
select,
textarea {
border: 2px solid $danger-color;
}
.sm-error-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: $font-color;
}
.sm-error-icon {
position: absolute;
display: none;
right: 0;
top: 2px;
padding: map-get($spacer, 2) map-get($spacer, 3);
color: $danger-color;
font-size: 120%;
}
input,
select,
textarea {
box-sizing: border-box;
display: block;
width: 100%;
border: 1px solid $border-color;
border-radius: 12px;
padding: map-get($spacer, 2) map-get($spacer, 3);
color: $font-color;
margin-bottom: map-get($spacer, 1);
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
textarea {
resize: none;
}
.sm-input-help {
font-size: 75%;
margin: 0 map-get($spacer, 1);
.sm-input-error {
color: $danger-color;
padding-right: map-get($spacer, 1);
}
}
}
// .form-group {
// margin-bottom: map-get($spacer, 3);
// padding: 0 4px;
// flex: 1;
// input,
// textarea {
// margin-bottom: map-get($spacer, 1);
// }
// label {
// position: absolute;
// }
// .form-group-info {
// font-size: 85%;
// margin-bottom: map-get($spacer, 1);
// }
// .form-group-error {
// // display: none;
// font-size: 85%;
// margin-bottom: map-get($spacer, 1);
// color: $danger-color;
// }
// .form-group-help {
// font-size: 85%;
// margin-bottom: map-get($spacer, 1);
// color: $secondary-color;
// svg {
// vertical-align: middle !important;
// }
// }
// &.has-error {
// input,
// textarea,
// .input-file-group,
// .input-media-group .input-media-display {
// border: 2px solid $danger-color;
// }
// .form-group-error {
// display: block;
// }
// }
// }
// .input-media-group {
// display: flex;
// margin: 0 auto;
// max-width: 26rem;
// flex-direction: column;
// align-items: center;
// .input-media-display {
// display: flex;
// margin-bottom: 1rem;
// border: 1px solid $border-color;
// background-color: #fff;
// img {
// max-width: 100%;
// max-height: 100%;
// }
// svg {
// padding: 4rem;
// }
// }
// .button {
// max-width: 13rem;
// }
// }
// .input-media-group + .form-group-error {
// text-align: center;
// }
// @media screen and (max-width: 768px) {
// .input-media-group {
// max-width: 13rem;
// }
// }
</style>

View File

@@ -1,6 +1,6 @@
<template>
<div class="sm-message message-outer">
<div :class="['message', type]">
<div class="sm-message-container">
<div :class="['sm-message', type]">
<ion-icon v-if="icon" :name="icon"></ion-icon>
<p>{{ message }}</p>
</div>
@@ -25,52 +25,50 @@ defineProps({
</script>
<style lang="scss">
.sm-message {
&.message-outer {
justify-content: center;
align-self: center;
.sm-message-container {
justify-content: center;
align-self: center;
.message {
display: inline-flex;
padding: map-get($spacer, 2) map-get($spacer, 3);
margin-bottom: map-get($spacer, 4);
text-align: center;
font-size: 90%;
word-break: break-word;
.sm-message {
display: inline-flex;
padding: map-get($spacer, 2) map-get($spacer, 3);
margin-bottom: map-get($spacer, 4);
text-align: center;
font-size: 90%;
word-break: break-word;
&.primary {
background-color: $primary-color-lighter;
color: $primary-color-darker;
border: 1px solid $primary-color-lighter;
border-radius: 12px;
}
&.primary {
background-color: $primary-color-lighter;
color: $primary-color-darker;
border: 1px solid $primary-color-lighter;
border-radius: 12px;
}
&.success {
background-color: $success-color-lighter;
color: $success-color-darker;
border: 1px solid $success-color-lighter;
border-radius: 12px;
}
&.success {
background-color: $success-color-lighter;
color: $success-color-darker;
border: 1px solid $success-color-lighter;
border-radius: 12px;
}
&.error {
background-color: $danger-color-lighter;
color: $danger-color-darker;
border: 1px solid $danger-color-lighter;
border-radius: 12px;
}
&.error {
background-color: $danger-color-lighter;
color: $danger-color-darker;
border: 1px solid $danger-color-lighter;
border-radius: 12px;
}
ion-icon {
height: 1.3em;
width: 1.3em;
margin-right: map-get($spacer, 1);
}
ion-icon {
height: 1.3em;
width: 1.3em;
margin-right: map-get($spacer, 1);
}
p {
margin-bottom: 0;
justify-content: center;
align-self: center;
white-space: pre-wrap;
}
p {
margin-bottom: 0;
justify-content: center;
align-self: center;
white-space: pre-wrap;
}
}
}

View File

@@ -1,5 +1,5 @@
<template>
<div class="modal">
<div class="sm-modal">
<slot></slot>
</div>
</template>

View File

@@ -1,12 +1,14 @@
<template>
<SMContainer
:full="true"
:class="['sm-navbar', { showDropdown: showToggle }]"
@click="handleHideMenu">
:class="['sm-navbar', { 'sm-show-dropdown': showToggle }]"
@click="handleClickHideMenu">
<template #inner>
<div class="navbar-container">
<router-link :to="{ name: 'home' }" class="brand"></router-link>
<ul class="navmenu flex-fill">
<div class="sm-navbar-container">
<router-link
:to="{ name: 'home' }"
class="sm-brand"></router-link>
<ul class="sm-navmenu flex-fill">
<template v-for="item in menuItems">
<li
v-if="
@@ -22,21 +24,23 @@
</ul>
<SMButton
:to="{ name: 'workshop-list' }"
class="navbar-cta"
class="sm-navbar-cta"
label="Find a workshop"
icon="arrow-forward-outline" />
<div class="menuButton" @click.stop="handleToggleMenu">
<div
class="sm-navbar-toggle-menu"
@click.stop="handleClickToggleMenu">
<span>Menu</span
><ion-icon
class="menuButtonIcon"
name="reorder-three-outline"></ion-icon>
><ion-icon name="reorder-three-outline"></ion-icon>
</div>
</div>
</template>
<div class="navbar-dropdown-cover"></div>
<ul class="navbar-dropdown">
<div class="sm-navbar-dropdown-cover"></div>
<ul class="sm-navbar-dropdown">
<li class="ml-auto">
<div class="menuClose" @click.stop="handleToggleMenu">
<div
class="sm-navbar-close-menu"
@click.stop="handleClickToggleMenu">
<ion-icon name="close-outline"></ion-icon>
</div>
</li>
@@ -121,11 +125,17 @@ const menuItems = [
},
];
const handleToggleMenu = () => {
/**
* Hanfle the user clicking an element to toggle the dropdown menu.
*/
const handleClickToggleMenu = () => {
showToggle.value = !showToggle.value;
};
const handleHideMenu = () => {
/**
* Handle the user clicking an element to hide the dropdown menu.
*/
const handleClickHideMenu = () => {
if (showToggle.value) {
showToggle.value = false;
}
@@ -143,20 +153,20 @@ const handleHideMenu = () => {
box-shadow: 0 0 4px rgba(0, 0, 0, 0.2);
z-index: 1000;
&.showDropdown {
.navbar-dropdown-cover {
&.sm-show-dropdown {
.sm-navbar-dropdown-cover {
visibility: visible;
opacity: 1;
transition: visibility 0.3s linear, opacity 0.3s linear;
}
.navbar-dropdown {
.sm-navbar-dropdown {
margin-top: 0;
transition: margin 0.5s ease-in-out;
}
}
.navbar-dropdown-cover {
.sm-navbar-dropdown-cover {
position: fixed;
visibility: hidden;
z-index: 2000;
@@ -169,7 +179,7 @@ const handleHideMenu = () => {
overflow: hidden;
}
.navbar-dropdown {
.sm-navbar-dropdown {
position: fixed;
z-index: 2001;
top: 0;
@@ -203,15 +213,12 @@ const handleHideMenu = () => {
}
}
.navmenu,
.navbar-dropdown {
.sm-navmenu,
.sm-navbar-dropdown {
padding-top: map-get($spacer, 4);
li {
// display: flex;
// width: 100%;
margin: 0 0.75rem;
// justify-content: center;
a {
color: rgba(0, 0, 0, 0.8);
@@ -225,32 +232,33 @@ const handleHideMenu = () => {
}
}
.menuClose ion-icon {
.sm-navbar-close-menu ion-icon {
cursor: pointer;
font-size: map-get($spacer, 4);
padding-left: map-get($spacer, 1);
&:hover {
color: $danger-color;
}
}
}
.navbar-container {
.sm-navbar-container {
display: flex;
flex: 1;
align-items: center;
.brand {
.sm-brand {
display: inline-block;
background-image: url("/img/logo.png");
background-position: left top;
background-repeat: no-repeat;
background-size: contain;
// width: 16.5rem;
// height: 3rem;
width: 13.5rem;
height: 2rem;
// margin-bottom: 1rem;
}
.navmenu {
.sm-navmenu {
flex: 1;
display: flex;
justify-content: end;
@@ -258,9 +266,8 @@ const handleHideMenu = () => {
padding: 0 1rem;
}
.menuButton {
.sm-navbar-toggle-menu {
cursor: pointer;
// display: none;
align-items: center;
font-size: 0.9rem;
margin-left: 2rem;
@@ -270,14 +277,14 @@ const handleHideMenu = () => {
display: none;
}
.menuButtonIcon {
ion-icon {
margin-left: 0.5rem;
font-size: map-get($spacer, 4);
}
}
}
.navbar-cta {
.sm-navbar-cta {
font-size: 0.9rem;
padding: 0.6rem 1.1rem;
}
@@ -285,16 +292,12 @@ const handleHideMenu = () => {
@media only screen and (max-width: 1200px) {
.sm-navbar .navbar-container {
.navmenu li {
.sm-navmenu li {
display: none;
}
.menuButton {
.sm-navbar-toggle-menu {
display: flex;
span {
// display: block;
}
}
}
}
@@ -308,13 +311,13 @@ const handleHideMenu = () => {
}
.navbar-container {
.brand {
.sm-brand {
width: 13.5rem;
height: 2rem;
margin-bottom: 0;
}
.navbar-cta {
.sm-navbar-cta {
font-size: 0.9rem;
padding: 0.5rem 0.75rem;
}
@@ -326,18 +329,18 @@ const handleHideMenu = () => {
.sm-navbar {
height: 4.5rem;
.navbar-dropdown-cover {
.sm-navbar-dropdown-cover {
margin-top: 4.5rem;
}
.navbar-container {
.brand {
.sm-navbar-container {
.sm-brand {
background-image: url("/img/logo-small.png");
width: 3rem;
height: 3rem;
}
.navbar-cta {
.sm-navbar-cta {
font-size: 0.9rem;
padding: 0.5rem 0.75rem;
@@ -346,7 +349,7 @@ const handleHideMenu = () => {
}
}
.menuButton {
.sm-menuButton {
margin-left: 1rem;
span {

View File

@@ -21,14 +21,14 @@
</template>
<script setup lang="ts">
import { useUserStore } from "../store/UserStore";
import { useSlots } from "vue";
import SMLoader from "./SMLoader.vue";
import SMBreadcrumbs from "../components/SMBreadcrumbs.vue";
import { useUserStore } from "../store/UserStore";
import SMErrorForbidden from "./errors/Forbidden.vue";
import SMErrorInternal from "./errors/Internal.vue";
import SMErrorNotFound from "./errors/NotFound.vue";
import SMBreadcrumbs from "../components/SMBreadcrumbs.vue";
import SMContainer from "./SMContainer.vue";
import SMLoader from "./SMLoader.vue";
const props = defineProps({
pageError: {
@@ -66,7 +66,12 @@ if (props.background != "") {
styleObject["backgroundImage"] = `url('${props.background}')`;
}
const hasPermission = () => {
/**
* Return if the current user has the props.permission to view this page.
*
* @returns {boolean} If the user has the permission.
*/
const hasPermission = (): boolean => {
return (
props.permission.length == 0 ||
userStore.permissions.includes(props.permission)
@@ -89,7 +94,6 @@ const hasPermission = () => {
margin-bottom: 0;
.sm-page {
// padding-top: calc(map-get($spacer, 5) * 2);
padding-bottom: calc(map-get($spacer, 5) * 2);
}
}

View File

@@ -1,25 +0,0 @@
<template v-if="error >= 300 || slots.default">
<d-error-forbidden v-if="error == 403"></d-error-forbidden>
<d-error-internal v-if="error >= 500"></d-error-internal>
<d-error-not-found v-if="error == 404"></d-error-not-found>
<template v-if="slots.default && error < 300">
<slot></slot>
</template>
</template>
<script setup lang="ts">
import { useSlots } from "vue";
import DErrorForbidden from "./errors/Forbidden.vue";
import DErrorInternal from "./errors/Internal.vue";
import DErrorNotFound from "./errors/NotFound.vue";
defineProps({
error: {
type: Number,
required: true,
default: 200,
},
});
const slots = useSlots();
</script>

View File

@@ -1,34 +1,36 @@
<template>
<router-link :to="to" class="panel">
<div class="panel-image" :style="styleObject">
<div v-if="dateInImage && date" class="panel-image-date">
<div class="panel-image-date-day">
<router-link :to="to" class="sm-panel">
<div v-if="image" class="sm-panel-image" :style="styleObject">
<div v-if="dateInImage && date" class="sm-panel-image-date">
<div class="sm-panel-image-date-day">
{{ new SMDate(date, { format: "yMd" }).format("dd") }}
</div>
<div class="panel-image-date-month">
<div class="sm-panel-image-date-month">
{{ new SMDate(date, { format: "yMd" }).format("MMM") }}
</div>
</div>
<ion-icon
v-if="hideImageLoader == false"
class="panel-image-loader"
v-if="imageUrl.length == 0"
class="sm-panel-image-loader"
name="image-outline" />
</div>
<div class="panel-body">
<h3 class="panel-title">{{ title }}</h3>
<div v-if="showDate && date" class="panel-date">
<div class="sm-panel-body">
<h3 class="sm-panel-title">{{ title }}</h3>
<div v-if="showDate && date" class="sm-panel-date">
<ion-icon
v-if="showTime == false && endDate.length == 0"
name="calendar-outline" />
<ion-icon v-else name="time-outline" />
<p>{{ panelDate }}</p>
<p>{{ computedDate }}</p>
</div>
<div v-if="location" class="panel-location">
<div v-if="location" class="sm-panel-location">
<ion-icon name="location-outline" />
<p>{{ location }}</p>
</div>
<div v-if="content" class="panel-content">{{ panelContent }}</div>
<div v-if="button.length > 0" class="panel-button">
<div v-if="content" class="sm-panel-content">
{{ computedContent }}
</div>
<div v-if="button.length > 0" class="sm-panel-button">
<SMButton :to="to" :type="buttonType" :label="button" />
</div>
</div>
@@ -36,13 +38,13 @@
</template>
<script setup lang="ts">
import { onMounted, computed, ref, reactive, watch } from "vue";
import { isUUID } from "../helpers/uuid";
import { excerpt, replaceHtmlEntites, stripHtmlTags } from "../helpers/string";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { api } from "../helpers/api";
import { imageLoad } from "../helpers/image";
import { ApiMedia } from "../helpers/api.types";
import { MediaResponse } from "../helpers/api.types";
import { SMDate } from "../helpers/datetime";
import { imageLoad } from "../helpers/image";
import { excerpt, replaceHtmlEntites, stripHtmlTags } from "../helpers/string";
import { isUUID } from "../helpers/uuid";
import SMButton from "./SMButton.vue";
const props = defineProps({
@@ -118,7 +120,10 @@ const props = defineProps({
let styleObject = reactive({});
let imageUrl = ref("");
const panelDate = computed(() => {
/**
* Return a human readable date based on props.date and props.endDate.
*/
const computedDate = computed(() => {
let str = "";
if (props.date.length > 0) {
@@ -149,29 +154,28 @@ const panelDate = computed(() => {
return str;
});
const panelContent = computed(() => {
/**
* Return the content string cleaned from HTML.
*/
const computedContent = computed(() => {
return excerpt(replaceHtmlEntites(stripHtmlTags(props.content)), 200);
});
const hideImageLoader = computed(() => {
return (
imageUrl.value &&
imageUrl.value.length > 0 &&
isUUID(imageUrl.value) == false
);
});
onMounted(async () => {
if (props.image && props.image.length > 0 && isUUID(props.image)) {
api.get(`/media/${props.image}`).then((result) => {
const data = result.data as ApiMedia;
api.get({ url: "/media/{medium}", params: { medium: props.image } })
.then((result) => {
const data = result.data as MediaResponse;
if (data && data.medium) {
imageLoad(data.medium.url, (url) => {
imageUrl.value = url;
});
}
});
if (data && data.medium) {
imageLoad(data.medium.url, (url) => {
imageUrl.value = url;
});
}
})
.catch(() => {
/* empty */
});
}
});
@@ -184,7 +188,7 @@ watch(
</script>
<style lang="scss">
.panel {
.sm-panel {
display: flex;
flex-direction: column;
border: 1px solid $border-color;
@@ -203,7 +207,7 @@ watch(
box-shadow: 0 0 14px rgba(0, 0, 0, 0.25);
}
.panel-image {
.sm-panel-image {
position: relative;
display: flex;
justify-content: center;
@@ -216,12 +220,12 @@ watch(
border-top-right-radius: 12px;
background-color: #eee;
.panel-image-loader {
.sm-panel-image-loader {
font-size: 5rem;
color: $secondary-color;
}
.panel-image-date {
.sm-panel-image-date {
background-color: #fff;
padding: 0.75rem 1rem;
text-align: center;
@@ -232,19 +236,19 @@ watch(
box-shadow: 4px 4px 15px rgba(0, 0, 0, 0.2);
text-align: center;
.panel-image-date-day {
.sm-panel-image-date-day {
font-weight: bold;
font-size: 130%;
}
.panel-image-date-month {
.sm-panel-image-date-month {
text-transform: uppercase;
font-size: 80%;
}
}
}
.panel-body {
.sm-panel-body {
display: flex;
flex-direction: column;
flex: 1;
@@ -252,12 +256,12 @@ watch(
background-color: #fff;
}
.panel-title {
.sm-panel-title {
margin-bottom: 1rem;
}
.panel-date,
.panel-location {
.sm-panel-date,
.sm-panel-location {
display: flex;
flex-direction: row;
align-items: top;
@@ -279,17 +283,10 @@ watch(
}
}
.panel-content {
.sm-panel-content {
margin-top: 1rem;
line-height: 130%;
flex: 1;
}
.panel-button {
.button {
display: block;
margin-top: 1.5rem;
}
}
}
</style>

View File

@@ -1,9 +1,9 @@
<template>
<div class="panel-list">
<div v-if="loading" class="panel-list-loading">
<div class="sm-panel-list">
<div v-if="loading" class="sm-panel-list-loading">
<SMLoadingIcon />
</div>
<div v-else-if="notFound" class="panel-list-not-found">
<div v-else-if="notFound" class="sm-panel-list-not-found">
<ion-icon name="alert-circle-outline" />
<p>{{ notFoundText }}</p>
</div>
@@ -34,7 +34,7 @@ defineProps({
</script>
<style lang="scss">
.panel-list {
.sm-panel-list {
display: flex;
flex-wrap: wrap;
justify-content: center;
@@ -43,17 +43,13 @@ defineProps({
width: 100%;
margin: 0 auto;
.panel-list-loading {
.sm-panel-list-loading {
display: flex;
flex: 1;
justify-content: center;
svg {
font-size: 500%;
}
}
.panel-list-not-found {
.sm-panel-list-not-found {
display: flex;
flex-direction: column;
flex: 1;

View File

@@ -1,5 +1,5 @@
<template>
<div v-show="label == selectedLabel" class="tab-content">
<div v-show="label == selectedLabel" class="sm-tab-content">
<slot></slot>
</div>
</template>
@@ -7,7 +7,7 @@
<script setup lang="ts">
import { inject } from "vue";
const props = defineProps({
defineProps({
label: {
type: String,
required: true,
@@ -18,7 +18,7 @@ const selectedLabel = inject("selectedLabel");
</script>
<style lang="scss">
.tab-content {
.sm-tab-content {
padding: map-get($spacer, 3);
background-color: #fff;
border: 1px solid $border-color;

View File

@@ -1,10 +1,10 @@
<template>
<div class="tab-group">
<ul class="tab-header">
<div class="sm-tab-group">
<ul class="sm-tab-header">
<li
v-for="label in tabLabels"
:key="label"
:class="['tab-item', { selected: selectedLabel == label }]"
:class="['sm-tab-item', { selected: selectedLabel == label }]"
@click="selectedLabel = label">
{{ label }}
</li>
@@ -14,7 +14,7 @@
</template>
<script setup lang="ts">
import { ref, useSlots, provide } from "vue";
import { provide, ref, useSlots } from "vue";
const slots = useSlots();
const tabLabels = ref(slots.default().map((tab) => tab.props.label));
@@ -24,17 +24,17 @@ provide("selectedLabel", selectedLabel);
</script>
<style lang="scss">
.tab-group {
.sm-tab-group {
margin-bottom: map-get($spacer, 4);
.tab-header {
.sm-tab-header {
// border-bottom: 1px solid $border-color;
list-style-type: none;
margin: 0;
padding: 0;
}
.tab-item {
.sm-tab-item {
display: inline-block;
padding: map-get($spacer, 2) map-get($spacer, 3);
border: 1px solid transparent;

View File

@@ -1,26 +1,26 @@
<template>
<div class="toolbar">
<div class="toolbar-column toolbar-column-left">
<div class="sm-toolbar">
<div class="sm-toolbar-column sm-toolbar-column-left">
<slot name="left"></slot>
</div>
<div class="toolbar-column toolbar-column-right">
<div class="sm-toolbar-column sm-toolbar-column-right">
<slot name="right"></slot>
</div>
</div>
</template>
<style lang="scss">
.toolbar {
.sm-toolbar {
display: flex;
justify-content: space-between;
margin-bottom: map-get($spacer, 2);
.toolbar-column {
.sm-toolbar-column {
display: flex;
flex-direction: row;
align-items: center;
&.toolbar-column-left {
&.sm-toolbar-column-left {
justify-content: flex-start;
}
@@ -43,7 +43,7 @@
// }
// }
&.toolbar-column-right {
&.sm-toolbar-column-right {
justify-content: flex-end;
}
// }

View File

@@ -2,19 +2,19 @@
<SMModal>
<SMDialog>
<h1>{{ props.title }}</h1>
<p v-html="sanitizedHtml"></p>
<p v-html="computedSanitizedText"></p>
<SMFormFooter>
<template #left>
<SMButton
:type="props.cancel.type"
:label="props.cancel.label"
@click="handleCancel()" />
@click="handleClickCancel()" />
</template>
<template #right>
<SMButton
:type="props.confirm.type"
:label="props.confirm.label"
@click="handleConfirm()" />
@click="handleClickConfirm()" />
</template>
</SMFormFooter>
</SMDialog>
@@ -22,13 +22,14 @@
</template>
<script setup lang="ts">
import { onMounted, onUnmounted } from "vue";
import DOMPurify from "dompurify";
import { computed, onMounted, onUnmounted } from "vue";
import { closeDialog } from "vue3-promise-dialog";
import { useApplicationStore } from "../../store/ApplicationStore";
import SMButton from "../SMButton.vue";
import SMDialog from "../SMDialog.vue";
import SMFormFooter from "../SMFormFooter.vue";
import SMModal from "../SMModal.vue";
import SMDialog from "../SMDialog.vue";
// import sanitizeHtml from "sanitize-html";
const props = defineProps({
title: {
@@ -59,30 +60,52 @@ const props = defineProps({
},
});
const handleCancel = () => {
const applicationStore = useApplicationStore();
/**
* Handle the user clicking the cancel button.
*/
const handleClickCancel = () => {
closeDialog(false);
};
const handleConfirm = () => {
/**
* Handle the user clicking the confirm button.
*/
const handleClickConfirm = () => {
closeDialog(true);
};
const eventKeyUp = (event: KeyboardEvent) => {
/**
* Sanitize the text property from XSS attacks.
*/
const computedSanitizedText = computed(() => {
return DOMPurify.sanitize(props.text);
});
/**
* Handle a keyboard event in this component.
*
* @param {KeyboardEvent} event The keyboard event.
* @returns {boolean} If the event was handled.
*/
const eventKeyUp = (event: KeyboardEvent): boolean => {
if (event.key === "Escape") {
handleCancel();
handleClickCancel();
return true;
} else if (event.key === "Enter") {
handleConfirm();
handleClickConfirm();
return true;
}
return false;
};
onMounted(() => {
document.addEventListener("keyup", eventKeyUp);
applicationStore.addKeyUpListener(eventKeyUp);
});
onUnmounted(() => {
document.removeEventListener("keyup", eventKeyUp);
applicationStore.removeKeyUpListener(eventKeyUp);
});
// const sanitizedHtml = sanitizeHtml(props.text);
const sanitizedHtml = props.text;
</script>

View File

@@ -3,7 +3,7 @@
<SMDialog
:loading="dialogLoading"
full
:loading_message="dialogLoadingMessage"
:loading-message="dialogLoadingMessage"
class="sm-dialog-media">
<h1>Insert Media</h1>
<SMMessage
@@ -101,18 +101,19 @@
</template>
<script setup lang="ts">
import { computed, watch, ref, onMounted, onUnmounted, Ref } from "vue";
import { computed, onMounted, onUnmounted, ref, Ref, watch } from "vue";
import { closeDialog } from "vue3-promise-dialog";
import SMButton from "../SMButton.vue";
import SMFormFooter from "../SMFormFooter.vue";
import SMDialog from "../SMDialog.vue";
import SMMessage from "../SMMessage.vue";
import SMModal from "../SMModal.vue";
import { api } from "../../helpers/api";
import { Media, MediaCollection, MediaResponse } from "../../helpers/api.types";
import { bytesReadable } from "../../helpers/types";
import { getFilePreview } from "../../helpers/utils";
import { Media, MediaCollection, MediaResponse } from "../../helpers/api.types";
import { useApplicationStore } from "../../store/ApplicationStore";
import SMButton from "../SMButton.vue";
import SMDialog from "../SMDialog.vue";
import SMFormFooter from "../SMFormFooter.vue";
import SMLoadingIcon from "../SMLoadingIcon.vue";
import SMMessage from "../SMMessage.vue";
import SMModal from "../SMModal.vue";
const props = defineProps({
mime: {
@@ -187,6 +188,8 @@ const selected = ref("");
*/
const perPage = ref(12);
const applicationStore = useApplicationStore();
/**
* Returns the pagination info
*/
@@ -247,7 +250,6 @@ const getMediaItem = (item_id: string): Media | null => {
let found: Media | null = null;
mediaItems.value.every((item) => {
console.log(item.id, item_id);
if (item.id == item_id) {
found = item;
return false;
@@ -272,7 +274,6 @@ const handleClickCancel = () => {
const handleClickInsert = () => {
if (selected.value !== "") {
const mediaItem = getMediaItem(selected.value);
console.log(mediaItem, selected.value);
if (mediaItem != null) {
closeDialog(mediaItem);
return;
@@ -365,7 +366,6 @@ const handleClickUpload = () => {
* Upload the file to the server.
*/
const handleChangeUpload = async () => {
dialogLoading.value = true;
formMessage.value = "";
if (refUploadInput.value != null && refUploadInput.value.files != null) {
@@ -374,20 +374,24 @@ const handleChangeUpload = async () => {
let submitFormData = new FormData();
submitFormData.append("file", firstFile);
dialogLoading.value = true;
dialogLoadingMessage.value = "Uploading file...";
api.post({
url: "/media",
body: submitFormData,
headers: {
"Content-Type": "multipart/form-data",
},
// progress: (progressData) =>
// (dialogLoadingMessage.value = `Uploading Files ${Math.floor(
// (progressData.loaded / progressData.total) * 100
// )}%`),
progress: (progressData) =>
(dialogLoadingMessage.value = `Uploading Files ${Math.floor(
(progressData.loaded / progressData.total) * 100
)}%`),
})
.then((result) => {
if (result.data) {
const data = result.data as MediaResponse;
closeDialog(data.medium);
} else {
formMessage.value =
@@ -398,6 +402,9 @@ const handleChangeUpload = async () => {
formMessage.value =
error.response?.data?.message ||
"An unexpected error occurred";
})
.finally(() => {
dialogLoading.value = false;
});
} else {
formMessage.value = "No file was selected to upload";
@@ -405,8 +412,6 @@ const handleChangeUpload = async () => {
} else {
formMessage.value = "No file was selected to upload";
}
dialogLoading.value = false;
};
/**
@@ -440,26 +445,32 @@ const handleLoad = async () => {
};
/**
* Handle the user pressing keyboard keys.
* Handle a keyboard event in this component.
*
* @param {KeyboardEvent} event The keyboard event.
* @returns {boolean} If the event was handled.
*/
const eventKeyUp = (event: KeyboardEvent) => {
const eventKeyUp = (event: KeyboardEvent): boolean => {
if (event.key === "Escape") {
handleClickCancel();
return true;
} else if (event.key === "Enter") {
if (selected.value.length > 0) {
handleClickInsert();
}
return true;
}
return false;
};
onMounted(() => {
document.addEventListener("keyup", eventKeyUp);
applicationStore.addKeyUpListener(eventKeyUp);
});
onUnmounted(() => {
document.removeEventListener("keyup", eventKeyUp);
applicationStore.removeKeyUpListener(eventKeyUp);
});
watch(page, () => {

View File

@@ -10,9 +10,7 @@
</SMPage>
</template>
<script setup lang="ts">
import SMPage from "../SMPage.vue";
</script>
<script setup lang="ts"></script>
<style lang="scss">
.page-error.forbidden .image {

View File

@@ -13,9 +13,7 @@
</SMPage>
</template>
<script setup lang="ts">
import SMPage from "../SMPage.vue";
</script>
<script setup lang="ts"></script>
<style lang="scss">
.page-error.internal .image {

View File

@@ -10,9 +10,7 @@
</SMPage>
</template>
<script setup lang="ts">
import SMPage from "../SMPage.vue";
</script>
<script setup lang="ts"></script>
<style lang="scss">
.page-error.not-found .image {