This commit is contained in:
2023-11-14 09:40:05 +10:00
parent c432b32d08
commit 7d9b6793d3
323 changed files with 907 additions and 65589 deletions

View File

@@ -1,230 +0,0 @@
*,
:after,
:before {
box-sizing: border-box;
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
border: 0 solid #e5e7eb;
}
html {
overflow: -moz-scrollbars-vertical;
overflow-y: auto;
background-color: #f3f4f6;
}
html,
body {
font-family: Poppins, Roboto, "Open Sans", ui-sans-serif, system-ui,
sans-serif;
font-size: 1rem;
color: #000;
width: 100%;
min-height: 100vh;
min-width: 100%;
overflow-x: hidden;
line-height: 1.5;
margin: 0;
}
h1,
h2,
h3,
p {
margin: 0;
}
p + p,
p + ul,
ul + p {
margin-top: 1rem;
}
ol,
ul {
list-style: none;
margin: 0;
padding: 0;
}
small {
display: inline-block;
}
.small {
font-size: smaller;
}
.x-small {
font-size: x-small;
}
.list-decimal,
.list-disc,
.list-circle {
margin-left: 2.5rem;
}
.list-decimal li,
.list-disc li,
.list-circle li {
margin-top: 0.5rem;
}
a:not([role="button"]) {
color: #0284c7;
}
a:not([role="button"]):hover {
color: #0ea5e9;
}
a[role="button"] {
text-decoration: inherit;
}
a {
text-decoration: none;
}
input:disabled {
background-color: rgba(243, 244, 246);
}
input[type="submit"]:disabled {
background-color: rgba(209, 213, 219);
}
input {
font-family: Poppins, Roboto, "Open Sans", ui-sans-serif, system-ui,
sans-serif;
}
.scrollbar-width-none {
scrollbar-width: none;
}
.scrollbar-width-none::-webkit-scrollbar {
display: none;
}
.bg-center {
background-position: center;
}
.whitespace-nowrap {
white-space: nowrap;
}
.spin {
animation: rotate 1s infinite linear;
}
.text-xxs {
font-size: 0.6rem;
line-height: 0.75rem;
}
.text-bold {
font-weight: bold;
}
.sm-html .ProseMirror {
outline: none;
}
.sm-html hr {
border-top: 1px solid #aaa;
margin: 1.5rem 0;
}
.sm-html pre {
padding: 0 1rem;
line-height: 1rem;
}
.sm-html blockquote {
border-left: 4px solid #ddd;
margin-left: 1rem;
padding-left: 1rem;
}
.sm-html p.info,
.sm-html p.success,
.sm-html p.warning,
.sm-html p.danger {
display: flex;
border-radius: 0.5rem;
padding: 0.5rem 1rem 0.5rem 0.75rem;
margin: 0.5rem;
font-size: 80%;
}
.sm-html p.info::before,
.sm-html p.success::before,
.sm-html p.warning::before,
.sm-html p.danger::before {
display: inline-block;
width: 1.5rem;
height: 1.5rem;
margin-right: 0.5rem;
margin-top: 0.1rem;
flex-shrink: 0;
}
.sm-html p.info {
border: 1px solid rgba(14, 165, 233, 1);
background-color: rgba(14, 165, 233, 0.25);
}
.sm-html p.info::before {
color: rgba(14, 165, 233, 1);
content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' %3E%3Cpath d='M11,9H13V7H11M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M11,17H13V11H11V17Z' fill='rgba(14,165,233,1)' /%3E%3C/svg%3E");
}
.sm-html p.success {
border: 1px solid rgba(22, 163, 74, 1);
background-color: rgba(22, 163, 74, 0.25);
}
.sm-html p.success::before {
color: rgba(22, 163, 74, 1);
content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' %3E%3Cpath d='M12 2C6.5 2 2 6.5 2 12S6.5 22 12 22 22 17.5 22 12 17.5 2 12 2M12 20C7.59 20 4 16.41 4 12S7.59 4 12 4 20 7.59 20 12 16.41 20 12 20M16.59 7.58L10 14.17L7.41 11.59L6 13L10 17L18 9L16.59 7.58Z' fill='rgba(22,163,74,1)' /%3E%3C/svg%3E");
}
.sm-html p.warning,
div.warning {
border: 1px solid rgba(202, 138, 4, 1);
background-color: rgba(250, 204, 21, 0.25);
}
.sm-html p.warning::before {
color: rgba(202, 138, 4, 1);
content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' %3E%3Cpath d='M12,2L1,21H23M12,6L19.53,19H4.47M11,10V14H13V10M11,16V18H13V16' fill='rgba(202,138,4,1)' /%3E%3C/svg%3E");
}
.sm-html p.danger {
border: 1px solid rgba(220, 38, 38, 1);
background-color: rgba(220, 38, 38, 0.25);
}
.sm-html p.danger::before {
color: rgba(220, 38, 38, 1);
content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' %3E%3Cpath d='M8.27,3L3,8.27V15.73L8.27,21H15.73L21,15.73V8.27L15.73,3M8.41,7L12,10.59L15.59,7L17,8.41L13.41,12L17,15.59L15.59,17L12,13.41L8.41,17L7,15.59L10.59,12L7,8.41' fill='rgba(220,38,38,1)' /%3E%3C/svg%3E");
}
.sm-html img {
display: block;
margin: 1rem auto;
max-height: 100%;
max-width: 100%;
}
.sm-html ul {
list-style: disc;
margin: 1rem 2rem;
}
.sm-html ol {
list-style: decimal;
margin: 1rem 2rem;
}
.sm-html ul li,
.sm-html ol li {
margin-bottom: 0.25rem;
}
.sm-editor::-webkit-scrollbar {
background-color: transparent;
width: 16px;
}
.sm-editor::-webkit-scrollbar-thumb {
background-color: #aaa;
border: 4px solid transparent;
border-radius: 8px;
background-clip: padding-box;
}
.selected-checked {
border: 3px solid rgba(2, 132, 199, 1);
position: relative;
}
.selected-checked::after {
display: block;
position: absolute;
border: 1px solid white;
height: 1.5rem;
width: 1.5rem;
background-color: rgba(2, 132, 199, 1);
top: -0.4rem;
right: -0.4rem;
content: "";
background-position: center;
background-repeat: no-repeat;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M21,7L9,19L2.712,12.712L5.556,9.892L9.029,13.358L18.186,4.189L21,7Z' fill='rgba(255,255,255,1)' /%3E%3C/svg%3E");
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,32 @@
/**
* We'll load the axios HTTP library which allows us to easily issue requests
* to our Laravel back-end. This library automatically handles sending the
* CSRF token as a header based on the value of the "XSRF" token cookie.
*/
import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
/**
* Echo exposes an expressive API for subscribing to channels and listening
* for events that are broadcast by Laravel. Echo and event broadcasting
* allows your team to easily build robust real-time web applications.
*/
// import Echo from 'laravel-echo';
// import Pusher from 'pusher-js';
// window.Pusher = Pusher;
// window.Echo = new Echo({
// broadcaster: 'pusher',
// key: import.meta.env.VITE_PUSHER_APP_KEY,
// cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER ?? 'mt1',
// wsHost: import.meta.env.VITE_PUSHER_HOST ? import.meta.env.VITE_PUSHER_HOST : `ws-${import.meta.env.VITE_PUSHER_APP_CLUSTER}.pusher.com`,
// wsPort: import.meta.env.VITE_PUSHER_PORT ?? 80,
// wssPort: import.meta.env.VITE_PUSHER_PORT ?? 443,
// forceTLS: (import.meta.env.VITE_PUSHER_SCHEME ?? 'https') === 'https',
// enabledTransports: ['ws', 'wss'],
// });

View File

@@ -1,41 +0,0 @@
<template>
<router-link
:to="{ name: 'article', params: { slug: props.article.slug } }"
class="article-card bg-white border-1 border-rounded-xl text-black decoration-none hover:shadow-md transition min-w-72">
<div
class="h-48 bg-cover bg-center rounded-t-xl relative"
:style="{
backgroundImage: `url(${mediaGetVariantUrl(
props.article.hero,
'medium'
)})`,
}"></div>
<div class="p-4 text-xs text-gray-7">
{{ computedDate(props.article.publish_at) }}
</div>
<h3 class="px-4 mb-3 font-500 text-gray-7">
{{ props.article.title }}
</h3>
<p class="p-4 text-sm text-gray-7">
{{ excerpt(props.article.content) }}
</p>
</router-link>
</template>
<script setup lang="ts">
import { Article } from "../helpers/api.types";
import { SMDate } from "../helpers/datetime";
import { mediaGetVariantUrl } from "../helpers/media";
import { excerpt } from "../helpers/string";
const props = defineProps({
article: {
type: Object as () => Article,
required: true,
},
});
const computedDate = (date) => {
return new SMDate(date, { format: "yMd" }).format("d MMMM yyyy");
};
</script>

View File

@@ -1,193 +0,0 @@
<template>
<div>
<SMHeader
v-if="showEditor || (modelValue && modelValue.length > 0)"
:no-copy="props.showEditor"
text="Files" />
<p v-if="props.showEditor" class="small">
{{ modelValue.length }} file{{ modelValue.length != 1 ? "s" : "" }}
</p>
<table
v-if="modelValue && modelValue.length > 0"
class="w-full border-1 rounded-2 bg-white text-sm mt-2">
<tbody>
<tr v-for="file of fileList" :key="file.id">
<td class="py-2 pl-2 hidden sm:block relative">
<img
:src="getFileIconImagePath(file.name || file.title)"
class="h-10 text-center" />
<div
v-if="file.security_type != ''"
class="absolute right--1 top-0 h-4 w-4">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24">
<title>locked</title>
<path
d="M18,8C19.097,8 20,8.903 20,10L20,20C20,21.097 19.097,22 18,22L6,22C4.903,22 4,21.097 4,20L4,10C4,8.89 4.9,8 6,8L7,8L7,6C7,3.257 9.257,1 12,1C14.743,1 17,3.257 17,6L17,8L18,8M12,3C10.354,3 9,4.354 9,6L9,8L15,8L15,6C15,4.354 13.646,3 12,3Z" />
</svg>
</div>
</td>
<td class="pl-2 py-4 w-full">
<a :href="file.url" target="_blank">{{
file.title || file.name
}}</a>
<p
v-if="file.security_type != ''"
class="text-xs color-gray">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="sm:hidden h-3.5 w-3.5 mb--0.5">
<title>locked</title>
<path
d="M18,8C19.097,8 20,8.903 20,10L20,20C20,21.097 19.097,22 18,22L6,22C4.903,22 4,21.097 4,20L4,10C4,8.89 4.9,8 6,8L7,8L7,6C7,3.257 9.257,1 12,1C14.743,1 17,3.257 17,6L17,8L18,8M12,3C10.354,3 9,4.354 9,6L9,8L15,8L15,6C15,4.354 13.646,3 12,3Z"
fill="currentColor" />
</svg>
This file requires additional permission or a
password to view
</p>
</td>
<td class="pr-2">
<a :href="addQueryParam(file.url, 'download', '1')"
><svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="h-7 pt-1 text-gray">
<path
d="M12 10V20M12 20L9.5 17.5M12 20L14.5 17.5"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M6.3218 7.05726C7.12925 4.69709 9.36551 3 12 3C14.6345 3 16.8708 4.69709 17.6782 7.05726C19.5643 7.37938 21 9.02203 21 11C21 13.2091 19.2091 15 17 15H16C15.4477 15 15 14.5523 15 14C15 13.4477 15.4477 13 16 13H17C18.1046 13 19 12.1046 19 11C19 9.89543 18.1046 9 17 9C16.9776 9 16.9552 9.00037 16.9329 9.0011C16.4452 9.01702 16.0172 8.67854 15.9202 8.20023C15.5502 6.37422 13.9345 5 12 5C10.0655 5 8.44979 6.37422 8.07977 8.20023C7.98284 8.67854 7.55482 9.01702 7.06706 9.0011C7.04476 9.00037 7.02241 9 7 9C5.89543 9 5 9.89543 5 11C5 12.1046 5.89543 13 7 13H8C8.55228 13 9 13.4477 9 14C9 14.5523 8.55228 15 8 15H7C4.79086 15 3 13.2091 3 11C3 9.02203 4.43567 7.37938 6.3218 7.05726Z"
fill="currentColor" />
</svg>
</a>
</td>
<td v-if="props.showEditor" class="pr-2">
<div
class="cursor-pointer text-gray hover:text-red"
@click.prevent="handleClickDelete(file.id)">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-7 pt-1"
viewBox="0 0 24 24">
<title>Delete</title>
<path
d="M9,3V4H4V6H5V19A2,2 0 0,0 7,21H17A2,2 0 0,0 19,19V6H20V4H15V3H9M7,6H17V19H7V6M9,8V17H11V8H9M13,8V17H15V8H13Z"
fill="currentColor" />
</svg>
</div>
</td>
<td
class="text-xs text-gray whitespace-nowrap pr-2 py-2 hidden sm:table-cell">
({{ bytesReadable(file.size) }})
</td>
</tr>
</tbody>
</table>
<button
v-if="props.showEditor"
type="button"
class="font-medium mt-4 px-6 py-1.5 rounded-md hover:shadow-md transition text-sm bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
@click="handleClickAdd">
Add File
</button>
</div>
</template>
<script setup lang="ts">
import { bytesReadable } from "../helpers/types";
import { getFileIconImagePath } from "../helpers/utils";
import { addQueryParam } from "../helpers/url";
import SMHeader from "../components/SMHeader.vue";
import { openDialog } from "../components/SMDialog";
import SMDialogMedia from "./dialogs/SMDialogMedia.vue";
import { Media } from "../helpers/api.types";
import { onMounted, ref, watch } from "vue";
import { mediaGetWebURL } from "../helpers/media";
const emits = defineEmits(["update:modelValue"]);
const props = defineProps({
modelValue: {
type: Array,
default: () => [],
required: true,
},
showEditor: {
type: Boolean,
default: false,
required: false,
},
});
const fileList = ref([]);
/**
* Handle the user adding a new media item.
*/
const handleClickAdd = async () => {
if (props.showEditor) {
let result = await openDialog(SMDialogMedia, {
initial: fileList.value,
mime: "",
accepts: "",
allowUpload: true,
multiple: true,
});
if (result) {
const mediaResult = result as Media[];
let newValue = props.modelValue;
let mediaIds = new Set(newValue.map((item) => (item as Media).id));
mediaResult.forEach((item) => {
if (!mediaIds.has(item.id)) {
newValue.push(item);
mediaIds.add(item.id);
}
});
emits("update:modelValue", newValue);
}
}
};
const handleClickDelete = (id: string) => {
if (props.showEditor == true) {
const newList = props.modelValue.filter(
(item) => (item as Media).id !== id,
);
emits("update:modelValue", newList);
}
};
watch(
() => props.modelValue,
(newValue) => {
updateFileList(newValue as Array<Media>);
},
);
onMounted(() => {
if (props.modelValue !== undefined) {
updateFileList(props.modelValue as Array<Media>);
}
});
const updateFileList = (newFileList: Array<Media>) => {
fileList.value = [];
for (const mediaItem of newFileList) {
mediaItem.url = mediaGetWebURL(mediaItem);
if (mediaItem.url != "") {
fileList.value.push(mediaItem);
}
}
};
</script>

View File

@@ -1,22 +0,0 @@
<template>
<div class="card">
<div v-if="slots.header" class="card-header">
<slot name="header"></slot>
</div>
<div v-if="slots.body || slots.default``" class="card-body">
<slot name="body"></slot>
<slot></slot>
</div>
<div v-if="slots.footer" class="card-footer">
<slot name="footer"></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { useSlots } from "vue";
const slots = useSlots();
</script>
<style lang="scss"></style>

View File

@@ -1,248 +0,0 @@
<template>
<div class="sm-checkbox flex flex-col flex-1">
<label :class="['control-label-checkbox', ,]" v-bind="{ for: id }"
><input
:id="id"
type="checkbox"
class="opacity-0 w-0 h-0 select-none"
:disabled="disabled"
:checked="value"
@input="handleCheckbox" />
<span
:class="[
'h-6',
'w-6',
'rounded',
'border-1',
'border-gray',
'absolute',
disabled ? 'bg-gray-2' : 'bg-white',
]">
<span
:class="[
'sm-check',
'hidden',
'absolute',
'left-1.5',
'top-0.2',
'border-r-4',
'border-b-4',
'h-4',
'w-2.5',
'rotate-45',
disabled ? 'border-gray' : 'border-sky-5',
]"></span> </span
><span
:class="[
'pl-8',
'pt-0.5',
'inline-block',
disabled ? 'text-gray' : 'text-black',
]"
>{{ label }}</span
></label
>
<p v-if="slots.default" class="px-2 pt-2 text-xs text-gray-5">
<slot></slot>
</p>
</div>
</template>
<script setup lang="ts">
import { watch, ref, useSlots, inject } from "vue";
import { isEmpty, generateRandomElementId } from "../helpers/utils";
import { toTitleCase } from "../helpers/string";
const emits = defineEmits(["update:modelValue", "blur", "keyup"]);
const props = defineProps({
form: {
type: Object,
default: undefined,
required: false,
},
control: {
type: [String, Object],
default: "",
},
label: {
type: String,
default: undefined,
required: false,
},
modelValue: {
type: [String, Number, Boolean],
default: undefined,
required: false,
},
type: {
type: String,
default: "text",
required: false,
},
id: {
type: String,
default: undefined,
required: false,
},
disabled: {
type: Boolean,
default: false,
required: false,
},
feedbackInvalid: {
type: String,
default: "",
required: false,
},
autofocus: {
type: Boolean,
default: false,
required: false,
},
options: {
type: Object,
default: null,
required: false,
},
formId: {
type: String,
default: "form",
required: false,
},
});
const slots = useSlots();
const form = inject(props.formId, props.form);
const control =
typeof props.control === "object"
? props.control
: form &&
!isEmpty(form) &&
typeof props.control === "string" &&
props.control !== "" &&
Object.prototype.hasOwnProperty.call(form.controls, props.control)
? form.controls[props.control]
: null;
const label = ref(
props.label != undefined
? props.label
: typeof props.control == "string"
? toTitleCase(props.control)
: "",
);
const value = ref(
props.modelValue != undefined
? props.modelValue
: control != null
? control.value
: "",
);
const id = ref(
props.id != undefined
? props.id
: typeof props.control == "string" && props.control.length > 0
? props.control
: generateRandomElementId(),
);
const feedbackInvalid = ref(props.feedbackInvalid);
const active = ref(value.value?.toString().length ?? 0 > 0);
const focused = ref(false);
const disabled = ref(props.disabled);
const handleCheckbox = (event: Event) => {
const target = event.target as HTMLInputElement;
value.value = target.checked;
emits("update:modelValue", target.checked);
if (control) {
control.value = target.checked;
feedbackInvalid.value = "";
}
};
watch(
() => value.value,
(newValue) => {
active.value = newValue.toString().length > 0 || focused.value == true;
},
);
if (props.modelValue != undefined) {
watch(
() => props.modelValue,
(newValue) => {
value.value = newValue;
},
);
}
watch(
() => props.feedbackInvalid,
(newValue) => {
feedbackInvalid.value = newValue;
},
);
watch(
() => props.disabled,
(newValue) => {
disabled.value = newValue;
},
);
if (typeof control === "object" && control !== null) {
watch(
() => control.validation.result.valid,
(newValue) => {
feedbackInvalid.value = newValue
? ""
: control.validation.result.invalidMessages[0];
},
{ deep: true },
);
watch(
() => control.value,
(newValue) => {
value.value = newValue;
},
{ deep: true },
);
}
const handleFocus = () => {
active.value = true;
focused.value = true;
};
const handleBlur = async () => {
active.value = value.value?.length ?? 0 > 0;
focused.value = false;
emits("blur");
if (control) {
await control.validate();
control.isValid();
}
};
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement;
value.value = target.value;
emits("update:modelValue", target.value);
if (control) {
control.value = target.value;
feedbackInvalid.value = "";
}
};
</script>
<style lang="scss">
.sm-checkbox input:checked + span .sm-check {
display: block;
}
</style>

View File

@@ -1,74 +0,0 @@
<template>
<div :class="['control-group', { 'control-invalid': invalid.length > 0 }]">
<div class="control-row">
<slot></slot>
</div>
<div v-if="!props.noHelp" class="control-help">
<span v-if="invalid" class="control-feedback">
{{ invalid }}
</span>
<span v-if="slots.help"><slot name="help"></slot></span>
</div>
</div>
</template>
<script setup lang="ts">
import { watch, ref, useSlots } from "vue";
const props = defineProps({
invalid: {
type: String,
default: "",
required: false,
},
noHelp: {
type: Boolean,
default: false,
required: false,
},
});
const slots = useSlots();
const invalid = ref(props.invalid);
watch(
() => props.invalid,
(newValue) => {
invalid.value = newValue;
}
);
</script>
<style lang="scss">
.control-group {
width: 100%;
.control-row {
display: flex;
align-items: center;
.control-item {
display: flex;
flex: 1;
position: relative;
align-items: center;
}
}
.control-help {
display: block;
font-size: 70%;
min-height: 32px;
padding-top: 8px;
.control-feedback {
color: var(--danger-color);
}
span + span:before {
content: "-";
margin: 0 6px;
}
}
}
</style>

View File

@@ -1,124 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
AllowedComponentProps,
Component,
defineComponent,
shallowReactive,
VNodeProps,
watch,
} from "vue";
export interface DialogInstance {
comp?: any;
dialog: Component;
wrapper: string;
props: unknown;
resolve: (data: unknown) => void;
}
const dialogRefs = shallowReactive<DialogInstance[]>([]);
export default defineComponent({
name: "SMDialogList",
template: `
<div class="dialog-list">
<div v-for="(dialogRef, index) in dialogRefList" :key="index" class="dialog-outer">
<component
:is="dialogRef.dialog"
v-if="dialogRef && dialogRef.wrapper === name"
v-bind="dialogRef.props"
:ref="(ref) => (dialogRef.comp = ref)"></component>
</div>
</div>
`,
data() {
const dialogRefList = dialogRefs;
return {
name: "default",
transitionAttrs: {},
dialogRefList,
};
},
});
/**
* Closes last opened dialog, resolving the promise with the return value of the dialog, or with the given
* data if any.
* @param {unknown} data The dialog return value.
*/
export function closeDialog(data?: unknown) {
if (dialogRefs.length <= 1) {
document.getElementsByTagName("html")[0].style.overflow = "";
document.getElementsByTagName("body")[0].style.overflow = "";
}
const lastDialog = dialogRefs.pop();
if (data === undefined && lastDialog.comp && lastDialog.comp.returnValue) {
data = lastDialog.comp.returnValue();
}
if (lastDialog && data !== undefined) {
lastDialog.resolve(data);
}
}
/**
* Extracts the type of props from a component definition.
*/
type PropsType<C extends Component> = C extends new (...args: any) => any
? Omit<
InstanceType<C>["$props"],
keyof VNodeProps | keyof AllowedComponentProps
>
: never;
/**
* Extracts the return type of the dialog from the setup function.
*/
type BindingReturnType<C extends Component> = C extends new (
...args: any
) => any
? InstanceType<C> extends { returnValue: () => infer Y }
? Y
: never
: never;
/**
* Extracts the return type of the dialog either from the setup method or from the methods.
*/
type ReturnType<C extends Component> = BindingReturnType<C>;
/**
* Opens a dialog.
* @param {Component} dialog The dialog you want to open.
* @param {PropsType} props The props to be passed to the dialog.
* @param {string} wrapper The dialog wrapper you want the dialog to open into.
* @returns {Promise} A promise that resolves when the dialog is closed
*/
export function openDialog<C extends Component>(
dialog: C,
props?: PropsType<C>,
wrapper: string = "default",
): Promise<ReturnType<C>> {
if (dialogRefs.length === 0) {
document.getElementsByTagName("html")[0].style.overflow = "hidden";
document.getElementsByTagName("body")[0].style.overflow = "hidden";
}
return new Promise((resolve) => {
dialogRefs.push({
dialog,
props,
wrapper,
resolve,
});
window.setTimeout(() => {
const autofocusElement = document.querySelector(
"[autofocus]",
) as HTMLInputElement;
if (autofocusElement) {
autofocusElement.focus();
}
}, 10);
});
}

View File

@@ -1,225 +0,0 @@
<template>
<div class="sm-dropdown flex flex-col flex-1">
<div
:class="[
'relative',
'w-full',
'flex',
{ 'input-active': active || focused },
]">
<label
:for="id"
class="absolute select-none pointer-events-none transform-origin-top-left text-gray block translate-x-4 top-2 scale-70 transition"
>{{ label }}</label
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="absolute right-1 top-1 h-10 pointer-events-none">
<path d="M480-360 280-559h400L480-360Z" fill="currentColor" />
</svg>
<select
:class="[
'appearance-none',
'border-1',
'border-gray',
'rounded-2',
'text-gray-6',
'text-lg',
'px-4',
'pt-5',
'flex-1',
'bg-white',
{ 'bg-gray-1': disabled },
]"
v-bind="{
id: id,
}"
@focus="handleFocus"
@blur="handleBlur"
@input="handleInput"
:value="value"
:disabled="disabled">
<option
v-for="option in Object.entries(props.options)"
:key="option[0]"
:value="option[0]"
:selected="option[0] == value">
{{ option[1] }}
</option>
</select>
</div>
<p v-if="slots.default" class="px-2 pt-2 text-xs text-gray-5">
<slot></slot>
</p>
</div>
</template>
<script setup lang="ts">
import { watch, ref, useSlots, inject } from "vue";
import { isEmpty, generateRandomElementId } from "../helpers/utils";
import { toTitleCase } from "../helpers/string";
const emits = defineEmits(["update:modelValue", "blur", "keyup"]);
const props = defineProps({
form: {
type: Object,
default: undefined,
required: false,
},
control: {
type: [String, Object],
default: "",
},
label: {
type: String,
default: undefined,
required: false,
},
modelValue: {
type: [String, Number, Boolean],
default: undefined,
required: false,
},
id: {
type: String,
default: undefined,
required: false,
},
disabled: {
type: Boolean,
default: false,
required: false,
},
options: {
type: Object,
default: null,
required: false,
},
formId: {
type: String,
default: "form",
required: false,
},
});
const slots = useSlots();
const form = inject(props.formId, props.form);
const control =
typeof props.control === "object"
? props.control
: form &&
!isEmpty(form) &&
typeof props.control === "string" &&
props.control !== "" &&
Object.prototype.hasOwnProperty.call(form.controls, props.control)
? form.controls[props.control]
: null;
const label = ref(
props.label != undefined
? props.label
: typeof props.control == "string"
? toTitleCase(props.control)
: "",
);
const value = ref(
props.modelValue != undefined
? props.modelValue
: control != null
? control.value
: "",
);
const id = ref(
props.id != undefined
? props.id
: typeof props.control == "string" && props.control.length > 0
? props.control
: generateRandomElementId(),
);
const active = ref(value.value?.toString().length ?? 0 > 0);
const focused = ref(false);
const disabled = ref(props.disabled);
watch(
() => value.value,
(newValue) => {
active.value = newValue.toString().length > 0 || focused.value == true;
},
);
if (props.modelValue != undefined) {
watch(
() => props.modelValue,
(newValue) => {
value.value = newValue;
},
);
}
watch(
() => props.disabled,
(newValue) => {
disabled.value = newValue;
},
);
if (typeof control === "object" && control !== null) {
watch(
() => control.value,
(newValue) => {
value.value = newValue;
},
{ deep: true },
);
}
const handleFocus = () => {
active.value = true;
focused.value = true;
};
const handleBlur = async () => {
active.value = value.value?.length ?? 0 > 0;
focused.value = false;
emits("blur");
if (control) {
await control.validate();
control.isValid();
}
};
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement;
value.value = target.value;
emits("update:modelValue", target.value);
if (control) {
control.value = target.value;
}
};
</script>
<style lang="scss">
.sm-dropdown {
select {
// appearance: none;
// width: 100%;
// padding: 20px 16px 8px 14px;
// border: 1px solid var(--base-color-darker);
// border-radius: 8px;
// background-color: var(--base-color-light);
// height: 52px;
// color: var(--base-color-text);
}
}
// label {
// --un-translate-y: 0.85rem;
// }
// .input-active label {
// transform: translate(16px, 6px) scale(0.7);
// }
</style>

View File

@@ -1,936 +0,0 @@
<template>
<div class="sm-html">
<bubble-menu
:editor="editor"
:should-show="bubbleMenuShow"
:tippy-options="{ hideOnClick: false }"
v-if="editor">
<button @click.prevent="setImageSize('small')">small</button>
<button @click.prevent="setImageSize('medium')">medium</button>
<button @click.prevent="setImageSize('large')">large</button>
<button @click.prevent="setImageSize('scaled')">original</button>
<button @click.prevent="editor.commands.deleteSelection()">
remove
</button>
</bubble-menu>
<div
v-if="editor"
class="flex flex-wrap bg-white border border-gray rounded-t-2">
<div class="flex px-1 relative border-r">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="absolute right-1 top-1.5 h-6 pointer-events-none">
<path
d="M480-360 280-559h400L480-360Z"
fill="currentColor" />
</svg>
<select
class="appearance-none pl-3 pr-7 text-xs outline-none select-none bg-white"
@change="updateNode">
<option
value="paragraph"
:selected="editor.isActive('paragraph')">
Paragraph
</option>
<option value="small" :selected="editor.isActive('small')">
Small
</option>
<option
value="h1"
:selected="editor.isActive('heading', { level: 1 })">
Heading 1
</option>
<option
value="h2"
:selected="editor.isActive('heading', { level: 2 })">
Heading 2
</option>
<option
value="h3"
:selected="editor.isActive('heading', { level: 3 })">
Heading 3
</option>
<option
value="h4"
:selected="editor.isActive('heading', { level: 4 })">
Heading 4
</option>
<option
value="h5"
:selected="editor.isActive('heading', { level: 5 })">
Heading 5
</option>
<option
value="h6"
:selected="editor.isActive('heading', { level: 6 })">
Heading 6
</option>
<option value="info" :selected="editor.isActive('info')">
Info
</option>
<option
value="success"
:selected="editor.isActive('success')">
Success
</option>
<option
value="warning"
:selected="editor.isActive('warning')">
Warning
</option>
<option
value="danger"
:selected="editor.isActive('danger')">
Danger
</option>
</select>
</div>
<div class="flex p-1 border-r">
<button
@click.prevent="editor.chain().focus().toggleBold().run()"
:disabled="!editor.can().chain().focus().toggleBold().run()"
title="bold"
:class="[
'flex',
'flex-items-center',
'p-1',
'hover-bg-gray-3',
editor.isActive('bold')
? ['bg-sky-6', 'text-white']
: ['bg-white', 'text-gray-6'],
]">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 24 24">
<path
d="M13.5,15.5H10V12.5H13.5A1.5,1.5 0 0,1 15,14A1.5,1.5 0 0,1 13.5,15.5M10,6.5H13A1.5,1.5 0 0,1 14.5,8A1.5,1.5 0 0,1 13,9.5H10M15.6,10.79C16.57,10.11 17.25,9 17.25,8C17.25,5.74 15.5,4 13.25,4H7V18H14.04C16.14,18 17.75,16.3 17.75,14.21C17.75,12.69 16.89,11.39 15.6,10.79Z"
fill="currentColor" />
</svg>
</button>
<button
@click.prevent="editor.chain().focus().toggleItalic().run()"
:disabled="
!editor.can().chain().focus().toggleItalic().run()
"
title="italic"
:class="[
'flex',
'flex-items-center',
'p-1',
'hover-bg-gray-3',
editor.isActive('italic')
? ['bg-sky-6', 'text-white']
: ['bg-white', 'text-gray-6'],
]">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 24 24">
<path
d="M10,4V7H12.21L8.79,15H6V18H14V15H11.79L15.21,7H18V4H10Z"
fill="currentColor" />
</svg>
</button>
<button
@click.prevent="
editor.chain().focus().toggleUnderline().run()
"
:disabled="
!editor.can().chain().focus().toggleUnderline().run()
"
title="underline"
:class="[
'flex',
'flex-items-center',
'p-1',
'hover-bg-gray-3',
editor.isActive('underline')
? ['bg-sky-6', 'text-white']
: ['bg-white', 'text-gray-6'],
]">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-5"
viewBox="0 0 24 24">
<path
d="M5,21H19V19H5V21M12,17A6,6 0 0,0 18,11V3H15.5V11A3.5,3.5 0 0,1 12,14.5A3.5,3.5 0 0,1 8.5,11V3H6V11A6,6 0 0,0 12,17Z"
fill="currentColor" />
</svg>
</button>
<button
@click.prevent="editor.chain().focus().toggleStrike().run()"
:disabled="
!editor.can().chain().focus().toggleStrike().run()
"
title="strike"
:class="[
'flex',
'flex-items-center',
'p-1',
'hover-bg-gray-3',
editor.isActive('strike')
? ['bg-sky-6', 'text-white']
: ['bg-white', 'text-gray-6'],
]">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-5"
viewBox="0 0 24 24">
<path
d="M23,12V14H18.61C19.61,16.14 19.56,22 12.38,22C4.05,22.05 4.37,15.5 4.37,15.5L8.34,15.55C8.37,18.92 11.5,18.92 12.12,18.88C12.76,18.83 15.15,18.84 15.34,16.5C15.42,15.41 14.32,14.58 13.12,14H1V12H23M19.41,7.89L15.43,7.86C15.43,7.86 15.6,5.09 12.15,5.08C8.7,5.06 9,7.28 9,7.56C9.04,7.84 9.34,9.22 12,9.88H5.71C5.71,9.88 2.22,3.15 10.74,2C19.45,0.8 19.43,7.91 19.41,7.89Z"
fill="currentColor" />
</svg>
</button>
<button
@click.prevent="
editor.chain().focus().toggleHighlight().run()
"
:disabled="
!editor.can().chain().focus().toggleHighlight().run()
"
title="highlight"
:class="[
'flex',
'flex-items-center',
'p-1',
'hover-bg-gray-3',
editor.isActive('highlight')
? ['bg-sky-6', 'text-white']
: ['bg-white', 'text-gray-6'],
]">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-5"
viewBox="0 0 24 24">
<path
d="M18.5,1.15C17.97,1.15 17.46,1.34 17.07,1.73L11.26,7.55L16.91,13.2L22.73,7.39C23.5,6.61 23.5,5.35 22.73,4.56L19.89,1.73C19.5,1.34 19,1.15 18.5,1.15M10.3,8.5L4.34,14.46C3.56,15.24 3.56,16.5 4.36,17.31C3.14,18.54 1.9,19.77 0.67,21H6.33L7.19,20.14C7.97,20.9 9.22,20.89 10,20.12L15.95,14.16"
fill="currentColor" />
</svg>
</button>
</div>
<div class="flex p-1 border-r">
<button
@click.prevent="
editor.chain().focus().setTextAlign('left').run()
"
title="align left"
:class="[
'flex',
'flex-items-center',
'p-1',
'hover-bg-gray-3',
editor.isActive({ textAlign: 'left' })
? ['bg-sky-6', 'text-white']
: ['bg-white', 'text-gray-6'],
]">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-5"
viewBox="0 0 24 24">
<path
d="M3,3H21V5H3V3M3,7H15V9H3V7M3,11H21V13H3V11M3,15H15V17H3V15M3,19H21V21H3V19Z"
fill="currentColor" />
</svg>
</button>
<button
@click.prevent="
editor.chain().focus().setTextAlign('center').run()
"
title="align center"
:class="[
'flex',
'flex-items-center',
'p-1',
'hover-bg-gray-3',
editor.isActive({ textAlign: 'center' })
? ['bg-sky-6', 'text-white']
: ['bg-white', 'text-gray-6'],
]">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-5"
viewBox="0 0 24 24">
<path
d="M3,3H21V5H3V3M7,7H17V9H7V7M3,11H21V13H3V11M7,15H17V17H7V15M3,19H21V21H3V19Z"
fill="currentColor" />
</svg>
</button>
<button
@click.prevent="
editor.chain().focus().setTextAlign('right').run()
"
title="align right"
:class="[
'flex',
'flex-items-center',
'p-1',
'hover-bg-gray-3',
editor.isActive({ textAlign: 'right' })
? ['bg-sky-6', 'text-white']
: ['bg-white', 'text-gray-6'],
]">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-5"
viewBox="0 0 24 24">
<path
d="M3,3H21V5H3V3M9,7H21V9H9V7M3,11H21V13H3V11M9,15H21V17H9V15M3,19H21V21H3V19Z"
fill="currentColor" />
</svg>
</button>
<button
@click.prevent="
editor.chain().focus().setTextAlign('justify').run()
"
title="align right"
:class="[
'flex',
'flex-items-center',
'p-1',
'hover-bg-gray-3',
editor.isActive({ textAlign: 'justify' })
? ['bg-sky-6', 'text-white']
: ['bg-white', 'text-gray-6'],
]">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-5"
viewBox="0 0 24 24">
<path
d="M3,3H21V5H3V3M3,7H21V9H3V7M3,11H21V13H3V11M3,15H21V17H3V15M3,19H21V21H3V19Z"
fill="currentColor" />
</svg>
</button>
</div>
<div class="flex p-1 border-r">
<button
@click.prevent="setLink()"
title="link"
:class="[
'flex',
'flex-items-center',
'p-1',
'hover-bg-gray-3',
editor.isActive('link')
? ['bg-sky-6', 'text-white']
: ['bg-white', 'text-gray-6'],
]">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-5"
viewBox="0 0 24 24">
<path
d="M3.9,12C3.9,10.29 5.29,8.9 7,8.9H11V7H7A5,5 0 0,0 2,12A5,5 0 0,0 7,17H11V15.1H7C5.29,15.1 3.9,13.71 3.9,12M8,13H16V11H8V13M17,7H13V8.9H17C18.71,8.9 20.1,10.29 20.1,12C20.1,13.71 18.71,15.1 17,15.1H13V17H17A5,5 0 0,0 22,12A5,5 0 0,0 17,7Z"
fill="currentColor" />
</svg>
</button>
<button
@click.prevent="editor.chain().focus().unsetLink().run()"
title="unlink"
:class="[
'flex',
'flex-items-center',
'p-1',
'hover-bg-gray-3',
'bg-white',
'text-gray-6',
]">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-5"
viewBox="0 0 24 24">
<path
d="M17,7H13V8.9H17C18.71,8.9 20.1,10.29 20.1,12C20.1,13.43 19.12,14.63 17.79,15L19.25,16.44C20.88,15.61 22,13.95 22,12A5,5 0 0,0 17,7M16,11H13.81L15.81,13H16V11M2,4.27L5.11,7.38C3.29,8.12 2,9.91 2,12A5,5 0 0,0 7,17H11V15.1H7C5.29,15.1 3.9,13.71 3.9,12C3.9,10.41 5.11,9.1 6.66,8.93L8.73,11H8V13H10.73L13,15.27V17H14.73L18.74,21L20,19.74L3.27,3L2,4.27Z"
fill="currentColor" />
</svg>
</button>
</div>
<div class="flex p-1 border-r">
<button
@click.prevent="setImage()"
title="image"
:class="[
'flex',
'flex-items-center',
'p-1',
'hover-bg-gray-3',
editor.isActive('image')
? ['bg-sky-6', 'text-white']
: ['bg-white', 'text-gray-6'],
]">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-5"
viewBox="0 0 24 24">
<path
d="M19,19H5V5H19M19,3H5A2,2 0 0,0 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5A2,2 0 0,0 19,3M13.96,12.29L11.21,15.83L9.25,13.47L6.5,17H17.5L13.96,12.29Z"
fill="currentColor" />
</svg>
</button>
</div>
<div class="flex p-1 border-r">
<button
@click.prevent="
editor.chain().focus().toggleBulletList().run()
"
title="bullet list"
:class="[
'flex',
'flex-items-center',
'p-1',
'hover-bg-gray-3',
editor.isActive('bulletList')
? ['bg-sky-6', 'text-white']
: ['bg-white', 'text-gray-6'],
]">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-5"
viewBox="0 0 24 24">
<path
d="M7,5H21V7H7V5M7,13V11H21V13H7M4,4.5A1.5,1.5 0 0,1 5.5,6A1.5,1.5 0 0,1 4,7.5A1.5,1.5 0 0,1 2.5,6A1.5,1.5 0 0,1 4,4.5M4,10.5A1.5,1.5 0 0,1 5.5,12A1.5,1.5 0 0,1 4,13.5A1.5,1.5 0 0,1 2.5,12A1.5,1.5 0 0,1 4,10.5M7,19V17H21V19H7M4,16.5A1.5,1.5 0 0,1 5.5,18A1.5,1.5 0 0,1 4,19.5A1.5,1.5 0 0,1 2.5,18A1.5,1.5 0 0,1 4,16.5Z"
fill="currentColor" />
</svg>
</button>
<button
@click.prevent="
editor.chain().focus().toggleOrderedList().run()
"
title="ordered list"
:class="[
'flex',
'flex-items-center',
'p-1',
'hover-bg-gray-3',
editor.isActive('orderedList')
? ['bg-sky-6', 'text-white']
: ['bg-white', 'text-gray-6'],
]">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-5"
viewBox="0 0 24 24">
<path
d="M7,13V11H21V13H7M7,19V17H21V19H7M7,7V5H21V7H7M3,8V5H2V4H4V8H3M2,17V16H5V20H2V19H4V18.5H3V17.5H4V17H2M4.25,10A0.75,0.75 0 0,1 5,10.75C5,10.95 4.92,11.14 4.79,11.27L3.12,13H5V14H2V13.08L4,11H2V10H4.25Z"
fill="currentColor" />
</svg>
</button>
</div>
<div class="flex p-1 border-r">
<button
@click.prevent="
editor.chain().focus().toggleCodeBlock().run()
"
title="code block"
:class="[
'flex',
'flex-items-center',
'p-1',
'hover-bg-gray-3',
editor.isActive('codeBlock')
? ['bg-sky-6', 'text-white']
: ['bg-white', 'text-gray-6'],
]">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 24 24">
<path
d="M14.6,16.6L19.2,12L14.6,7.4L16,6L22,12L16,18L14.6,16.6M9.4,16.6L4.8,12L9.4,7.4L8,6L2,12L8,18L9.4,16.6Z"
fill="currentColor" />
</svg>
</button>
<button
@click.prevent="
editor.chain().focus().toggleBlockquote().run()
"
title="blockquote"
:class="[
'flex',
'flex-items-center',
'p-1',
'hover-bg-gray-3',
editor.isActive('blockquote')
? ['bg-sky-6', 'text-white']
: ['bg-white', 'text-gray-6'],
]">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 24 24">
<path
d="M10,7L8,11H11V17H5V11L7,7H10M18,7L16,11H19V17H13V11L15,7H18Z"
fill="currentColor" />
</svg>
</button>
<button
@click.prevent="
editor.chain().focus().setHorizontalRule().run()
"
title="horizontal rule"
:class="[
'flex',
'flex-items-center',
'p-1',
'bg-white',
'hover-bg-gray-3',
]">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 24 24">
<path d="M19,13H5V11H19V13Z" fill="currentColor" />
</svg>
</button>
</div>
<div class="flex p-1 border-r">
<button
@click.prevent="
editor.chain().focus().unsetSuperscript().run();
editor.chain().focus().toggleSubscript().run();
"
title="subscript"
:class="[
'flex',
'flex-items-center',
'p-1',
'hover-bg-gray-3',
editor.isActive('subscript')
? ['bg-sky-6', 'text-white']
: ['bg-white', 'text-gray-6'],
]">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 24 24">
<path
d="M16,7.41L11.41,12L16,16.59L14.59,18L10,13.41L5.41,18L4,16.59L8.59,12L4,7.41L5.41,6L10,10.59L14.59,6L16,7.41M21.85,21.03H16.97V20.03L17.86,19.23C18.62,18.58 19.18,18.04 19.56,17.6C19.93,17.16 20.12,16.75 20.13,16.36C20.14,16.08 20.05,15.85 19.86,15.66C19.68,15.5 19.39,15.38 19,15.38C18.69,15.38 18.42,15.44 18.16,15.56L17.5,15.94L17.05,14.77C17.32,14.56 17.64,14.38 18.03,14.24C18.42,14.1 18.85,14 19.32,14C20.1,14.04 20.7,14.25 21.1,14.66C21.5,15.07 21.72,15.59 21.72,16.23C21.71,16.79 21.53,17.31 21.18,17.78C20.84,18.25 20.42,18.7 19.91,19.14L19.27,19.66V19.68H21.85V21.03Z"
fill="currentColor" />
</svg>
</button>
<button
@click.prevent="
editor.chain().focus().unsetSubscript().run();
editor.chain().focus().toggleSuperscript().run();
"
title="Superscript"
:class="[
'flex',
'flex-items-center',
'p-1',
'hover-bg-gray-3',
editor.isActive('superscript')
? ['bg-sky-6', 'text-white']
: ['bg-white', 'text-gray-6'],
]">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 24 24">
<path
d="M16,7.41L11.41,12L16,16.59L14.59,18L10,13.41L5.41,18L4,16.59L8.59,12L4,7.41L5.41,6L10,10.59L14.59,6L16,7.41M21.85,9H16.97V8L17.86,7.18C18.62,6.54 19.18,6 19.56,5.55C19.93,5.11 20.12,4.7 20.13,4.32C20.14,4.04 20.05,3.8 19.86,3.62C19.68,3.43 19.39,3.34 19,3.33C18.69,3.34 18.42,3.4 18.16,3.5L17.5,3.89L17.05,2.72C17.32,2.5 17.64,2.33 18.03,2.19C18.42,2.05 18.85,2 19.32,2C20.1,2 20.7,2.2 21.1,2.61C21.5,3 21.72,3.54 21.72,4.18C21.71,4.74 21.53,5.26 21.18,5.73C20.84,6.21 20.42,6.66 19.91,7.09L19.27,7.61V7.63H21.85V9Z"
fill="currentColor" />
</svg>
</button>
</div>
<div class="flex p-1 border-r">
<button
@click.prevent="editor.chain().focus().setHardBreak().run()"
title="hard break"
:class="[
'flex',
'flex-items-center',
'p-1',
'hover-bg-gray-3',
'bg-white',
]">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 24 24">
<path
d="M10,11A4,4 0 0,1 6,7A4,4 0 0,1 10,3H18V5H16V21H14V5H12V21H10V11Z"
fill="currentColor" />
</svg>
</button>
<button
@click.prevent="
editor.chain().focus().unsetAllMarks().run();
editor.chain().focus().clearNodes().run();
"
title="Clear formatting"
:class="[
'flex',
'flex-items-center',
'p-1',
'hover-bg-gray-3',
'bg-white',
]">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 24 24">
<path
d="M6,5V5.18L8.82,8H11.22L10.5,9.68L12.6,11.78L14.21,8H20V5H6M3.27,5L2,6.27L8.97,13.24L6.5,19H9.5L11.07,15.34L16.73,21L18,19.73L3.55,5.27L3.27,5Z"
fill="currentColor" />
</svg>
</button>
</div>
<div class="flex p-1 border-r">
<button
@click.prevent="editor.chain().focus().undo().run()"
title="Undo"
:disabled="!editor.can().chain().focus().undo().run()"
:class="[
'flex',
'flex-items-center',
'p-1',
'hover-bg-gray-3',
'bg-white',
[
'disabled-text-gray',
'hover-disabled-bg-transparent',
'disabled-cursor-not-allowed',
],
]">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 24 24">
<path
d="M12.5,8C9.85,8 7.45,9 5.6,10.6L2,7V16H11L7.38,12.38C8.77,11.22 10.54,10.5 12.5,10.5C16.04,10.5 19.05,12.81 20.1,16L22.47,15.22C21.08,11.03 17.15,8 12.5,8Z"
fill="currentColor" />
</svg>
</button>
<button
@click.prevent="editor.chain().focus().redo().run()"
title="Redo"
:disabled="!editor.can().chain().focus().redo().run()"
:class="[
'flex',
'flex-items-center',
'p-1',
'hover-bg-gray-3',
'bg-white',
[
'disabled-text-gray',
'hover-disabled-bg-transparent',
'disabled-cursor-not-allowed',
],
]">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 24 24">
<path
d="M18.4,10.6C16.55,9 14.15,8 11.5,8C6.85,8 2.92,11.03 1.54,15.22L3.9,16C4.95,12.81 7.95,10.5 11.5,10.5C13.45,10.5 15.23,11.22 16.62,12.38L13,16H22V7L18.4,10.6Z"
fill="currentColor" />
</svg>
</button>
</div>
</div>
<EditorContent
:editor="editor"
class="rounded-b-2 bg-white p-4 border-x border-b border-gray h-128 overflow-auto sm-editor" />
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, watch } from "vue";
import { useEditor, EditorContent, BubbleMenu, isActive } from "@tiptap/vue-3";
import StarterKit from "@tiptap/starter-kit";
import Underline from "@tiptap/extension-underline";
import TextAlign from "@tiptap/extension-text-align";
import Highlight from "@tiptap/extension-highlight";
import { Info } from "../extensions/info";
import { Success } from "../extensions/success";
import { Warning } from "../extensions/warning";
import { Danger } from "../extensions/danger";
import Subscript from "@tiptap/extension-subscript";
import Superscript from "@tiptap/extension-superscript";
import Link from "@tiptap/extension-link";
import Image from "@tiptap/extension-image";
import { Small } from "../extensions/small";
import { openDialog } from "./SMDialog";
import SMDialogMedia from "./dialogs/SMDialogMedia.vue";
import { Media, MediaCollection } from "../helpers/api.types";
import { api } from "../helpers/api";
import { extractFileNameFromUrl } from "../helpers/url";
import { mediaGetVariantUrl } from "../helpers/media";
const props = defineProps({
modelValue: {
type: String,
required: true,
},
});
const emits = defineEmits(["update:modelValue"]);
const editor = useEditor({
content: props.modelValue,
extensions: [
StarterKit,
Underline,
TextAlign.configure({
types: [
"heading",
"paragraph",
"info",
"success",
"warning",
"danger",
],
}),
Highlight,
Info,
Success,
Warning,
Danger,
Small,
Subscript,
Superscript,
Link.configure({
openOnClick: false,
}),
Image,
BubbleMenu,
],
onUpdate: () => {
emits("update:modelValue", editor.value.getHTML());
},
});
const bubbleMenuShow = ({ editor, view, state, oldState, from, to }) => {
return isActive(state, "image");
};
const updateNode = (event) => {
if (event.target.value) {
switch (event.target.value) {
case "paragraph":
editor.value.chain().focus().setParagraph().run();
break;
case "small":
editor.value.chain().focus().setSmall().run();
break;
case "h1":
editor.value.chain().focus().setHeading({ level: 1 }).run();
break;
case "h2":
editor.value.chain().focus().setHeading({ level: 2 }).run();
break;
case "h3":
editor.value.chain().focus().setHeading({ level: 3 }).run();
break;
case "h4":
editor.value.chain().focus().setHeading({ level: 4 }).run();
break;
case "h5":
editor.value.chain().focus().setHeading({ level: 5 }).run();
break;
case "h6":
editor.value.chain().focus().setHeading({ level: 6 }).run();
break;
case "info":
editor.value.chain().focus().toggleInfo().run();
break;
case "success":
editor.value.chain().focus().toggleSuccess().run();
break;
case "warning":
editor.value.chain().focus().toggleWarning().run();
break;
case "danger":
editor.value.chain().focus().toggleDanger().run();
break;
}
}
};
const setLink = () => {
const previousUrl = editor.value.getAttributes("link").href;
const url = window.prompt("URL", previousUrl);
// cancelled
if (url === null) {
return;
}
// empty
if (url === "") {
editor.value.chain().focus().extendMarkRange("link").unsetLink().run();
return;
}
// update link
editor.value
.chain()
.focus()
.extendMarkRange("link")
.setLink({ href: url })
.run();
};
const setImage = async () => {
let result = await openDialog(SMDialogMedia, {
allowUpload: true,
allowUrl: true,
});
if (result) {
const mediaResult = result as Media;
editor.value
.chain()
.focus()
.setImage({
src: mediaResult.url,
title: mediaResult.title,
alt: mediaResult.description,
})
.run();
}
};
onBeforeUnmount(() => {
editor.value.destroy();
});
watch(
() => props.modelValue,
(newValue) => {
const isSame = editor.value.getHTML() === newValue;
if (isSame) {
return;
}
editor.value.commands.setContent(newValue, false);
},
);
const getImageSize = async () => {
let size = "default";
if (!editor.value.view.state.selection.node) {
return "unknown";
}
const src = editor.value.view.state.selection.node.attrs.src;
const fileName = extractFileNameFromUrl(src);
let r = await api
.get({
url: "/media",
params: {
variants: extractFileNameFromUrl(src),
},
})
.then((result) => {
if (result.data) {
const data = result.data as MediaCollection;
if (data.media.length > 0 && data.media[0].variants) {
for (const [key, value] of Object.entries(
data.media[0].variants,
)) {
if (value === fileName) {
size = key;
console.log(size);
break;
}
}
}
}
console.log("final", size);
return size;
})
.catch((error) => {
console.log(error);
return "xx";
});
console.log(r);
return size;
};
const setImageSize = (size: string): void => {
const { selection } = editor.value.view.state;
const src = editor.value.view.state.selection.node.attrs.src;
api.get({
url: "/media",
params: {
variants: extractFileNameFromUrl(src),
},
})
.then((result) => {
console.log(result);
/*
large
medium
scaled
small,
thumb
xlarge
xxlarge
*/
const newSrc = mediaGetVariantUrl(result.data.media[0], size);
const transaction = editor.value.view.state.tr.setNodeMarkup(
selection.from,
undefined,
{ src: newSrc },
);
editor.value.view.dispatch(transaction);
})
.catch((error) => {
console.log(error);
});
};
</script>
<style lang="scss">
.tippy-content div {
display: flex !important;
justify-content: center;
align-items: center;
button {
color: rgba(255, 255, 255, 1);
appearance: none;
background-color: rgba(0, 0, 0, 1);
font-size: 0.8rem;
padding: 0.5rem 0.75rem;
&:hover {
background-color: rgba(60, 60, 60, 1);
}
&:first-child {
border-top-left-radius: 0.5rem;
border-bottom-left-radius: 0.5rem;
}
&:last-child {
border-top-right-radius: 0.5rem;
border-bottom-right-radius: 0.5rem;
}
}
}
// .tippy-arrow {
// height: 0.75rem;
// width: 0.75rem;
// z-index: -1;
// &::after {
// display: block;
// content: "";
// background-color: rgba(0, 0, 0, 1);
// height: 100%;
// width: 100%;
// transform: translateY(-50%) rotate(45deg);
// }
// }
</style>

View File

@@ -1,269 +0,0 @@
<template>
<router-link
rel="prefetch"
:to="{ name: 'event', params: { id: props.event.id } }"
class="event-card bg-white border-1 border-rounded-xl text-black decoration-none hover:shadow-md transition min-w-72">
<div
class="h-48 bg-cover bg-center rounded-t-xl relative"
:style="{
backgroundImage: `url('${mediaGetVariantUrl(
props.event.hero,
'medium',
)}')`,
}">
<div
:class="[
'absolute',
'top-2',
'right-2',
'text-xs',
'font-bold',
'uppercase',
'px-4',
'py-1',
computedBanner(props.event)['bg-class'],
computedBanner(props.event)['font-class'],
]">
{{ computedBanner(props.event)["banner"] }}
</div>
<div
class="flex flex-col items-center justify-center bg-white border-1 border-rounded absolute top-2 left-2 w-12 h-12 text-gray-6">
<div class="font-bold line-height-none">
{{ formatDateDay(props.event.start_at) }}
</div>
<div class="text-xs uppercase line-height-none">
{{ formatDateMonth(props.event.start_at) }}
</div>
</div>
</div>
<div class="p-4">
<h3 class="mb-3 font-500">{{ props.event.title }}</h3>
<div class="flex items-center mb-2 text-gray-5">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 pr-2"
viewBox="0 -960 960 960">
<path
d="M180-80q-24 0-42-18t-18-42v-620q0-24 18-42t42-18h65v-60h65v60h340v-60h65v60h65q24 0 42 18t18 42v620q0 24-18 42t-42 18H180Zm0-60h600v-430H180v430Zm0-490h600v-130H180v130Zm0 0v-130 130Zm300 230q-17 0-28.5-11.5T440-440q0-17 11.5-28.5T480-480q17 0 28.5 11.5T520-440q0 17-11.5 28.5T480-400Zm-160 0q-17 0-28.5-11.5T280-440q0-17 11.5-28.5T320-480q17 0 28.5 11.5T360-440q0 17-11.5 28.5T320-400Zm320 0q-17 0-28.5-11.5T600-440q0-17 11.5-28.5T640-480q17 0 28.5 11.5T680-440q0 17-11.5 28.5T640-400ZM480-240q-17 0-28.5-11.5T440-280q0-17 11.5-28.5T480-320q17 0 28.5 11.5T520-280q0 17-11.5 28.5T480-240Zm-160 0q-17 0-28.5-11.5T280-280q0-17 11.5-28.5T320-320q17 0 28.5 11.5T360-280q0 17-11.5 28.5T320-240Zm320 0q-17 0-28.5-11.5T600-280q0-17 11.5-28.5T640-320q17 0 28.5 11.5T680-280q0 17-11.5 28.5T640-240Z"
fill="currentColor" />
</svg>
<span class="text-sm">{{ computedDate(props.event) }}</span>
</div>
<div class="flex items-center mb-2 text-gray-5">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 pr-2"
viewBox="0 -960 960 960">
<path
d="M480.089-490Q509-490 529.5-510.589q20.5-20.588 20.5-49.5Q550-589 529.411-609.5q-20.588-20.5-49.5-20.5Q451-630 430.5-609.411q-20.5 20.588-20.5 49.5Q410-531 430.589-510.5q20.588 20.5 49.5 20.5ZM480-159q133-121 196.5-219.5T740-552q0-117.79-75.292-192.895Q589.417-820 480-820t-184.708 75.105Q220-669.79 220-552q0 75 65 173.5T480-159Zm0 79Q319-217 239.5-334.5T160-552q0-150 96.5-239T480-880q127 0 223.5 89T800-552q0 100-79.5 217.5T480-80Zm0-472Z"
fill="currentColor" />
</svg>
<span class="text-sm">
{{ computedLocation(props.event) }}
</span>
</div>
<div class="flex items-center mb-2 text-gray-5">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 pr-2"
viewBox="0 -960 960 960">
<path
d="M626-533q22.5 0 38.25-15.75T680-587q0-22.5-15.75-38.25T626-641q-22.5 0-38.25 15.75T572-587q0 22.5 15.75 38.25T626-533Zm-292 0q22.5 0 38.25-15.75T388-587q0-22.5-15.75-38.25T334-641q-22.5 0-38.25 15.75T280-587q0 22.5 15.75 38.25T334-533Zm146 272q66 0 121.5-35.5T682-393H278q26 61 81 96.5T480-261Zm0 181q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-400Zm0 340q142.375 0 241.188-98.812Q820-337.625 820-480t-98.812-241.188Q622.375-820 480-820t-241.188 98.812Q140-622.375 140-480t98.812 241.188Q337.625-140 480-140Z"
fill="currentColor" />
</svg>
<span class="text-sm">
{{ computedAges(props.event.ages) }}
</span>
</div>
<div class="flex items-center text-gray-5">
<span class="block text-center w-4 mr-2">$</span>
<span class="text-sm">
{{ computedPrice(props.event.price) }}
</span>
</div>
</div>
</router-link>
</template>
<script setup lang="ts">
import { Event } from "../helpers/api.types";
import { SMDate } from "../helpers/datetime";
import { mediaGetVariantUrl } from "../helpers/media";
const props = defineProps({
event: {
type: Object as () => Event,
required: true,
},
});
/**
* Return a human readable Date string.
* @param {Event} event The event to convert.
* @returns The converted string.
*/
const computedDate = (event: Event) => {
let str = "";
const start_at =
event.start_at.length > 0
? new SMDate(event.start_at, {
format: "yMd",
utc: true,
}).format("dd/MM/yyyy @ h:mm aa")
: "";
const end_at =
event.end_at.length > 0
? new SMDate(event.end_at, {
format: "yMd",
utc: true,
}).format("dd/MM/yyyy")
: "";
if (start_at.length > 0) {
if (
end_at.length > 0 &&
start_at.substring(0, start_at.indexOf(" ")) != end_at
) {
str = start_at.substring(0, start_at.indexOf(" ")) + " - " + end_at;
} else {
str = start_at;
}
}
return str;
};
/**
* Return a the event starting month day number.
* @param {string} date The date to format.
* @returns The converted string.
*/
const formatDateDay = (date: string) => {
return new SMDate(date, { format: "yMd", utc: true }).format("dd");
};
/**
* Return a the event starting month name.
* @param {string} date The date to format.
* @returns The converted string.
*/
const formatDateMonth = (date: string) => {
return new SMDate(date, { format: "yMd", utc: true }).format("MMM");
};
/**
* Return a human readable Location string.
* @param {Event} event The event to convert.
* @returns The converted string.
*/
const computedLocation = (event: Event): string => {
if (event.location == "online") {
return "Online";
}
return event.address;
};
/**
* Return a human readable Ages string.
* @param {string} ages The string to convert.
* @returns The converted string.
*/
const computedAges = (ages: string): string => {
const trimmed = ages.trim();
const regex = /^(\d+)(\s*\+?\s*|\s*-\s*\d+\s*)?$/;
if (trimmed.length === 0) {
return "All ages";
}
if (regex.test(trimmed)) {
return `Ages ${trimmed}`;
}
return ages;
};
/**
* Return a human readable Price string.
* @param {string} price The string to convert.
* @returns The converted string.
*/
const computedPrice = (price: string): string => {
if (price.toLowerCase() === "tbd" || price.toLowerCase() === "tbc") {
return price.toUpperCase();
}
const trimmed = parseInt(price.trim());
if (isNaN(trimmed) || trimmed == 0) {
return "Free";
}
return trimmed.toString();
};
type EventBanner = {
banner: string;
"bg-class": string;
"font-class": string;
};
const computedBanner = (event: Event): EventBanner => {
const parsedEndAt = new SMDate(event.end_at, {
format: "yyyy-MM-dd HH:mm:ss",
utc: true,
});
if (
(parsedEndAt.isBefore(new SMDate("now")) &&
(event.status == "open" ||
event.status == "soon" ||
event.status == "full")) ||
event.status == "closed"
) {
return {
banner: "closed",
"bg-class": "bg-purple-800",
"font-class": "text-white",
};
} else if (event.status == "full") {
return {
banner: "full",
"bg-class": "bg-purple-800",
"font-class": "text-white",
};
} else if (event.status == "open") {
return {
banner: "open",
"bg-class": "bg-green-700",
"font-class": "text-white",
};
} else if (event.status == "cancelled") {
return {
banner: "cancelled",
"bg-class": "bg-red-700",
"font-class": "text-white",
};
} else if (event.status == "draft") {
return {
banner: "draft",
"bg-class": "bg-purple-800",
"font-class": "text-white",
};
}
return {
banner: "Open Soon",
"bg-class": "bg-yellow-400",
"font-class": "text-black",
};
};
</script>
<style lang="scss">
.event-card {
color: inherit !important;
}
</style>

View File

@@ -1,42 +0,0 @@
<template>
<a :href="computedUrl" :target="props.target" rel="noopener"
><slot></slot
></a>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useUserStore } from "../store/UserStore";
const props = defineProps({
href: {
type: String,
required: true,
},
target: {
type: String,
default: "_self",
},
});
const userStore = useUserStore();
/**
* 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);
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;
});
</script>

View File

@@ -1,79 +0,0 @@
<template>
<form :id="id" @submit.prevent="submit">
<slot></slot>
</form>
</template>
<script setup lang="ts">
import { provide, watch } from "vue";
import { generateRandomElementId } from "../helpers/utils";
const props = defineProps({
modelValue: {
type: Object,
required: true,
},
formId: {
type: String,
default: "form",
required: false,
},
});
const emits = defineEmits(["submit", "failedValidation"]);
const id = generateRandomElementId();
let inputs = [];
watch(
() => props.modelValue.loading(),
(status) => {
if (!status) {
enableFormInputs();
}
},
);
/**
* Handle the user submitting the form.
*/
const submit = async function () {
try {
inputs = Array.from(document.querySelectorAll(`#${id} input`));
for (let i = inputs.length - 1; i >= 0; i--) {
const input = inputs[i] as HTMLInputElement;
if (!input.disabled) {
input.disabled = true;
} else {
inputs.splice(i, 1);
}
}
} catch {
/* empty */
}
if (await props.modelValue.validate()) {
emits("submit", () => {
enableFormInputs();
});
} else {
emits("failedValidation");
enableFormInputs();
}
};
/**
* Reenable form inputs
*/
const enableFormInputs = () => {
for (const input of inputs) {
const typedInput = input as HTMLInputElement;
typedInput.disabled = false;
}
inputs = [];
};
provide(props.formId, props.modelValue);
defineExpose({ submit });
</script>

View File

@@ -1,38 +0,0 @@
<template>
<div class="form-error" v-if="props.modelValue._message.length > 0">
<ion-icon class="invalid-icon" name="alert-circle-outline"></ion-icon>
<p>{{ props.modelValue._message }}</p>
</div>
</template>
<script setup lang="ts">
const props = defineProps({
modelValue: {
type: Object,
required: true,
},
});
</script>
<style lang="scss">
.form-error {
display: flex;
color: var(--danger-color);
background-color: var(--danger-color-lighter);
padding: 6px 12px;
margin-bottom: 24px;
align-items: center;
ion-icon {
font-size: 1.5rem;
}
p {
display: inline-block;
margin: 0;
text-align: center;
flex-grow: 1;
white-space: pre;
}
}
</style>

View File

@@ -1,72 +0,0 @@
<template>
<component :is="computedContent"></component>
</template>
<script setup lang="ts">
import DOMPurify from "dompurify";
import { computed } from "vue";
import { ImportMetaExtras } from "../../../import-meta";
// import SMImageGallery from "./SMImageGallery.vue";
const props = defineProps({
html: {
type: String,
default: "",
required: true,
},
});
/**
* Return the html as a component, relative links as router-link and sanitized.
*/
const computedContent = computed(() => {
let html = "";
// Sanitize HTML
html = DOMPurify.sanitize(props.html);
// Convert nl to <br>
html = html.replaceAll("\n", "<br />");
// Convert local links to router-links
const regexHref = new RegExp(
`<a ([^>]*?)href="${
(import.meta as ImportMetaExtras).env.APP_URL
}(.*?>.*?)</a>`,
"ig",
);
html = html.replace(regexHref, '<router-link $1to="$2</router-link>');
// Convert image galleries to SMImageGallery component
// const regexGallery =
// /<div.*?class="tinymce-gallery".*?>\s*((?:<div class="tinymce-gallery-item" style="background-image: url\('.*?'\);">.*?<\/div>\s*)*)<\/div>/gi;
// const matches = [...html.matchAll(regexGallery)];
// for (const match of matches) {
// const images = match[1]; // Extract the captured group from the match
// const imageSrcs = images
// .match(/style="background-image: url\('(.*?)'\)/gi)
// .map((m) => m.match(/background-image: url\('(.*?)'\)/i)[1]);
// const smImageGallery = `<SMImageGallery :images='${JSON.stringify(
// imageSrcs
// )}' />`;
// html = html.replace(images, smImageGallery);
// }
// Update local images to use at most the large size
const regexImg = new RegExp(
`<img ([^>]*?)src="${
(import.meta as ImportMetaExtras).env.APP_URL
}/uploads/([^"]*?)"`,
"ig",
);
html = html.replace(
regexImg,
`<img $1src="${
(import.meta as ImportMetaExtras).env.APP_URL
}/uploads/$2?size=large"`,
);
return { template: `<div class="sm-html">${html}</div>` };
});
</script>

View File

@@ -1,78 +0,0 @@
<template>
<component
:is="`h${props.size}`"
:id="id"
:class="['sm-header', props.noCopy ? '' : 'cursor-pointer']"
@click.prevent="copyAnchor">
{{ props.text }}
<span v-if="!props.noCopy" class="pl-2 text-sky-5 opacity-75 hidden"
>#</span
>
</component>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { useToastStore } from "../store/ToastStore";
const props = defineProps({
size: {
type: Number,
default: 3,
required: false,
},
text: {
type: String,
required: true,
},
id: {
type: String,
default: "",
required: false,
},
noCopy: {
type: Boolean,
default: false,
required: false,
},
});
const computedHeaderId = (text: string): string => {
return text.replace(/[^a-zA-Z0-9]+/g, "-").toLowerCase();
};
const id = ref(
props.id && props.id.length > 0 ? props.id : computedHeaderId(props.text),
);
const copyAnchor = () => {
if (props.noCopy === false) {
const currentUrl = window.location.href.replace(/#.*/, "");
const newUrl = currentUrl + "#" + id.value;
navigator.clipboard
.writeText(newUrl)
.then(() => {
useToastStore().addToast({
title: "Copied to Clipboard",
content:
"The heading URL has been copied to the clipboard.",
type: "success",
});
})
.catch(() => {
useToastStore().addToast({
title: "Copy to Clipboard",
content: "Failed to copy the heading URL to the clipboard.",
type: "danger",
});
});
}
};
</script>
<style lang="scss">
.sm-header:hover span {
display: inline-block;
}
</style>

View File

@@ -1,64 +0,0 @@
<template>
<div class="image">
<SMLoading
v-if="props.src != '' && imgLoaded == false && imgError == false" />
<img
v-if="props.src != '' && imgError == false"
:src="src"
@load="imgLoaded = true"
@error="imgError = true" />
<div v-if="imgError == true" class="image-error">
<ion-icon name="alert-circle-outline"></ion-icon>
<p>Error loading image</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import SMLoading from "./SMLoading.vue";
const props = defineProps({
src: {
type: String,
required: true,
},
});
const imgLoaded = ref(false);
const imgError = ref(false);
</script>
<style lang="scss">
.image {
display: flex;
flex-basis: 300px;
/* Firefox */
justify-content: center;
max-height: 300px;
img {
max-height: 100%;
max-width: 100%;
object-fit: contain;
border-radius: 8px;
}
.image-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 0 auto;
ion-icon {
font-size: 300%;
}
p {
margin: 0;
}
}
}
</style>

View File

@@ -1,364 +0,0 @@
<template>
<div>
<div
:class="[
'flex',
'gap-4',
'my-4',
'select-none',
props.showEditor
? ['overflow-auto']
: ['flex-wrap', 'flex-justify-center'],
]">
<div
v-for="(image, index) in modelValue"
class="flex flex-col flex-justify-center relative sm-gallery-item p-1"
:key="index">
<img
:src="mediaGetThumbnail(image as Media, 'small')"
class="max-h-40 max-w-40 cursor-pointer"
@click="showGalleryModal(index)" />
<div
v-if="props.showEditor"
class="absolute rounded-5 bg-white -top-0.25 -right-0.25 hidden cursor-pointer item-delete"
@click="handleRemoveItem((image as Media).id)">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 block"
viewBox="0 0 24 24">
<path
d="M12,2C17.53,2 22,6.47 22,12C22,17.53 17.53,22 12,22C6.47,22 2,17.53 2,12C2,6.47 6.47,2 12,2M15.59,7L12,10.59L8.41,7L7,8.41L10.59,12L7,15.59L8.41,17L12,13.41L15.59,17L17,15.59L13.41,12L17,8.41L15.59,7Z"
fill="rgba(185,28,28,1)" />
</svg>
</div>
</div>
<div
v-if="props.showEditor"
class="flex flex-col flex-justify-center">
<div
class="flex flex-col flex-justify-center flex-items-center h-23 w-40 cursor-pointer bg-gray-300 text-gray-800 hover:text-gray-600"
@click="handleAddToGallery">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-15 w-15"
viewBox="0 0 24 24">
<title>Add image</title>
<path
d="M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M13,7H11V11H7V13H11V17H13V13H17V11H13V7Z"
fill="currentColor" />
</svg>
</div>
</div>
</div>
<div
v-if="props.showEditor == false && showModalImage !== null"
:class="[
'image-gallery-modal',
{ 'image-gallery-modal-buttons': showButtons },
]"
@click="hideModal"
@mousemove="handleModalUpdateButtons"
@mouseleave="handleModalUpdateButtons">
<img
:src="mediaGetVariantUrl(modelValue[showModalImage] as Media)"
class="image-gallery-modal-image" />
<div
class="image-gallery-modal-prev"
@click.stop="handleModalPrevImage"></div>
<div
class="image-gallery-modal-next"
@click.stop="handleModalNextImage"></div>
<div class="image-gallery-modal-close" @click="hideModal">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
viewBox="0 0 24 24">
<title>Close</title>
<path
d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"
fill="currentColor" />
</svg>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from "vue";
import { Media } from "../helpers/api.types";
import { mediaGetThumbnail, mediaGetVariantUrl } from "../helpers/media";
import { openDialog } from "./SMDialog";
import SMDialogMedia from "./dialogs/SMDialogMedia.vue";
const emits = defineEmits(["update:modelValue"]);
const props = defineProps({
modelValue: {
type: Array,
default: () => [],
required: true,
},
showEditor: {
type: Boolean,
default: false,
required: false,
},
});
const showModalImage = ref(null);
let showButtons = ref(false);
let mouseMoveTimeout = null;
const showGalleryModal = (index) => {
showModalImage.value = index;
document.addEventListener("keydown", handleKeyDown);
};
const hideModal = () => {
showModalImage.value = null;
document.removeEventListener("keydown", handleKeyDown);
};
const handleKeyDown = (event) => {
if (event.key === "ArrowLeft") {
handleModalPrevImage();
} else if (event.key === "ArrowRight") {
handleModalNextImage();
} else if (event.key === "Escape") {
hideModal();
}
};
const handleModalUpdateButtons = () => {
if (mouseMoveTimeout !== null) {
clearTimeout(mouseMoveTimeout);
mouseMoveTimeout = null;
}
showButtons.value = true;
mouseMoveTimeout = setTimeout(() => {
showButtons.value = false;
mouseMoveTimeout = null;
}, 3000);
};
const handleModalPrevImage = () => {
handleModalUpdateButtons();
if (showModalImage.value !== null) {
if (showModalImage.value > 0) {
showModalImage.value--;
} else {
showModalImage.value = props.modelValue.length - 1;
}
}
};
const handleModalNextImage = () => {
handleModalUpdateButtons();
if (showModalImage.value !== null) {
if (showModalImage.value < props.modelValue.length - 1) {
showModalImage.value++;
} else {
showModalImage.value = 0;
}
}
};
const handleAddToGallery = async () => {
let result = await openDialog(SMDialogMedia, {
allowUpload: true,
multiple: true,
initial: props.modelValue,
});
if (result) {
const mediaResult = result as Media[];
let newValue = props.modelValue;
let galleryIds = new Set(mediaResult.map((item) => (item as Media).id));
mediaResult.forEach((item) => {
if (!galleryIds.has(item.id)) {
newValue.push(item);
galleryIds.add(item.id);
}
});
emits("update:modelValue", mediaResult);
}
};
const handleRemoveItem = async (id: string) => {
const newList = props.modelValue.filter((item) => item.id !== id);
emits("update:modelValue", newList);
};
onMounted(() => {
document.addEventListener("keydown", handleKeyDown);
});
onBeforeUnmount(() => {
document.removeEventListener("keydown", handleKeyDown);
});
</script>
<style lang="scss">
// .image-gallery {
// display: grid;
// grid-template-columns: 1fr 1fr;
// gap: 15px;
// .image-gallery-image {
// cursor: pointer;
// max-width: 100%;
// max-height: 100%;
// object-fit: contain;
// }
// }
// @media (min-width: 768px) {
// .image-gallery {
// grid-template-columns: 1fr 1fr 1fr;
// }
// }
// @media (min-width: 1024px) {
// .image-gallery {
// grid-template-columns: 1fr 1fr 1fr 1fr;
// }
// }
.image-gallery-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
display: flex;
justify-content: center;
align-items: center;
z-index: 5000;
.image-gallery-modal-image {
max-width: 90%;
max-height: 90%;
object-fit: contain;
}
&.image-gallery-modal-buttons {
.image-gallery-modal-prev,
.image-gallery-modal-next {
opacity: 1;
}
}
.image-gallery-modal-close {
position: absolute;
top: 10px;
right: 10px;
width: 30px;
height: 30px;
display: flex;
justify-content: center;
align-items: center;
font-size: 150%;
font-weight: bold;
color: white;
cursor: pointer;
transition: color 0.3s ease-in-out;
&:hover {
color: rgba(255, 255, 255, 0.7);
}
}
.image-gallery-modal-prev,
.image-gallery-modal-next {
position: absolute;
display: flex;
content: "";
justify-content: center;
align-items: center;
top: 0;
bottom: 0;
width: 75px;
background-color: rgba(0, 0, 0, 0.25);
opacity: 0;
transition: all 0.2s ease;
cursor: pointer;
&::before {
position: absolute;
display: block;
content: "";
width: 50px;
height: 50px;
border-radius: 50%;
background-color: #999;
transition: all 0.2s ease;
}
&::after {
position: absolute;
display: block;
content: "";
width: 12px;
height: 12px;
transition: all 0.2s ease;
}
&:hover {
&::before {
background-color: #ddd;
}
}
}
.image-gallery-modal-prev {
left: 0;
&::after {
border-left: 2px solid black;
border-bottom: 2px solid black;
transform: rotateZ(45deg) translateX(2px) translateY(-2px);
}
&:hover {
&::before {
transform: translateX(-3px);
}
&::after {
transform: rotateZ(45deg) translateX(-0.5px) translateY(0.5px);
}
}
}
.image-gallery-modal-next {
right: 0;
&::after {
border-right: 2px solid black;
border-top: 2px solid black;
transform: rotateZ(45deg) translateX(-2px) translateY(2px);
}
&:hover {
&::before {
transform: translateX(3px);
}
&::after {
transform: rotateZ(45deg) translateX(0.5px) translateY(-0.5px);
}
}
}
}
.sm-gallery-item:hover .item-delete {
display: block;
}
</style>

View File

@@ -1,80 +0,0 @@
<template>
<div class="sm-image-stack-container">
<div
:class="[
'sm-image-stack',
{ 'sm-image-stack-hover': frontImage !== -1 },
]"
:style="{
height: 300 + props.src.length * 20 + 'px',
width: 533 + props.src.length * 40 + 'px',
}"
@mouseout="handleHover(-1)">
<div
v-for="(source, index) in props.src"
:key="index"
:style="{
top: (index + 1) * 20 + 'px',
left: index * 40 + 'px',
'background-image': `url('${source}')`,
}"
:class="['image', { hover: frontImage == index }]"
@mouseover="handleHover(index)"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
const props = defineProps({
src: {
type: Array,
required: true,
},
});
const frontImage = ref(-1);
const handleHover = (index) => {
frontImage.value = index;
};
</script>
<style lang="scss">
.sm-image-stack-container {
display: flex;
width: 100%;
justify-content: center;
}
.sm-image-stack {
position: relative;
display: flex;
&.sm-image-stack-hover {
.image {
opacity: 0.5;
}
.hover {
opacity: 1 !important;
z-index: 1;
top: 0 !important;
}
}
.image {
position: absolute;
background-position: top left;
background-repeat: no-repeat;
background-size: cover;
height: 300px;
width: 533px;
border-radius: 8px;
box-shadow: var(--base-shadow);
// box-shadow: 4px 4px 10px rgba(0, 0, 0, 0.5);
transition: all 0.1s ease-in-out;
}
}
</style>

View File

@@ -1,882 +0,0 @@
<template>
<SMControl
:class="[
'control-type-input',
{
'input-active': active,
'has-prepend': slots.prepend,
'has-append': slots.append,
},
props.size,
]"
:invalid="feedbackInvalid"
:no-help="props.noHelp">
<div v-if="slots.prepend" class="input-control-prepend">
<slot name="prepend"></slot>
</div>
<div class="control-item">
<template v-if="props.type == 'checkbox'">
<label
:class="[
'control-label',
'control-label-checkbox',
{ disabled: disabled },
]"
v-bind="{ for: id }"
><input
:id="id"
type="checkbox"
class="checkbox-control"
:disabled="disabled"
:checked="value"
@input="handleCheckbox" />
<span class="checkbox-control-box">
<span class="checkbox-control-tick"></span> </span
>{{ label }}</label
>
</template>
<template v-else-if="props.type == 'range'">
<label
class="control-label control-label-range"
v-bind="{ for: id }"
>{{ label }}</label
>
<input
:id="id"
type="range"
class="range-control"
:disabled="disabled"
v-bind="{
min: props.min,
max: props.max,
step: props.step,
}"
:value="value"
@input="handleInput" />
<span class="range-control-value">{{ value }}</span>
</template>
<template v-else-if="props.type == 'select'">
<label
class="control-label control-label-select"
v-bind="{ for: id }"
>{{ label }}</label
>
<ion-icon
class="select-dropdown-icon"
name="caret-down-outline" />
<select
class="select-input-control"
:disabled="disabled"
@input="handleInput">
<option
v-for="option in Object.entries(props.options)"
:key="option[0]"
:value="option[0]"
:selected="option[0] == value">
{{ option[1] }}
</option>
</select>
</template>
<template v-else>
<label class="control-label" v-bind="{ for: id }">{{
label
}}</label>
<template v-if="props.type == 'static'">
<div class="static-input-control" v-bind="{ id: id }">
<span class="text">
{{ value }}
</span>
</div>
</template>
<template v-else-if="props.type == 'file'">
<input
:id="id"
type="file"
class="file-input-control"
:accept="props.accept"
:disabled="disabled"
@change="handleChange" />
<div class="file-input-control-value">
{{ value?.name ? value.name : value }}
</div>
<label
:class="[
'button',
'primary',
'file-input-control-button',
{ disabled: disabled },
]"
:for="id"
>Select file</label
>
</template>
<template v-else-if="props.type == 'textarea'">
<ion-icon
class="invalid-icon"
name="alert-circle-outline"></ion-icon>
<textarea
:type="props.type"
class="input-control"
:disabled="disabled"
v-bind="{ id: id, autofocus: props.autofocus }"
v-model="value"
rows="5"
@focus="handleFocus"
@blur="handleBlur"
@input="handleInput"
@keyup="handleKeyup"></textarea>
</template>
<template v-else-if="props.type == 'media'">
<div class="media-input-control">
<img
v-if="mediaUrl?.length > 0"
:src="mediaGetVariantUrl(value, 'medium')" />
<ion-icon v-else name="image-outline" />
<!-- <SMButton
size="medium"
:disabled="disabled"
@click="handleMediaSelect"
label="Select File" /> -->
</div>
</template>
<template v-else>
<ion-icon
class="invalid-icon"
name="alert-circle-outline"></ion-icon>
<ion-icon
v-if="
props.showClear &&
value?.length > 0 &&
!feedbackInvalid
"
class="clear-icon"
name="close-outline"
@click.stop="handleClear"></ion-icon>
<input
:type="props.type"
class="input-control"
:disabled="disabled"
v-bind="{
id: id,
autofocus: props.autofocus,
autocomplete:
props.type === 'email' ? 'email' : null,
spellcheck: props.type === 'email' ? false : null,
autocorrect: props.type === 'email' ? 'on' : null,
autocapitalize:
props.type === 'email' ? 'off' : null,
}"
v-model="value"
@focus="handleFocus"
@blur="handleBlur"
@input="handleInput"
@keyup="handleKeyup" />
<ul
class="autocomplete-list"
v-if="computedAutocompleteItems.length > 0 && focused">
<li
v-for="item in computedAutocompleteItems"
:key="item"
@mousedown="handleAutocompleteClick(item)">
{{ item }}
</li>
</ul>
</template>
</template>
</div>
<div v-if="slots.append" class="input-control-append">
<slot name="append"></slot>
</div>
<template v-if="slots.help" #help><slot name="help"></slot></template>
</SMControl>
</template>
<script setup lang="ts">
import { inject, watch, ref, useSlots, computed } from "vue";
import { isEmpty, generateRandomElementId } from "../helpers/utils";
import { toTitleCase } from "../helpers/string";
import { mediaGetVariantUrl } from "../helpers/media";
import SMControl from "./SMControl.vue";
import SMButton from "./SMButton.vue";
import { openDialog } from "./SMDialog";
import SMDialogMedia from "./dialogs/SMDialogMedia.vue";
import { Media } from "../helpers/api.types";
const emits = defineEmits(["update:modelValue", "blur", "keyup"]);
const props = defineProps({
form: {
type: Object,
default: undefined,
required: false,
},
control: {
type: [String, Object],
default: "",
},
label: {
type: String,
default: undefined,
required: false,
},
modelValue: {
type: [String, Number, Boolean],
default: undefined,
required: false,
},
type: {
type: String,
default: "text",
required: false,
},
id: {
type: String,
default: undefined,
required: false,
},
disabled: {
type: Boolean,
default: false,
required: false,
},
button: {
type: String,
default: "",
required: false,
},
showClear: {
type: Boolean,
default: false,
required: false,
},
feedbackInvalid: {
type: String,
default: "",
required: false,
},
autofocus: {
type: Boolean,
default: false,
required: false,
},
accept: {
type: String,
default: "",
required: false,
},
options: {
type: Object,
default: null,
required: false,
},
size: {
type: String,
default: "",
required: false,
},
min: {
type: Number,
default: undefined,
required: false,
},
max: {
type: Number,
default: undefined,
required: false,
},
step: {
type: Number,
default: undefined,
required: false,
},
noHelp: {
type: Boolean,
default: false,
required: false,
},
formId: {
type: String,
default: "form",
required: false,
},
autocomplete: {
type: [Array<string>, Function],
default: () => {
[];
},
required: false,
},
});
const slots = useSlots();
const form = inject(props.formId, props.form);
const control =
typeof props.control === "object"
? props.control
: form &&
!isEmpty(form) &&
typeof props.control === "string" &&
props.control !== "" &&
Object.prototype.hasOwnProperty.call(form.controls, props.control)
? form.controls[props.control]
: null;
const label = ref(
props.label != undefined
? props.label
: typeof props.control == "string"
? toTitleCase(props.control)
: ""
);
const value = ref(
props.modelValue != undefined
? props.modelValue
: control != null
? control.value
: ""
);
const id = ref(
props.id != undefined
? props.id
: typeof props.control == "string" && props.control.length > 0
? props.control
: generateRandomElementId()
);
const feedbackInvalid = ref(props.feedbackInvalid);
const active = ref(value.value?.toString().length ?? 0 > 0);
const focused = ref(false);
const disabled = ref(props.disabled);
watch(
() => value.value,
(newValue) => {
if (props.type === "media") {
mediaUrl.value = value.value.url ?? "";
}
active.value =
newValue.toString().length > 0 ||
newValue instanceof File ||
focused.value == true;
}
);
if (props.modelValue != undefined) {
watch(
() => props.modelValue,
(newValue) => {
value.value = newValue;
}
);
}
watch(
() => props.feedbackInvalid,
(newValue) => {
feedbackInvalid.value = newValue;
}
);
watch(
() => props.disabled,
(newValue) => {
disabled.value = newValue;
}
);
if (typeof control === "object" && control !== null) {
watch(
() => control.validation.result.valid,
(newValue) => {
feedbackInvalid.value = newValue
? ""
: control.validation.result.invalidMessages[0];
},
{ deep: true }
);
watch(
() => control.value,
(newValue) => {
value.value = newValue;
},
{ deep: true }
);
}
if (form) {
watch(
() => form.loading(),
(newValue) => {
disabled.value = newValue;
}
);
}
const mediaUrl = ref(value.value.url ?? "");
const handleFocus = () => {
active.value = true;
focused.value = true;
};
const handleBlur = async () => {
active.value = value.value?.length ?? 0 > 0;
focused.value = false;
emits("blur");
if (control) {
await control.validate();
control.isValid();
}
};
const handleCheckbox = (event: Event) => {
const target = event.target as HTMLInputElement;
value.value = target.checked;
emits("update:modelValue", target.checked);
if (control) {
control.value = target.checked;
feedbackInvalid.value = "";
}
};
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement;
value.value = target.value;
emits("update:modelValue", target.value);
if (control) {
control.value = target.value;
feedbackInvalid.value = "";
}
};
const handleKeyup = (event: Event) => {
emits("keyup", event);
};
const handleClear = () => {
value.value = "";
emits("update:modelValue", "");
};
const handleChange = (event) => {
if (control) {
control.value = event.target.files[0];
feedbackInvalid.value = "";
}
};
const handleMediaSelect = async () => {
let result = await openDialog(SMDialogMedia);
if (result) {
const mediaResult = result as Media;
mediaUrl.value = mediaResult.url;
emits("update:modelValue", mediaResult);
if (control) {
control.value = mediaResult;
feedbackInvalid.value = "";
}
}
};
const computedAutocompleteItems = computed(() => {
let autocompleteList = [];
if (props.autocomplete) {
if (typeof props.autocomplete === "function") {
autocompleteList = props.autocomplete(value.value);
} else {
autocompleteList = props.autocomplete.filter((str) =>
str.includes(value.value)
);
}
return autocompleteList.sort((a, b) => a.localeCompare(b));
}
return autocompleteList;
});
const handleAutocompleteClick = (item) => {
value.value = item;
emits("update:modelValue", item);
};
</script>
<style lang="scss">
.control-group.control-type-input {
.control-row {
.input-control-prepend {
p {
display: block;
color: var(--base-color-text);
background-color: var(--base-color-dark);
border-width: 1px 0 1px 1px;
border-style: solid;
border-color: var(--base-color-darker);
border-radius: 8px 0 0 8px;
padding: 16px 16px 16px 16px;
}
.button {
border-width: 1px 0 1px 1px;
border-style: solid;
border-color: var(--base-color-darker);
border-radius: 8px 0 0 8px;
}
& + .control-item .input-control {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
.input-control-append {
p {
display: block;
color: var(--base-color-text);
background-color: var(--base-color-dark);
border-width: 1px 1px 1px 0;
border-style: solid;
border-color: var(--base-color-darker);
border-radius: 0 8px 8px 0;
padding: 16px 16px 16px 16px;
}
.button {
border-width: 1px 1px 1px 0;
border-style: solid;
border-color: var(--base-color-darker);
height: 50px;
border-radius: 0 8px 8px 0;
}
}
.control-item {
max-width: 100%;
align-items: start;
.control-label {
position: absolute;
display: block;
transform-origin: top left;
transform: translate(16px, 16px) scale(1);
transition: all 0.1s ease-in-out;
color: var(--base-color-darker);
pointer-events: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.invalid-icon {
position: absolute;
display: none;
right: 10px;
top: 14px;
color: var(--danger-color);
font-size: 150%;
}
.clear-icon {
position: absolute;
right: 12px;
top: 18px;
background-color: var(--input-clear-icon-color);
border-radius: 50%;
font-size: 80%;
padding: 1px 1px 1px 0px;
&:hover {
color: var(--input-clear-icon-color-hover);
}
}
.input-control {
display: block;
width: 100%;
padding: 20px 16px 10px 16px;
border: 1px solid var(--base-color-darker);
border-radius: 8px;
background-color: var(--base-color-light);
color: var(--base-color-text);
&:disabled {
background-color: hsl(0, 0%, 92%);
cursor: not-allowed;
}
}
.autocomplete-list {
position: absolute;
list-style-type: none;
top: 100%;
width: 100%;
margin: 0;
padding: 0;
border: 1px solid var(--base-color-darker);
background-color: var(--base-color-light);
color: var(--primary-color);
z-index: 1;
max-height: 200px;
overflow: scroll;
scroll-behavior: smooth;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
li {
cursor: pointer;
padding: 8px 16px;
margin: 2px;
&:hover {
background-color: var(--base-color);
}
}
}
.static-input-control {
width: 100%;
padding: 22px 16px 8px 16px;
border: 1px solid var(--base-color-darker);
border-radius: 8px;
background-color: var(--base-color);
height: 52px;
overflow: auto;
scroll-behavior: smooth;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.file-input-control {
opacity: 0;
width: 0.1px;
height: 0.1px;
position: absolute;
margin-left: -9999px;
}
.file-input-control-value {
width: 100%;
padding: 22px 16px 8px 16px;
border: 1px solid var(--base-color-darker);
border-radius: 8px 0 0 8px;
background-color: var(--base-color);
height: 52px;
overflow: auto;
scroll-behavior: smooth;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.file-input-control-button {
border-width: 1px 1px 1px 0;
border-style: solid;
border-color: var(--base-color-darker);
border-radius: 0 8px 8px 0;
padding: 16px 30px;
width: auto;
}
.control-label-select {
transform: translate(16px, 6px) scale(0.7);
}
.select-dropdown-icon {
position: absolute;
top: 50%;
right: 0;
transform: translate(-50%, -50%);
font-size: 110%;
}
.select-input-control {
appearance: none;
width: 100%;
padding: 20px 16px 8px 14px;
border: 1px solid var(--base-color-darker);
border-radius: 8px;
background-color: var(--base-color-light);
height: 52px;
color: var(--base-color-text);
}
.control-label-checkbox {
position: relative;
display: flex;
align-items: center;
padding: 16px 0 16px 32px;
pointer-events: all;
transform: none;
color: var(--base-color-text);
&.disabled {
color: var(--base-color-darker);
cursor: not-allowed;
.checkbox-control-box {
background-color: var(--base-color);
}
}
}
.checkbox-control {
opacity: 0;
width: 0;
height: 0;
&:checked + .checkbox-control-box {
.checkbox-control-tick {
display: block;
}
}
}
.checkbox-control-box {
position: absolute;
top: 14px;
left: 0;
width: 24px;
height: 24px;
border: 1px solid var(--base-color-darker);
border-radius: 2px;
background-color: var(--base-color-light);
.checkbox-control-tick {
position: absolute;
display: none;
border-right: 3px solid var(--base-color-text);
border-bottom: 3px solid var(--base-color-text);
top: 1px;
left: 7px;
width: 8px;
height: 16px;
transform: rotate(45deg);
}
}
.media-input-control {
width: 100%;
text-align: center;
img,
ion-icon {
display: block;
margin: 48px auto 8px auto;
border-radius: 8px;
font-size: 800%;
max-height: 300px;
}
}
.control-label-range {
transform: none !important;
}
.range-control {
margin-top: 24px;
width: 100%;
}
.range-control-value {
margin-top: 22px;
padding-left: 16px;
font-size: 90%;
font-weight: 600;
width: 48px;
text-align: right;
}
}
}
&.has-append .control-item .input-control {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
&.input-active {
.control-item {
.control-label:not(.control-label-checkbox) {
transform: translate(16px, 6px) scale(0.7);
}
}
}
&.control-invalid {
.control-row .control-item {
.invalid-icon {
display: block;
}
.input-control {
border: 2px solid var(--danger-color);
}
}
}
&.small {
&.input-active {
.control-row .control-item .control-label {
transform: translate(16px, 6px) scale(0.7);
}
}
.control-row {
.control-item {
.control-label {
transform: translate(16px, 12px) scale(1);
}
.input-control {
padding: 16px 8px 4px 14px;
}
}
.input-control-append {
.button {
.button-label {
ion-icon {
height: 16px;
width: 16px;
}
}
height: 36px;
padding: 3px 24px 13px 24px;
}
}
}
}
}
@media (prefers-color-scheme: dark) {
.control-group.control-type-input {
.control-row {
.control-item {
.input-control {
&:disabled {
background-color: hsl(0, 0%, 8%);
}
}
}
}
}
}
</style>

View File

@@ -1,315 +0,0 @@
<template>
<div
:class="[
'sm-input',
'flex',
'flex-col',
'flex-1',
{ 'sm-input-small': small },
]">
<div
:class="[
'relative',
'w-full',
'flex',
{ 'input-active': active || focused },
]">
<label
:for="id"
:class="[
'absolute',
'select-none',
'pointer-events-none',
'transform-origin-top-left',
'text-gray',
'block',
'scale-100',
'transition',
small
? ['translate-x-4', 'text-sm', '-top-1.5']
: ['translate-x-5', 'top-0.5'],
]"
>{{ label }}</label
>
<template v-if="!props.textarea">
<input
:type="props.type"
:class="[
'w-full',
'text-gray-6',
'flex-1',
small
? ['text-sm', 'pt-3', 'px-3']
: ['text-lg', 'pt-5', 'px-4'],
feedbackInvalid ? 'border-red-6' : 'border-gray',
feedbackInvalid ? 'border-2' : 'border-1',
{ 'bg-gray-1': disabled },
{ 'rounded-l-2': !slots.prepend },
{ 'rounded-r-2': !slots.append },
]"
v-bind="{
id: id,
autofocus: props.autofocus,
autocomplete: props.type === 'email' ? 'email' : null,
spellcheck: props.type === 'email' ? false : null,
autocorrect: props.type === 'email' ? 'on' : null,
autocapitalize: props.type === 'email' ? 'off' : null,
}"
@focus="handleFocus"
@blur="handleBlur"
@input="handleInput"
@keyup="handleKeyup"
:value="value"
:disabled="disabled" />
<template v-if="slots.append"
><slot name="append"></slot
></template>
</template>
<template v-else>
<textarea
:class="[
'w-full',
'text-gray-6',
'flex-1',
small
? ['text-sm', 'pt-3', 'px-3']
: ['text-lg', 'pt-5', 'px-4'],
feedbackInvalid ? 'border-red-6' : 'border-gray',
feedbackInvalid ? 'border-2' : 'border-1',
{ 'bg-gray-1': disabled },
{ 'rounded-l-2': !slots.prepend },
{ 'rounded-r-2': !slots.append },
]"
v-bind="{
id: id,
autofocus: props.autofocus,
autocomplete: props.type === 'email' ? 'email' : null,
spellcheck: props.type === 'email' ? false : null,
autocorrect: props.type === 'email' ? 'on' : null,
autocapitalize: props.type === 'email' ? 'off' : null,
}"
@focus="handleFocus"
@blur="handleBlur"
@input="handleInput"
@keyup="handleKeyup"
:value="value"
:disabled="disabled"></textarea>
</template>
</div>
<p v-if="feedbackInvalid" class="px-2 pt-2 text-xs text-red-6">
{{ feedbackInvalid }}
</p>
<p v-if="slots.default" class="px-2 pt-2 text-xs text-gray-5">
<slot></slot>
</p>
</div>
</template>
<script setup lang="ts">
import { watch, ref, useSlots, inject } from "vue";
import { isEmpty, generateRandomElementId } from "../helpers/utils";
import { toTitleCase } from "../helpers/string";
const emits = defineEmits(["update:modelValue", "blur", "keyup"]);
const props = defineProps({
form: {
type: Object,
default: undefined,
required: false,
},
control: {
type: [String, Object],
default: "",
},
label: {
type: String,
default: undefined,
required: false,
},
modelValue: {
type: [String, Number, Boolean],
default: undefined,
required: false,
},
type: {
type: String,
default: "text",
required: false,
},
id: {
type: String,
default: undefined,
required: false,
},
disabled: {
type: Boolean,
default: false,
required: false,
},
feedbackInvalid: {
type: String,
default: "",
required: false,
},
autofocus: {
type: Boolean,
default: false,
required: false,
},
options: {
type: Object,
default: null,
required: false,
},
formId: {
type: String,
default: "form",
required: false,
},
small: {
type: Boolean,
default: false,
required: false,
},
textarea: {
type: Boolean,
default: false,
required: false,
},
});
const slots = useSlots();
const form = inject(props.formId, props.form);
const control =
typeof props.control === "object"
? props.control
: form &&
!isEmpty(form) &&
typeof props.control === "string" &&
props.control !== "" &&
Object.prototype.hasOwnProperty.call(form.controls, props.control)
? form.controls[props.control]
: null;
const label = ref(
props.label != undefined
? props.label
: typeof props.control == "string"
? toTitleCase(props.control)
: "",
);
const value = ref(
props.modelValue != undefined
? props.modelValue
: control != null
? control.value
: "",
);
const id = ref(
props.id != undefined
? props.id
: typeof props.control == "string" && props.control.length > 0
? props.control
: generateRandomElementId(),
);
const feedbackInvalid = ref(props.feedbackInvalid);
const active = ref(value.value?.toString().length ?? 0 > 0);
const focused = ref(false);
const disabled = ref(props.disabled);
watch(
() => value.value,
(newValue) => {
active.value = newValue.toString().length > 0 || focused.value == true;
},
);
if (props.modelValue != undefined) {
watch(
() => props.modelValue,
(newValue) => {
value.value = newValue;
},
);
}
watch(
() => props.feedbackInvalid,
(newValue) => {
feedbackInvalid.value = newValue;
},
);
watch(
() => props.disabled,
(newValue) => {
disabled.value = newValue;
},
);
if (typeof control === "object" && control !== null) {
watch(
() => control.validation.result.valid,
(newValue) => {
feedbackInvalid.value = newValue
? ""
: control.validation.result.invalidMessages[0];
},
{ deep: true },
);
watch(
() => control.value,
(newValue) => {
value.value = newValue;
},
{ deep: true },
);
}
const handleFocus = () => {
active.value = true;
focused.value = true;
};
const handleBlur = async () => {
active.value = value.value?.length ?? 0 > 0;
focused.value = false;
emits("blur");
if (control) {
await control.validate();
control.isValid();
}
};
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement;
value.value = target.value;
emits("update:modelValue", target.value);
if (control) {
control.value = target.value;
feedbackInvalid.value = "";
}
};
const handleKeyup = (event: Event) => {
emits("keyup", event);
};
</script>
<style lang="scss">
.sm-input {
label {
--un-translate-y: 0.85rem;
}
.input-active label {
transform: translate(16px, 6px) scale(0.7);
}
&.sm-input-small .input-active label {
transform: translate(12px, 7px) scale(0.7);
}
}
</style>

View File

@@ -1,202 +0,0 @@
<template>
<div class="input-attachments">
<ul>
<li v-if="mediaItems.length == 0" class="attachments-none">
<ion-icon name="sad-outline"></ion-icon>
<p>No attachments</p>
</li>
<li v-for="media of mediaItems" :key="media.id">
<div class="attachment-media-icon">
<img
:src="getFilePreview(media.url)"
height="48"
width="48" />
</div>
<div class="attachment-media-name">
{{ media.title || media.name }}
</div>
<div class="attachment-media-size">
({{ bytesReadable(media.size) }})
</div>
<div class="attachment-media-remove">
<ion-icon
name="close-outline"
title="Remove attachment"
@click="handleClickRemove(media.id)" />
</div>
</li>
</ul>
<button type="button" @click="handleClickAdd">Add media</button>
</div>
</template>
<script setup lang="ts">
import { ref, Ref, watch } from "vue";
import { openDialog } from "../components/SMDialog";
import { api } from "../helpers/api";
import { Media, MediaResponse } from "../helpers/api.types";
import { bytesReadable } from "../helpers/types";
import { getFilePreview } from "../helpers/utils";
import SMDialogMedia from "./dialogs/SMDialogMedia.vue";
const props = defineProps({
modelValue: {
type: Array<string>,
default: () => [],
required: true,
},
accept: {
type: String,
default: "",
},
});
const emits = defineEmits(["update:modelValue"]);
const value: Ref<string[]> = ref(props.modelValue);
const mediaItems: Ref<Media[]> = ref([]);
/**
* Handle the user adding a new media item.
*/
const handleClickAdd = async () => {
openDialog(SMDialogMedia, { mime: "", accepts: "", allowUpload: true })
.then((result) => {
const media = result as Media;
mediaItems.value.push(media);
value.value.push(media.id);
emits("update:modelValue", value.value);
})
.catch(() => {
/* empty */
});
};
/**
* Handle removing a media item from the attachment array.
* @param {string} media_id The media id to remove.
*/
const handleClickRemove = (media_id: string) => {
const index = value.value.indexOf(media_id);
if (index !== -1) {
value.value.splice(index, 1);
}
const mediaIndex = mediaItems.value.findIndex(
(media) => media.id === media_id,
);
if (mediaIndex !== -1) {
mediaItems.value.splice(mediaIndex, 1);
}
emits("update:modelValue", value.value);
};
/**
* Load the attachment list
*/
const handleLoad = () => {
mediaItems.value = [];
if (value.value && typeof value.value.forEach === "function") {
value.value.forEach((item) => {
api.get({
url: `/media/${item}`,
})
.then((result) => {
if (result.data) {
const data = result.data as MediaResponse;
mediaItems.value.push(data.medium);
}
})
.catch(() => {
/* empty */
});
});
}
};
watch(
() => props.modelValue,
(newValue) => {
value.value = newValue;
handleLoad();
},
);
handleLoad();
</script>
<style lang="scss">
// .input-attachments {
// display: block;
// label {
// position: relative;
// display: block;
// padding: 8px 16px 0 16px;
// color: var(--base-color);
// }
// a.button {
// display: inline-block;
// }
// ul {
// list-style-type: none;
// padding: 0;
// border: 1px solid var(--base-color-border);
// li {
// background-color: var(--base-color-light);
// display: flex;
// align-items: center;
// padding: 16px;
// margin: 0;
// &.attachments-none {
// justify-content: center;
// ion-icon {
// font-size: 115%;
// }
// p {
// margin: 0;
// padding-left: #{map-get($spacing, 2)};
// }
// }
// .attachment-media-icon {
// display: flex;
// width: 64px;
// justify-content: center;
// }
// .attachment-media-name {
// flex: 1;
// }
// .attachment-media-size {
// font-size: 75%;
// padding-left: #{map-get($spacing, 2)};
// color: var(--base-color-dark);
// }
// .attachment-media-remove {
// font-size: 115%;
// padding-top: #{map-get($spacing, 1)};
// margin-left: #{map-get($spacing, 3)};
// color: var(--base-color-text);
// cursor: pointer;
// &:hover {
// color: var(--danger-color);
// }
// }
// }
// }
// }
</style>

View File

@@ -1,882 +0,0 @@
<template>
<SMControl
:class="[
'control-type-input',
{
'input-active': active,
'has-prepend': slots.prepend,
'has-append': slots.append,
},
props.size,
]"
:invalid="feedbackInvalid"
:no-help="props.noHelp">
<div v-if="slots.prepend" class="input-control-prepend">
<slot name="prepend"></slot>
</div>
<div class="control-item">
<template v-if="props.type == 'checkbox'">
<label
:class="[
'control-label',
'control-label-checkbox',
{ disabled: disabled },
]"
v-bind="{ for: id }"
><input
:id="id"
type="checkbox"
class="checkbox-control"
:disabled="disabled"
:checked="value"
@input="handleCheckbox" />
<span class="checkbox-control-box">
<span class="checkbox-control-tick"></span> </span
>{{ label }}</label
>
</template>
<template v-else-if="props.type == 'range'">
<label
class="control-label control-label-range"
v-bind="{ for: id }"
>{{ label }}</label
>
<input
:id="id"
type="range"
class="range-control"
:disabled="disabled"
v-bind="{
min: props.min,
max: props.max,
step: props.step,
}"
:value="value"
@input="handleInput" />
<span class="range-control-value">{{ value }}</span>
</template>
<template v-else-if="props.type == 'select'">
<label
class="control-label control-label-select"
v-bind="{ for: id }"
>{{ label }}</label
>
<ion-icon
class="select-dropdown-icon"
name="caret-down-outline" />
<select
class="select-input-control"
:disabled="disabled"
@input="handleInput">
<option
v-for="option in Object.entries(props.options)"
:key="option[0]"
:value="option[0]"
:selected="option[0] == value">
{{ option[1] }}
</option>
</select>
</template>
<template v-else>
<label class="control-label" v-bind="{ for: id }">{{
label
}}</label>
<template v-if="props.type == 'static'">
<div class="static-input-control" v-bind="{ id: id }">
<span class="text">
{{ value }}
</span>
</div>
</template>
<template v-else-if="props.type == 'file'">
<input
:id="id"
type="file"
class="file-input-control"
:accept="props.accept"
:disabled="disabled"
@change="handleChange" />
<div class="file-input-control-value">
{{ value?.name ? value.name : value }}
</div>
<label
:class="[
'button',
'primary',
'file-input-control-button',
{ disabled: disabled },
]"
:for="id"
>Select file</label
>
</template>
<template v-else-if="props.type == 'textarea'">
<ion-icon
class="invalid-icon"
name="alert-circle-outline"></ion-icon>
<textarea
:type="props.type"
class="input-control"
:disabled="disabled"
v-bind="{ id: id, autofocus: props.autofocus }"
v-model="value"
rows="5"
@focus="handleFocus"
@blur="handleBlur"
@input="handleInput"
@keyup="handleKeyup"></textarea>
</template>
<template v-else-if="props.type == 'media'">
<div class="media-input-control">
<img
v-if="mediaUrl?.length > 0"
:src="mediaGetVariantUrl(value, 'medium')" />
<ion-icon v-else name="image-outline" />
<!-- <SMButton
size="medium"
:disabled="disabled"
@click="handleMediaSelect"
label="Select File" /> -->
</div>
</template>
<template v-else>
<ion-icon
class="invalid-icon"
name="alert-circle-outline"></ion-icon>
<ion-icon
v-if="
props.showClear &&
value?.length > 0 &&
!feedbackInvalid
"
class="clear-icon"
name="close-outline"
@click.stop="handleClear"></ion-icon>
<input
:type="props.type"
class="input-control"
:disabled="disabled"
v-bind="{
id: id,
autofocus: props.autofocus,
autocomplete:
props.type === 'email' ? 'email' : null,
spellcheck: props.type === 'email' ? false : null,
autocorrect: props.type === 'email' ? 'on' : null,
autocapitalize:
props.type === 'email' ? 'off' : null,
}"
v-model="value"
@focus="handleFocus"
@blur="handleBlur"
@input="handleInput"
@keyup="handleKeyup" />
<ul
class="autocomplete-list"
v-if="computedAutocompleteItems.length > 0 && focused">
<li
v-for="item in computedAutocompleteItems"
:key="item"
@mousedown="handleAutocompleteClick(item)">
{{ item }}
</li>
</ul>
</template>
</template>
</div>
<div v-if="slots.append" class="input-control-append">
<slot name="append"></slot>
</div>
<template v-if="slots.help" #help><slot name="help"></slot></template>
</SMControl>
</template>
<script setup lang="ts">
import { inject, watch, ref, useSlots, computed } from "vue";
import { isEmpty, generateRandomElementId } from "../helpers/utils";
import { toTitleCase } from "../helpers/string";
import { mediaGetVariantUrl } from "../helpers/media";
import SMControl from "./SMControl.vue";
import SMButton from "./SMButton.vue";
import { openDialog } from "./SMDialog";
import SMDialogMedia from "./dialogs/SMDialogMedia.vue";
import { Media } from "../helpers/api.types";
const emits = defineEmits(["update:modelValue", "blur", "keyup"]);
const props = defineProps({
form: {
type: Object,
default: undefined,
required: false,
},
control: {
type: [String, Object],
default: "",
},
label: {
type: String,
default: undefined,
required: false,
},
modelValue: {
type: [String, Number, Boolean],
default: undefined,
required: false,
},
type: {
type: String,
default: "text",
required: false,
},
id: {
type: String,
default: undefined,
required: false,
},
disabled: {
type: Boolean,
default: false,
required: false,
},
button: {
type: String,
default: "",
required: false,
},
showClear: {
type: Boolean,
default: false,
required: false,
},
feedbackInvalid: {
type: String,
default: "",
required: false,
},
autofocus: {
type: Boolean,
default: false,
required: false,
},
accept: {
type: String,
default: "",
required: false,
},
options: {
type: Object,
default: null,
required: false,
},
size: {
type: String,
default: "",
required: false,
},
min: {
type: Number,
default: undefined,
required: false,
},
max: {
type: Number,
default: undefined,
required: false,
},
step: {
type: Number,
default: undefined,
required: false,
},
noHelp: {
type: Boolean,
default: false,
required: false,
},
formId: {
type: String,
default: "form",
required: false,
},
autocomplete: {
type: [Array<string>, Function],
default: () => {
[];
},
required: false,
},
});
const slots = useSlots();
const form = inject(props.formId, props.form);
const control =
typeof props.control === "object"
? props.control
: form &&
!isEmpty(form) &&
typeof props.control === "string" &&
props.control !== "" &&
Object.prototype.hasOwnProperty.call(form.controls, props.control)
? form.controls[props.control]
: null;
const label = ref(
props.label != undefined
? props.label
: typeof props.control == "string"
? toTitleCase(props.control)
: ""
);
const value = ref(
props.modelValue != undefined
? props.modelValue
: control != null
? control.value
: ""
);
const id = ref(
props.id != undefined
? props.id
: typeof props.control == "string" && props.control.length > 0
? props.control
: generateRandomElementId()
);
const feedbackInvalid = ref(props.feedbackInvalid);
const active = ref(value.value?.toString().length ?? 0 > 0);
const focused = ref(false);
const disabled = ref(props.disabled);
watch(
() => value.value,
(newValue) => {
if (props.type === "media") {
mediaUrl.value = value.value.url ?? "";
}
active.value =
newValue.toString().length > 0 ||
newValue instanceof File ||
focused.value == true;
}
);
if (props.modelValue != undefined) {
watch(
() => props.modelValue,
(newValue) => {
value.value = newValue;
}
);
}
watch(
() => props.feedbackInvalid,
(newValue) => {
feedbackInvalid.value = newValue;
}
);
watch(
() => props.disabled,
(newValue) => {
disabled.value = newValue;
}
);
if (typeof control === "object" && control !== null) {
watch(
() => control.validation.result.valid,
(newValue) => {
feedbackInvalid.value = newValue
? ""
: control.validation.result.invalidMessages[0];
},
{ deep: true }
);
watch(
() => control.value,
(newValue) => {
value.value = newValue;
},
{ deep: true }
);
}
if (form) {
watch(
() => form.loading(),
(newValue) => {
disabled.value = newValue;
}
);
}
const mediaUrl = ref(value.value.url ?? "");
const handleFocus = () => {
active.value = true;
focused.value = true;
};
const handleBlur = async () => {
active.value = value.value?.length ?? 0 > 0;
focused.value = false;
emits("blur");
if (control) {
await control.validate();
control.isValid();
}
};
const handleCheckbox = (event: Event) => {
const target = event.target as HTMLInputElement;
value.value = target.checked;
emits("update:modelValue", target.checked);
if (control) {
control.value = target.checked;
feedbackInvalid.value = "";
}
};
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement;
value.value = target.value;
emits("update:modelValue", target.value);
if (control) {
control.value = target.value;
feedbackInvalid.value = "";
}
};
const handleKeyup = (event: Event) => {
emits("keyup", event);
};
const handleClear = () => {
value.value = "";
emits("update:modelValue", "");
};
const handleChange = (event) => {
if (control) {
control.value = event.target.files[0];
feedbackInvalid.value = "";
}
};
const handleMediaSelect = async () => {
let result = await openDialog(SMDialogMedia);
if (result) {
const mediaResult = result as Media;
mediaUrl.value = mediaResult.url;
emits("update:modelValue", mediaResult);
if (control) {
control.value = mediaResult;
feedbackInvalid.value = "";
}
}
};
const computedAutocompleteItems = computed(() => {
let autocompleteList = [];
if (props.autocomplete) {
if (typeof props.autocomplete === "function") {
autocompleteList = props.autocomplete(value.value);
} else {
autocompleteList = props.autocomplete.filter((str) =>
str.includes(value.value)
);
}
return autocompleteList.sort((a, b) => a.localeCompare(b));
}
return autocompleteList;
});
const handleAutocompleteClick = (item) => {
value.value = item;
emits("update:modelValue", item);
};
</script>
<style lang="scss">
.control-group.control-type-input {
.control-row {
.input-control-prepend {
p {
display: block;
color: var(--base-color-text);
background-color: var(--base-color-dark);
border-width: 1px 0 1px 1px;
border-style: solid;
border-color: var(--base-color-darker);
border-radius: 8px 0 0 8px;
padding: 16px 16px 16px 16px;
}
.button {
border-width: 1px 0 1px 1px;
border-style: solid;
border-color: var(--base-color-darker);
border-radius: 8px 0 0 8px;
}
& + .control-item .input-control {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
.input-control-append {
p {
display: block;
color: var(--base-color-text);
background-color: var(--base-color-dark);
border-width: 1px 1px 1px 0;
border-style: solid;
border-color: var(--base-color-darker);
border-radius: 0 8px 8px 0;
padding: 16px 16px 16px 16px;
}
.button {
border-width: 1px 1px 1px 0;
border-style: solid;
border-color: var(--base-color-darker);
height: 50px;
border-radius: 0 8px 8px 0;
}
}
.control-item {
max-width: 100%;
align-items: start;
.control-label {
position: absolute;
display: block;
transform-origin: top left;
transform: translate(16px, 16px) scale(1);
transition: all 0.1s ease-in-out;
color: var(--base-color-darker);
pointer-events: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.invalid-icon {
position: absolute;
display: none;
right: 10px;
top: 14px;
color: var(--danger-color);
font-size: 150%;
}
.clear-icon {
position: absolute;
right: 12px;
top: 18px;
background-color: var(--input-clear-icon-color);
border-radius: 50%;
font-size: 80%;
padding: 1px 1px 1px 0px;
&:hover {
color: var(--input-clear-icon-color-hover);
}
}
.input-control {
display: block;
width: 100%;
padding: 20px 16px 10px 16px;
border: 1px solid var(--base-color-darker);
border-radius: 8px;
background-color: var(--base-color-light);
color: var(--base-color-text);
&:disabled {
background-color: hsl(0, 0%, 92%);
cursor: not-allowed;
}
}
.autocomplete-list {
position: absolute;
list-style-type: none;
top: 100%;
width: 100%;
margin: 0;
padding: 0;
border: 1px solid var(--base-color-darker);
background-color: var(--base-color-light);
color: var(--primary-color);
z-index: 1;
max-height: 200px;
overflow: scroll;
scroll-behavior: smooth;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
li {
cursor: pointer;
padding: 8px 16px;
margin: 2px;
&:hover {
background-color: var(--base-color);
}
}
}
.static-input-control {
width: 100%;
padding: 22px 16px 8px 16px;
border: 1px solid var(--base-color-darker);
border-radius: 8px;
background-color: var(--base-color);
height: 52px;
overflow: auto;
scroll-behavior: smooth;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.file-input-control {
opacity: 0;
width: 0.1px;
height: 0.1px;
position: absolute;
margin-left: -9999px;
}
.file-input-control-value {
width: 100%;
padding: 22px 16px 8px 16px;
border: 1px solid var(--base-color-darker);
border-radius: 8px 0 0 8px;
background-color: var(--base-color);
height: 52px;
overflow: auto;
scroll-behavior: smooth;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.file-input-control-button {
border-width: 1px 1px 1px 0;
border-style: solid;
border-color: var(--base-color-darker);
border-radius: 0 8px 8px 0;
padding: 16px 30px;
width: auto;
}
.control-label-select {
transform: translate(16px, 6px) scale(0.7);
}
.select-dropdown-icon {
position: absolute;
top: 50%;
right: 0;
transform: translate(-50%, -50%);
font-size: 110%;
}
.select-input-control {
appearance: none;
width: 100%;
padding: 20px 16px 8px 14px;
border: 1px solid var(--base-color-darker);
border-radius: 8px;
background-color: var(--base-color-light);
height: 52px;
color: var(--base-color-text);
}
.control-label-checkbox {
position: relative;
display: flex;
align-items: center;
padding: 16px 0 16px 32px;
pointer-events: all;
transform: none;
color: var(--base-color-text);
&.disabled {
color: var(--base-color-darker);
cursor: not-allowed;
.checkbox-control-box {
background-color: var(--base-color);
}
}
}
.checkbox-control {
opacity: 0;
width: 0;
height: 0;
&:checked + .checkbox-control-box {
.checkbox-control-tick {
display: block;
}
}
}
.checkbox-control-box {
position: absolute;
top: 14px;
left: 0;
width: 24px;
height: 24px;
border: 1px solid var(--base-color-darker);
border-radius: 2px;
background-color: var(--base-color-light);
.checkbox-control-tick {
position: absolute;
display: none;
border-right: 3px solid var(--base-color-text);
border-bottom: 3px solid var(--base-color-text);
top: 1px;
left: 7px;
width: 8px;
height: 16px;
transform: rotate(45deg);
}
}
.media-input-control {
width: 100%;
text-align: center;
img,
ion-icon {
display: block;
margin: 48px auto 8px auto;
border-radius: 8px;
font-size: 800%;
max-height: 300px;
}
}
.control-label-range {
transform: none !important;
}
.range-control {
margin-top: 24px;
width: 100%;
}
.range-control-value {
margin-top: 22px;
padding-left: 16px;
font-size: 90%;
font-weight: 600;
width: 48px;
text-align: right;
}
}
}
&.has-append .control-item .input-control {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
&.input-active {
.control-item {
.control-label:not(.control-label-checkbox) {
transform: translate(16px, 6px) scale(0.7);
}
}
}
&.control-invalid {
.control-row .control-item {
.invalid-icon {
display: block;
}
.input-control {
border: 2px solid var(--danger-color);
}
}
}
&.small {
&.input-active {
.control-row .control-item .control-label {
transform: translate(16px, 6px) scale(0.7);
}
}
.control-row {
.control-item {
.control-label {
transform: translate(16px, 12px) scale(1);
}
.input-control {
padding: 16px 8px 4px 14px;
}
}
.input-control-append {
.button {
.button-label {
ion-icon {
height: 16px;
width: 16px;
}
}
height: 36px;
padding: 3px 24px 13px 24px;
}
}
}
}
}
@media (prefers-color-scheme: dark) {
.control-group.control-type-input {
.control-row {
.control-item {
.input-control {
&:disabled {
background-color: hsl(0, 0%, 8%);
}
}
}
}
}
}
</style>

View File

@@ -1,73 +0,0 @@
<template>
<div class="flex flex-col flex-items-center justify-center">
<div :class="['spinner', { small: props.small }]"></div>
<div v-if="slots.default" :class="['mt-3', { small: props.small }]">
<slot name="default"></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { useSlots } from "vue";
const slots = useSlots();
const props = defineProps({
small: {
type: Boolean,
default: false,
required: false,
},
});
</script>
<style lang="scss">
.spinner {
width: 12rem;
height: 12rem;
border: 2rem solid transparent;
border-top-color: #00a5f1;
border-bottom-color: rgba(0, 0, 0, 0.1);
border-left-color: rgba(0, 0, 0, 0.1);
border-right-color: rgba(0, 0, 0, 0.1);
border-radius: 50%;
animation: spinner-rotation 8s ease-in-out infinite;
margin-top: 2rem;
margin-bottom: 2rem;
flex-shrink: 0;
&.small {
width: 2rem;
height: 2rem;
border-width: 0.5rem;
margin: 0 1.5rem 0 1.5rem;
}
}
@keyframes spinner-rotation {
0% {
transform: rotate(0deg);
border-top-color: #eb3594;
}
20% {
transform: rotate(360deg);
border-top-color: #00a5f1;
}
40% {
transform: rotate(720deg);
border-top-color: #39b54a;
}
60% {
transform: rotate(1080deg);
border-top-color: #f79e1c;
}
80% {
transform: rotate(1440deg);
border-top-color: #e11e26;
}
100% {
transform: rotate(1800deg);
border-top-color: #eb3594;
}
}
</style>

View File

@@ -1,103 +0,0 @@
<template>
<div class="bg-sky-500 text-white">
<div class="max-w-7xl mx-auto flex flex-col pt-10 px-4">
<div class="pb-12">
<h1 class="text-4xl">{{ title }}</h1>
<router-link
class="sm-masthead-backlink text-sm"
v-if="props.backLink !== null"
:to="props.backLink"
><svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-3">
<path
d="M400-80 0-480l400-400 56 57-343 343 343 343-56 57Z"
fill="currentColor" />
</svg>
{{ props.backTitle }}</router-link
>
<p
class="sm-masthead-info text-sm max-w-lg pt-2 text-sky-2"
v-if="slots.default">
<slot></slot>
</p>
</div>
<div
v-if="tabs().length > 0"
class="block text-right overflow-x-auto whitespace-nowrap scroll-smooth scrollbar-width-none"
style="scrollbar-width: none">
<router-link
:to="tab.to"
v-for="(tab, idx) in tabs()"
:key="idx"
class="inline-block decoration-none !text-sky-1 px-6 py-4 font-bold hover:bg-sky-400 rounded-t-2"
exact-active-class="!bg-gray-1 !text-sky-500"
>{{ tab.title }}</router-link
>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useSlots } from "vue";
import { useRoute } from "vue-router";
const props = defineProps({
title: {
type: String,
required: true,
},
backLink: {
type: Object,
default: () => {
return null;
},
required: false,
},
backTitle: {
type: String,
default: "Back",
required: false,
},
});
const slots = useSlots();
const tabGroups = [
[
{ title: "Contact", to: "/contact" },
{ title: "Code of Conduct", to: "/code-of-conduct" },
{ title: "Rules", to: "/rules" },
{ title: "Terms and Conditions", to: "/terms-and-conditions" },
{ title: "Privacy", to: "/privacy" },
],
[
{ title: "Connect", to: "/minecraft" },
{ title: "Curve Calculator", to: "/minecraft/curve" },
],
];
const route = useRoute();
const tabs = () => {
const currentTabGroup = tabGroups.find((items) =>
items.some((item) => item.to === route.path)
);
return currentTabGroup || [];
};
</script>
<style lang="scss">
.sm-masthead-info a,
.sm-masthead-backlink {
color: rgba(255, 255, 255, 1) !important;
text-decoration: none;
&:hover {
color: rgba(255, 255, 255, 0.5) !important;
}
}
</style>

View File

@@ -1,717 +0,0 @@
<template>
<nav id="navbar" :class="{ 'is-open': isNavVisible }">
<div class="relative">
<div
class="max-w-7xl flex flex-row items-center mx-auto px-4 py-2 gap-2">
<router-link :to="{ name: 'home' }">
<svg
id="navbar-logo"
width="100%"
height="100%"
viewBox="0 0 2762 491"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xml:space="preserve"
xmlns:serif="http://www.serif.com/"
style="
fill-rule: evenodd;
clip-rule: evenodd;
stroke-linecap: round;
stroke-linejoin: round;
stroke-miterlimit: 10;
"
alt="STEMMechanics">
<g>
<g>
<g id="g7146">
<g id="g7154"></g>
<g id="g7158"></g>
<g id="g7162">
<g id="g7164">
<g id="g7188"></g>
<g id="g7192">
<path
id="path7194"
d="M520.706,133.507l0,263.567l32.794,-0l0,-257.505c0,-3.348 -2.714,-6.062 -6.061,-6.062l-26.733,-0Z"
style="
fill: #b00;
fill-rule: nonzero;
" />
</g>
<g id="g7196">
<path
id="path7198"
d="M56.5,139.568l-0,257.506l32.794,0l-0,-263.568l-26.733,0c-3.348,0 -6.061,2.714 -6.061,6.062"
style="
fill: #b00;
fill-rule: nonzero;
" />
</g>
<g id="g7200">
<path
id="path7202"
d="M553.5,216.404l-0,-76.836c-0,-3.348 -2.714,-6.061 -6.061,-6.061l-26.733,0l-0,263.567l32.794,0l-0,-145.67"
style="
fill: none;
fill-rule: nonzero;
stroke: #000;
stroke-width: 15px;
" />
</g>
<g id="g7204">
<path
id="path7206"
d="M89.294,216.404l-0,-82.897l-26.733,0c-3.348,0 -6.061,2.713 -6.061,6.061l-0,257.506l32.794,0l-0,-145.67"
style="
fill: none;
fill-rule: nonzero;
stroke: #000;
stroke-width: 15px;
" />
</g>
<g id="g7208">
<path
id="path7210"
d="M112.715,74.519l55.15,259.463l39.438,-8.383l-55.15,-259.463l-39.438,8.383Z"
style="
fill: #f89d00;
fill-rule: nonzero;
" />
</g>
<g id="g7212">
<path
id="path7214"
d="M112.715,74.519l8.464,39.818l39.437,-8.382l-8.463,-39.819l-39.438,8.383Z"
style="
fill: #d58700;
fill-rule: nonzero;
" />
</g>
<g id="g7216">
<path
id="path7218"
d="M207.304,325.599l-55.151,-259.463l-39.437,8.383l55.15,259.463"
style="
fill: none;
fill-rule: nonzero;
stroke: #000;
stroke-width: 15px;
" />
</g>
<g id="g7220">
<path
id="path7222"
d="M78.998,49.805l-32.558,6.92c-1.252,0.267 -2.484,-0.533 -2.75,-1.786l-1.96,-9.222c-0.291,-1.369 -1.636,-2.242 -3.005,-1.952l-24.19,5.143c-1.369,0.29 -2.242,1.635 -1.951,3.003l11.328,53.293c0.291,1.37 1.636,2.243 3.004,1.952l24.19,-5.142c1.369,-0.291 2.243,-1.636 1.952,-3.005l-1.961,-9.222c-0.266,-1.253 0.534,-2.484 1.786,-2.751l32.558,-6.919l-6.443,-30.312Z"
style="
fill: #b7cee9;
fill-rule: nonzero;
" />
</g>
<g id="g7224">
<path
id="path7226"
d="M78.998,49.805l-32.558,6.92c-1.252,0.267 -2.484,-0.533 -2.75,-1.786l-1.96,-9.222c-0.291,-1.369 -1.636,-2.242 -3.005,-1.952l-24.19,5.143c-1.369,0.29 -2.242,1.635 -1.951,3.003l11.328,53.293c0.291,1.37 1.636,2.243 3.004,1.952l24.19,-5.142c1.369,-0.291 2.243,-1.636 1.952,-3.005l-1.961,-9.222c-0.266,-1.253 0.534,-2.484 1.786,-2.751l32.558,-6.919l-6.443,-30.312Z"
style="
fill: none;
fill-rule: nonzero;
stroke: #000;
stroke-width: 15px;
" />
</g>
<g id="g7228">
<path
id="path7230"
d="M164.359,17.061l-91.655,19.481c-3.116,0.663 -5.105,3.726 -4.443,6.843l9.982,46.963c0.663,3.116 3.726,5.107 6.843,4.443l110.229,-23.429c4.739,-1.008 9.577,1.218 11.896,5.472l8.86,16.253c1.528,2.802 5.038,3.835 7.84,2.308l7.786,-3.384c7.942,-4.329 11.079,-13.3 6.748,-21.242l-14.286,-26.203c-11.658,-21.383 -35.978,-32.568 -59.8,-27.505"
style="
fill: #d7e3f2;
fill-rule: nonzero;
" />
</g>
<g id="g72281" serif:id="g7228">
<path
id="path72301"
serif:id="path7230"
d="M164.359,17.061l-91.655,19.481c-3.116,0.663 -5.105,3.726 -4.443,6.843l9.982,46.963c0.663,3.116 3.726,5.107 6.843,4.443l110.229,-23.429c4.739,-1.008 9.577,1.218 11.896,5.472l8.86,16.253c1.528,2.802 5.038,3.835 7.84,2.308l7.786,-3.384c7.942,-4.329 11.079,-13.3 6.748,-21.242l-14.286,-26.203c-11.658,-21.383 -35.978,-32.568 -59.8,-27.505"
style="
fill: #d7e3f2;
fill-rule: nonzero;
" />
</g>
<g id="g7232">
<path
id="path7234"
d="M102.999,30.103l-30.294,6.439c-3.117,0.663 -5.106,3.726 -4.444,6.843l9.983,46.963c0.662,3.116 3.726,5.107 6.842,4.443l110.229,-23.429c4.739,-1.008 9.578,1.218 11.896,5.472l8.86,16.253c1.528,2.802 5.038,3.835 7.84,2.308l7.786,-3.384c7.943,-4.329 11.079,-13.3 6.749,-21.242l-14.287,-26.203c-11.658,-21.383 -35.977,-32.568 -59.8,-27.505l-26.359,5.603"
style="
fill: none;
fill-rule: nonzero;
stroke: #000;
stroke-width: 15px;
" />
</g>
<g id="g7240"></g>
<g id="g7244">
<path
id="path7246"
d="M553.5,295.445l-497,0l-0,172.631c-0,3.905 3.166,7.07 7.071,7.07l482.858,0c3.905,0 7.071,-3.165 7.071,-7.07l-0,-172.631Z"
style="
fill: #e00000;
fill-rule: nonzero;
" />
</g>
<g id="g7248">
<path
id="path7250"
d="M546.429,444.992l-482.857,0c-3.906,0 -7.072,-3.166 -7.072,-7.071l-0,30.155c-0,3.905 3.166,7.07 7.072,7.07l482.857,0c3.905,0 7.071,-3.165 7.071,-7.07l-0,-30.155c-0,3.905 -3.166,7.071 -7.071,7.071"
style="
fill: #b00;
fill-rule: nonzero;
" />
</g>
<g id="g7252">
<path
id="path7254"
d="M553.5,295.445l-497,0l-0,172.631c-0,3.905 3.166,7.07 7.071,7.07l482.858,0c3.905,0 7.071,-3.165 7.071,-7.07l-0,-172.631Z"
style="
fill: none;
fill-rule: nonzero;
stroke: #000;
stroke-width: 15px;
" />
</g>
<g id="g7256">
<path
id="path7258"
d="M249.87,347.278l-0,33c-0,2.762 2.239,5 5,5l100.261,0c2.761,0 5,-2.238 5,-5l-0,-33c-0,-2.762 -2.239,-5 -5,-5l-100.261,0c-2.761,0 -5,2.238 -5,5"
style="
fill: #b00;
fill-rule: nonzero;
" />
</g>
<g id="g7260">
<path
id="path7262"
d="M249.87,347.278l-0,33c-0,2.762 2.239,5 5,5l100.261,0c2.761,0 5,-2.238 5,-5l-0,-33c-0,-2.762 -2.239,-5 -5,-5l-100.261,0c-2.761,0 -5,2.238 -5,5Z"
style="
fill: none;
fill-rule: nonzero;
stroke: #000;
stroke-width: 15px;
" />
</g>
</g>
<rect
id="path7148"
x="89.294"
y="133.507"
width="431.412"
height="36.366"
style="
fill: #7d8c97;
fill-rule: nonzero;
stroke: #000;
stroke-width: 15px;
" />
</g>
</g>
<g>
<clipPath id="_clip1">
<rect
x="48.391"
y="1"
width="554.191"
height="287.599" />
</clipPath>
<g clip-path="url(#_clip1)">
<clipPath id="_clip2">
<polygon
points="122.46,223.269 389.191,141.565 470.894,408.297 204.163,490 122.46,223.269 " />
</clipPath>
<g clip-path="url(#_clip2)">
<path
d="M401.112,309.199l20.426,-6.257c5.753,-1.762 8.987,-7.856 7.222,-13.609l-7.905,-25.756c-1.764,-5.748 -7.853,-8.979 -13.602,-7.218l-20.445,6.262c-5.67,-9.632 -12.768,-18.137 -20.928,-25.318l10.016,-18.862c2.822,-5.314 0.8,-11.909 -4.515,-14.729l-23.799,-12.627c-5.313,-2.819 -11.904,-0.798 -14.724,4.512l-10.021,18.874c-10.513,-2.725 -21.525,-3.831 -32.67,-3.127l-6.26,-20.436c-1.762,-5.751 -7.852,-8.985 -13.603,-7.223l-25.76,7.89c-5.752,1.762 -8.986,7.852 -7.224,13.604l6.26,20.435c-9.628,5.659 -18.132,12.743 -25.314,20.889l-18.874,-10.022c-5.311,-2.82 -11.903,-0.803 -14.725,4.508l-12.644,23.789c-2.824,5.313 -0.805,11.909 4.509,14.731l18.862,10.016c-2.738,10.52 -3.855,21.541 -3.158,32.697l-20.446,6.262c-5.748,1.761 -8.983,7.848 -7.225,13.598l7.876,25.764c1.76,5.755 7.852,8.992 13.605,7.23l20.427,-6.257c5.665,9.653 12.762,18.178 20.925,25.376l-10.022,18.873c-2.821,5.311 -0.803,11.903 4.507,14.725l23.79,12.645c5.314,2.823 11.909,0.805 14.73,-4.509l10.017,-18.862c10.542,2.744 21.588,3.86 32.768,3.153l6.26,20.436c1.762,5.751 7.852,8.985 13.603,7.223l25.76,-7.89c5.752,-1.762 8.986,-7.852 7.224,-13.603l-6.26,-20.436c9.658,-5.677 18.185,-12.788 25.381,-20.965l18.862,10.016c5.314,2.822 11.909,0.8 14.729,-4.515l12.627,-23.8c2.819,-5.312 0.798,-11.903 -4.513,-14.723l-18.873,-10.022c2.731,-10.535 3.837,-21.572 3.124,-32.742Z"
style="
fill: #ffdb05;
fill-rule: nonzero;
" />
<path
d="M276.901,251.218c35.634,-10.915 73.425,9.154 84.34,44.787c10.915,35.634 -9.153,73.426 -44.787,84.341c-35.633,10.915 -73.425,-9.153 -84.34,-44.787c-10.915,-35.634 9.153,-73.426 44.787,-84.341Z"
style="fill: #b3b6c3" />
<path
d="M283.918,274.128c22.99,-7.042 47.372,5.905 54.414,28.895c7.042,22.989 -5.906,47.371 -28.895,54.413c-22.99,7.042 -47.371,-5.905 -54.413,-28.895c-7.042,-22.989 5.905,-47.371 28.894,-54.413Z"
style="fill: #fff" />
<path
d="M318.049,385.553c-18.636,5.708 -38.38,3.818 -55.594,-5.323c-17.215,-9.142 -29.839,-24.44 -35.548,-43.076c-5.708,-18.636 -3.818,-38.38 5.324,-55.595c14.472,-27.253 44.692,-42.511 75.198,-37.969c2.974,0.443 5.026,3.213 4.584,6.188c-0.444,2.974 -3.214,5.026 -6.189,4.584c-25.952,-3.865 -51.662,9.118 -63.975,32.305c-7.777,14.645 -9.386,31.442 -4.529,47.298c4.856,15.855 15.597,28.87 30.242,36.646c14.645,7.777 31.442,9.386 47.297,4.529c15.855,-4.857 28.87,-15.597 36.646,-30.242c12.783,-24.071 8.534,-53.276 -10.571,-72.676c-2.111,-2.142 -2.084,-5.59 0.059,-7.701c2.142,-2.111 5.59,-2.084 7.701,0.059c10.813,10.98 17.77,24.873 20.121,40.179c2.397,15.612 -0.262,31.258 -7.69,45.247c-9.141,17.214 -24.439,29.838 -43.076,35.547Z"
style="fill-rule: nonzero" />
<path
d="M330.222,261.501c-1.342,0.411 -2.841,0.306 -4.174,-0.411l-0.255,-0.136c-2.656,-1.41 -3.666,-4.707 -2.256,-7.364c1.411,-2.656 4.707,-3.665 7.364,-2.255l0.306,0.164c2.649,1.424 3.641,4.726 2.216,7.375c-0.708,1.315 -1.878,2.221 -3.201,2.627Z"
style="fill-rule: nonzero" />
<path
d="M311.144,362.648c-5.021,1.538 -10.346,2.282 -15.812,2.136c-3.006,-0.08 -5.378,-2.583 -5.298,-5.589c0.081,-3.007 2.583,-5.38 5.589,-5.298c14.58,0.389 27.884,-7.364 34.72,-20.237c4.776,-8.992 5.764,-19.306 2.782,-29.042c-2.983,-9.735 -9.577,-17.726 -18.57,-22.502c-8.993,-4.775 -19.306,-5.763 -29.042,-2.781c-9.736,2.982 -17.727,9.577 -22.502,18.57c-6.772,12.752 -5.815,28.016 2.496,39.837c1.73,2.46 1.138,5.857 -1.322,7.586c-2.46,1.731 -5.856,1.139 -7.586,-1.322c-10.683,-15.194 -11.912,-34.816 -3.206,-51.209c6.139,-11.563 16.413,-20.041 28.931,-23.875c12.517,-3.835 25.777,-2.565 37.34,3.575c11.562,6.14 20.041,16.414 23.875,28.931c3.834,12.518 2.564,25.778 -3.576,37.341c-6.225,11.722 -16.623,20.143 -28.819,23.879Z"
style="fill-rule: nonzero" />
<path
d="M278.051,359.577c-1.325,0.405 -2.803,0.309 -4.126,-0.386l-0.233,-0.123c-2.656,-1.411 -3.666,-4.707 -2.256,-7.364c1.411,-2.656 4.707,-3.666 7.364,-2.256l0.188,0.1c2.663,1.398 3.688,4.689 2.29,7.352c-0.703,1.34 -1.886,2.266 -3.227,2.677Z"
style="fill-rule: nonzero" />
<path
d="M350.408,445.197l-25.761,7.89c-8.613,2.639 -17.767,-2.222 -20.405,-10.835l-5.026,-16.409c-8.725,0.207 -17.424,-0.631 -25.964,-2.499l-8.039,15.139c-2.047,3.854 -5.472,6.68 -9.644,7.958c-0.001,0.001 -0.001,0.001 -0.002,0.001c-4.174,1.278 -8.595,0.853 -12.449,-1.195l-23.79,-12.645c-7.951,-4.226 -10.985,-14.134 -6.761,-22.088l8.047,-15.154c-6.317,-6.026 -11.871,-12.761 -16.576,-20.1l-16.397,5.022c-4.174,1.279 -8.595,0.855 -12.45,-1.194c-3.855,-2.048 -6.681,-5.475 -7.958,-9.65l-7.876,-25.765c-2.632,-8.61 2.23,-17.759 10.838,-20.396l16.421,-5.03c-0.2,-8.7 0.637,-17.376 2.5,-25.89l-15.139,-8.039c-3.854,-2.048 -6.681,-5.474 -7.959,-9.647c-1.278,-4.174 -0.853,-8.594 1.195,-12.449l12.645,-23.79c4.225,-7.952 14.134,-10.985 22.087,-6.761l15.154,8.047c6.009,-6.299 12.724,-11.84 20.039,-16.537l-5.026,-16.407c-2.638,-8.613 2.223,-17.767 10.836,-20.406l25.761,-7.89c8.613,-2.639 17.767,2.222 20.405,10.835l5.026,16.409c8.691,-0.205 17.357,0.624 25.863,2.476l8.047,-15.154c4.224,-7.953 14.131,-10.99 22.085,-6.769l23.8,12.627c3.856,2.046 6.684,5.47 7.962,9.644c1.279,4.173 0.857,8.594 -1.19,12.449l-8.04,15.139c6.312,6.009 11.865,12.728 16.572,20.048l16.421,-5.03c8.608,-2.637 17.761,2.221 20.402,10.827l7.905,25.756c1.281,4.173 0.86,8.595 -1.187,12.451c-2.046,3.856 -5.472,6.684 -9.646,7.963l-16.396,5.022c0.212,8.715 -0.617,17.405 -2.475,25.936l15.154,8.047c7.954,4.224 10.99,14.131 6.769,22.085l-12.626,23.8c-2.046,3.856 -5.471,6.684 -9.644,7.962c-0,0.001 -0.002,0.001 -0.003,0.001c-4.172,1.278 -8.592,0.855 -12.446,-1.192l-15.139,-8.039c-6.027,6.33 -12.766,11.897 -20.11,16.612l5.027,16.408c2.636,8.613 -2.224,17.767 -10.837,20.406Zm-81.581,-33.338c0.94,-0.287 1.962,-0.323 2.965,-0.062c10.156,2.643 20.604,3.648 31.054,2.988c2.515,-0.159 4.812,1.43 5.55,3.84l6.26,20.436c0.879,2.871 3.931,4.492 6.802,3.612l25.76,-7.891c2.871,-0.879 4.492,-3.931 3.612,-6.802l-6.259,-20.435c-0.739,-2.41 0.274,-5.012 2.447,-6.29c9.026,-5.306 17.118,-11.991 24.052,-19.869c1.666,-1.893 4.415,-2.394 6.642,-1.212l18.862,10.016c1.285,0.682 2.759,0.823 4.15,0.397c1.391,-0.426 2.532,-1.369 3.214,-2.654l12.627,-23.8c1.407,-2.651 0.395,-5.953 -2.256,-7.361l-18.874,-10.023c-2.225,-1.181 -3.349,-3.736 -2.717,-6.175c2.631,-10.149 3.627,-20.588 2.96,-31.029c-0.161,-2.517 1.428,-4.816 3.84,-5.555l20.426,-6.256c1.392,-0.427 2.533,-1.369 3.215,-2.654c0.683,-1.286 0.823,-2.76 0.396,-4.15l-7.905,-25.757c-0.88,-2.869 -3.931,-4.487 -6.801,-3.608l-20.445,6.262c-2.408,0.738 -5.009,-0.273 -6.288,-2.444c-5.301,-9.004 -11.973,-17.076 -19.834,-23.993c-1.893,-1.667 -2.394,-4.415 -1.212,-6.642l10.016,-18.862c0.683,-1.285 0.823,-2.759 0.397,-4.15c-0.426,-1.391 -1.369,-2.532 -2.654,-3.214l-23.799,-12.627c-2.652,-1.407 -5.954,-0.396 -7.362,2.256l-10.022,18.875c-1.181,2.225 -3.736,3.35 -6.176,2.717c-10.125,-2.625 -20.541,-3.622 -30.96,-2.964c-2.516,0.159 -4.812,-1.429 -5.551,-3.84l-6.26,-20.436c-0.879,-2.871 -3.931,-4.491 -6.802,-3.612l-25.76,7.891c-2.871,0.88 -4.491,3.931 -3.612,6.802l6.26,20.436c0.738,2.41 -0.274,5.012 -2.448,6.29c-8.999,5.29 -17.071,11.95 -23.989,19.795c-1.667,1.89 -4.413,2.389 -6.638,1.208l-18.873,-10.022c-2.652,-1.408 -5.954,-0.397 -7.363,2.253l-12.645,23.791c-0.683,1.285 -0.824,2.758 -0.398,4.149c0.426,1.391 1.368,2.534 2.653,3.216l18.862,10.016c2.227,1.183 3.351,3.74 2.716,6.181c-2.638,10.134 -3.645,20.558 -2.993,30.986c0.157,2.514 -1.431,4.808 -3.841,5.546l-20.443,6.262c-2.869,0.879 -4.49,3.929 -3.613,6.8l7.877,25.764c0.426,1.391 1.367,2.534 2.652,3.216c1.285,0.683 2.759,0.824 4.15,0.398l20.427,-6.257c2.411,-0.738 5.014,0.276 6.291,2.451c5.295,9.023 11.968,17.114 19.831,24.048c1.89,1.667 2.39,4.413 1.208,6.638l-10.022,18.874c-1.408,2.651 -0.397,5.954 2.253,7.362l23.791,12.645c1.285,0.683 2.758,0.824 4.149,0.398c0,-0 0.001,-0 0.001,-0c1.391,-0.426 2.532,-1.368 3.215,-2.653l10.016,-18.862c0.696,-1.312 1.87,-2.241 3.216,-2.654Z"
style="fill-rule: nonzero" />
</g>
<path
d="M331.028,101.547l46.069,15.854c-0,-0 31.781,-6.692 34.148,-47.156c2.41,-40.449 -35.739,-58.435 -35.739,-58.435c-0.711,18.016 -5.801,18.692 -20.98,31.049c-15.178,12.356 -33.75,37.677 -23.498,58.688Z"
style="
fill: #7c5748;
fill-rule: nonzero;
" />
<path
d="M359.686,111.409l17.412,5.992c0,0 31.782,-6.691 34.148,-47.156c2.411,-40.449 -35.738,-58.434 -35.738,-58.434c32.273,36.893 -5.32,85.875 -15.822,99.598Z"
style="
fill: #5f4c44;
fill-rule: nonzero;
" />
<path
d="M407.419,88.985c1.997,-5.238 3.393,-11.41 3.827,-18.74c2.41,-40.449 -35.739,-58.434 -35.739,-58.434c-0.712,18.015 -5.801,18.691 -20.98,31.048c-2.503,2.052 -5.126,4.452 -7.65,7.129c2.17,2.446 3.834,6.078 3.708,11.522c0,0 14.139,-2.418 26.644,1.885c2.997,1.032 5.203,2.665 7.143,4.644c6.184,6.304 9.196,16.179 23.047,20.946Z"
style="
fill: #00a5f1;
fill-rule: nonzero;
" />
<path
d="M407.419,88.985c1.997,-5.238 3.393,-11.41 3.827,-18.74c2.41,-40.449 -35.739,-58.434 -35.739,-58.434c15.513,17.722 14.878,38.192 8.865,56.228c6.184,6.304 9.196,16.179 23.047,20.946Z"
style="
fill: #0094d8;
fill-rule: nonzero;
" />
<path
d="M321.254,134.516l47.508,16.35c3.765,1.295 7.868,-0.707 9.163,-4.472l6.488,-18.85c1.296,-3.766 -0.706,-7.869 -4.472,-9.164l-47.508,-16.349c-3.765,-1.296 -7.868,0.706 -9.163,4.471l-6.488,18.851c-1.296,3.765 0.706,7.867 4.472,9.163Z"
style="
fill: #c0c9d2;
fill-rule: nonzero;
" />
<path
d="M365.308,121.077l-6.42,18.657c-1.314,3.819 -5.476,5.849 -9.295,4.535l19.072,6.563c3.819,1.314 7.98,-0.716 9.294,-4.535l6.421,-18.657c1.314,-3.818 -0.716,-7.979 -4.535,-9.294l-19.072,-6.563c3.819,1.314 5.849,5.475 4.535,9.294Z"
style="
fill: #a6aeba;
fill-rule: nonzero;
" />
<path
d="M324.347,135.58c-11.322,7.729 -23.596,27.224 -38.115,69.412c-25.168,73.133 -35.058,150.447 -16.621,156.791c18.437,6.345 58.219,-60.681 83.387,-133.814c14.518,-42.188 16.842,-65.108 12.674,-78.168"
style="
fill: #f89e00;
fill-rule: nonzero;
" />
<path
d="M336.293,222.22c14.518,-42.187 19.332,-64.251 19.042,-75.976l10.336,3.557c4.169,13.06 1.845,35.979 -12.674,78.168c-25.168,73.133 -64.949,140.158 -83.387,133.814c9.214,3.17 41.515,-66.43 66.683,-139.563Z"
style="
fill: #d38302;
fill-rule: nonzero;
" />
<path
d="M268.259,365.699c7.978,2.746 17.565,-3.002 29.311,-17.572c9.288,-11.519 19.504,-28.127 29.666,-48.198c1.039,-2.053 0.203,-4.563 -1.858,-5.584c-2.039,-1.01 -4.511,-0.181 -5.539,1.851c-24.764,48.965 -42.73,63.785 -48.883,61.667c-4.802,-1.652 -9.061,-15.466 -5.268,-48.963c3.376,-29.812 12.292,-67.194 24.461,-102.559c12.241,-35.571 23.68,-57.245 34.909,-66.134l37.205,12.804c3.381,13.915 -0.94,38.038 -13.182,73.611c-6.071,17.64 -13.273,35.637 -20.923,52.308c-0.947,2.063 -0.045,4.504 2.011,5.467l0.001,0c2.086,0.978 4.566,0.064 5.528,-2.031c7.76,-16.91 15.063,-35.162 21.219,-53.047c11.784,-34.244 16.388,-58.069 14.209,-73.928c4.703,-0.013 9.103,-2.957 10.718,-7.648l6.487,-18.852c1.289,-3.745 0.493,-7.707 -1.76,-10.633c2.175,-1.078 4.612,-2.487 7.124,-4.31c1.975,-1.432 2.296,-4.251 0.69,-6.087l-0.001,-0c-1.42,-1.623 -3.833,-1.867 -5.58,-0.603c-4.971,3.597 -9.556,5.235 -11.518,5.826l-43.209,-14.869c-5.449,-13.415 2.155,-29.18 11.956,-41.016c0.302,1.235 0.446,2.589 0.409,4.068c-0.018,0.708 0.09,1.422 0.406,2.055c0.858,1.719 2.675,2.571 4.43,2.271c0.131,-0.023 13.226,-2.195 24.597,1.719c4.422,1.521 6.85,4.886 9.924,9.144c3.522,4.882 7.796,10.799 16.226,14.799c-0.487,1.023 -1.008,2.019 -1.562,2.987c-1.058,1.851 -0.504,4.206 1.246,5.424l0.003,0.002c1.999,1.39 4.759,0.766 5.968,-1.348c4.508,-7.88 7.105,-17.207 7.732,-27.794c1.046,-17.73 -5.222,-34.16 -18.13,-47.513c-9.647,-9.982 -19.558,-14.717 -19.974,-14.914c-1.143,-0.538 -2.461,-0.524 -3.585,0.024c-0.112,0.055 -0.223,0.115 -0.332,0.181c-1.188,0.72 -1.936,1.988 -1.992,3.377c-0.529,13.196 -2.91,15.039 -12.929,22.801c-1.925,1.491 -4.106,3.181 -6.546,5.17c-9.257,7.544 -17.414,17.555 -22.381,27.468c-5.619,11.215 -7.016,21.958 -4.146,31.399c-0.096,0.044 -0.195,0.079 -0.291,0.125c-2.726,1.33 -4.77,3.642 -5.757,6.51l-6.488,18.851c-1.614,4.692 0.043,9.722 3.743,12.625c-11.479,11.161 -22.51,32.773 -34.294,67.016c-12.357,35.912 -21.417,73.936 -24.859,104.322c-3.904,34.496 -0.269,53.919 10.808,57.731Zm88.902,-319.624c2.36,-1.924 4.499,-3.58 6.386,-5.042c9.052,-7.013 13.907,-10.773 15.56,-22.321c9.841,6.455 29.701,23.083 28.033,51.325c-0.283,4.8 -1.019,9.281 -2.195,13.428c-6.063,-3.044 -9.17,-7.346 -12.426,-11.858c-3.461,-4.796 -7.039,-9.755 -13.947,-12.133c-9.004,-3.098 -18.626,-2.998 -24.131,-2.572c-0.373,-2.353 -1.104,-4.505 -2.184,-6.446c1.651,-1.604 3.301,-3.074 4.904,-4.381Zm-36.46,80.626l6.487,-18.852c0.267,-0.775 0.818,-1.399 1.555,-1.758c0.736,-0.36 1.567,-0.411 2.343,-0.143l47.508,16.348c1.6,0.551 2.453,2.3 1.903,3.899l-6.488,18.851c-0.275,0.8 -0.849,1.413 -1.555,1.758c-0.706,0.344 -1.543,0.42 -2.343,0.144l-47.509,-16.35c-1.599,-0.55 -2.452,-2.298 -1.901,-3.897Z"
style="fill-rule: nonzero" />
<clipPath id="_clip3">
<polygon
points="338.743,110.391 329.8,355.224 84.967,346.281 93.91,101.448 338.743,110.391 " />
</clipPath>
<g clip-path="url(#_clip3)">
<path
d="M199.991,236.632l21.148,-19.657c2.063,-1.919 2.181,-5.148 0.263,-7.213l-67.366,-72.473l0.295,-8.09c0.071,-1.93 -0.954,-3.735 -2.649,-4.663l-40.059,-21.893c-1.928,-1.052 -4.313,-0.752 -5.921,0.744l-10.574,9.828c-1.61,1.495 -2.082,3.852 -1.174,5.851l18.912,41.551c0.802,1.757 2.527,2.911 4.458,2.982l8.089,0.295l67.365,72.475c1.919,2.064 5.148,2.182 7.213,0.263Z"
style="
fill: #cfd8dc;
fill-rule: nonzero;
" />
<path
d="M315.572,345.996l6.207,-5.769c11.646,-11.351 12.322,-29.849 1.535,-42.019l-61.852,-66.563c-0.923,-0.99 -2.203,-1.574 -3.557,-1.621c-8.451,-0.309 -15.051,-7.41 -14.743,-15.861c0.052,-1.354 -0.436,-2.672 -1.357,-3.666l-9.829,-10.573c-1.919,-2.064 -5.147,-2.182 -7.212,-0.264l-42.296,39.315c-2.064,1.919 -2.182,5.147 -0.263,7.212l9.828,10.574c0.92,0.992 2.195,1.579 3.547,1.631c8.451,0.309 15.051,7.41 14.743,15.861c-0.052,1.354 0.436,2.673 1.357,3.666l61.862,66.542c11.183,12.019 29.99,12.706 42.02,1.535l0.01,0Z"
style="
fill: #ff02ad;
fill-rule: nonzero;
" />
<g>
<path
d="M241.858,249.868c0.098,-2.817 2.461,-5.022 5.279,-4.924c1.358,0.047 2.641,0.634 3.565,1.631l58.972,63.443c1.955,2.032 1.893,5.262 -0.138,7.218c-2.031,1.954 -5.262,1.892 -7.217,-0.139c-0.041,-0.042 -0.081,-0.085 -0.121,-0.129l-58.972,-63.444c-0.922,-0.99 -1.414,-2.304 -1.368,-3.656Z"
style="
fill: #c62828;
fill-rule: nonzero;
" />
<path
d="M225.997,264.611c0.098,-2.817 2.461,-5.022 5.279,-4.924c1.358,0.047 2.641,0.634 3.565,1.631l58.972,63.444c1.955,2.031 1.893,5.262 -0.138,7.217c-2.031,1.955 -5.262,1.892 -7.217,-0.139c-0.041,-0.042 -0.081,-0.085 -0.121,-0.129l-58.972,-63.444c-0.922,-0.99 -1.414,-2.304 -1.368,-3.656Z"
style="
fill: #c62828;
fill-rule: nonzero;
" />
</g>
<path
d="M180.837,242.532c0.047,-1.354 0.63,-2.634 1.621,-3.557l42.296,-39.315c2.064,-1.918 5.293,-1.8 7.212,0.264l9.829,10.574c0.924,0.991 1.416,2.31 1.367,3.666c-0.308,8.451 6.292,15.552 14.743,15.86c1.354,0.048 2.634,0.631 3.557,1.622l61.852,66.552c10.627,12.227 9.956,30.599 -1.535,42.019l-6.217,5.779c-12.03,11.171 -30.837,10.484 -42.02,-1.535l-61.861,-66.552c-0.919,-0.991 -1.407,-2.305 -1.358,-3.656c0.308,-8.451 -6.292,-15.552 -14.743,-15.861c-1.354,-0.047 -2.634,-0.63 -3.557,-1.621l-9.829,-10.574c-0.921,-0.993 -1.409,-2.312 -1.357,-3.665Zm47.133,-31.917l-34.821,32.366l5.013,5.393c12.293,1.528 21.714,11.663 22.341,24.035l60.515,65.104c7.345,7.891 19.693,8.342 27.595,1.008l6.217,-5.78c7.536,-7.503 7.976,-19.561 1.008,-27.594l-60.515,-65.104c-12.293,-1.527 -21.714,-11.663 -22.341,-24.035l-5.012,-5.393Z"
style="fill-rule: nonzero" />
<path
d="M93.496,116.762c0.047,-1.354 0.63,-2.633 1.622,-3.557l10.573,-9.829c1.608,-1.496 3.993,-1.795 5.921,-0.743l40.06,21.893c1.702,0.927 2.732,2.737 2.659,4.673l-0.295,8.09l67.366,72.474c1.883,2.097 1.709,5.324 -0.389,7.208c-2.046,1.836 -5.18,1.722 -7.086,-0.259l-68.802,-74.018c-0.924,-0.992 -1.416,-2.311 -1.367,-3.666l0.257,-7.049l-34.051,-18.61l-5.181,4.816l16.076,35.318l7.039,0.257c1.353,0.047 2.633,0.63 3.557,1.622l68.8,74.017c1.883,2.098 1.709,5.325 -0.389,7.208c-2.046,1.837 -5.18,1.722 -7.086,-0.259l-67.368,-72.453l-8.089,-0.295c-1.93,-0.071 -3.656,-1.225 -4.457,-2.982l-18.912,-41.551c-0.33,-0.722 -0.487,-1.511 -0.458,-2.305Z"
style="fill-rule: nonzero" />
<path
d="M241.858,249.868c0.098,-2.817 2.461,-5.022 5.279,-4.924c1.358,0.047 2.641,0.634 3.565,1.631l58.972,63.443c1.955,2.032 1.893,5.262 -0.138,7.218c-2.031,1.954 -5.262,1.892 -7.217,-0.139c-0.041,-0.042 -0.081,-0.085 -0.121,-0.129l-58.972,-63.444c-0.922,-0.99 -1.414,-2.304 -1.368,-3.656Z"
style="fill-rule: nonzero" />
<path
d="M225.997,264.611c0.098,-2.817 2.461,-5.022 5.279,-4.924c1.358,0.047 2.641,0.634 3.565,1.631l58.972,63.444c1.955,2.031 1.893,5.262 -0.138,7.217c-2.031,1.955 -5.262,1.892 -7.217,-0.139c-0.041,-0.042 -0.081,-0.085 -0.121,-0.129l-58.972,-63.444c-0.922,-0.99 -1.414,-2.304 -1.368,-3.656Z"
style="fill-rule: nonzero" />
</g>
<g>
<g>
<g>
<g>
<path
d="M562.573,120.232l-87.881,207.058c-10.861,25.591 -40.411,37.532 -66.002,26.671c-25.591,-10.862 -37.532,-40.412 -26.671,-66.003l87.881,-207.058l92.673,39.332Z"
style="
fill: #dfe9f4;
fill-rule: nonzero;
" />
</g>
<g>
<path
d="M419.861,198.798l-37.842,89.16c-10.861,25.591 1.08,55.141 26.671,66.002c25.59,10.861 55.14,-1.079 66.002,-26.67l53.493,-126.037l-108.324,-2.455Z"
style="
fill: #24c100;
fill-rule: nonzero;
" />
</g>
<g>
<path
d="M528.18,201.254l-53.499,126.05c-10.864,25.596 -40.385,37.525 -65.981,26.662c-16.865,-7.158 -27.793,-22.426 -30.186,-39.277c4.202,3.899 9.15,7.192 14.756,9.571c25.596,10.864 55.163,-1.045 66.027,-26.642l41.172,-97.008l27.711,0.644Z"
style="
fill: #21af00;
fill-rule: nonzero;
" />
</g>
<g>
<path
d="M578.717,120.131l12.276,-28.925c1.381,-3.254 -0.137,-7.011 -3.391,-8.392l-108.177,-45.912c-3.253,-1.381 -7.01,0.137 -8.391,3.39l-12.277,28.926c-1.381,3.254 0.137,7.011 3.391,8.392l108.177,45.913c3.253,1.38 7.01,-0.138 8.392,-3.392Z"
style="
fill: #2dcef6;
fill-rule: nonzero;
" />
</g>
<g>
<path
d="M590.997,91.198l-12.288,28.95c-1.365,3.217 -5.116,4.771 -8.378,3.386l-108.175,-45.912c-3.263,-1.385 -4.751,-5.162 -3.385,-8.378l3.12,-7.353l100.179,42.518c3.263,1.385 7.013,-0.169 8.398,-3.431l9.147,-21.552l7.996,3.393c3.263,1.384 4.77,5.116 3.386,8.379Z"
style="
fill: #1ec5e0;
fill-rule: nonzero;
" />
</g>
<g>
<path
d="M523.331,129.908c7.056,2.995 10.353,11.155 7.359,18.21c-2.995,7.056 -11.155,10.353 -18.21,7.359c-7.056,-2.995 -10.353,-11.155 -7.359,-18.21c2.995,-7.056 11.155,-10.353 18.21,-7.359Z"
style="
fill: #21af00;
" />
<path
d="M470.558,233.05c7.624,3.236 11.187,12.053 7.951,19.677c-3.235,7.624 -12.052,11.186 -19.676,7.951c-7.624,-3.236 -11.187,-12.053 -7.951,-19.677c3.236,-7.624 12.053,-11.186 19.676,-7.951Z"
style="
fill: #dfe9f4;
" />
<path
d="M428.819,292.984c6.059,2.571 8.891,9.579 6.319,15.638c-2.571,6.059 -9.578,8.89 -15.638,6.318c-6.059,-2.571 -8.89,-9.578 -6.318,-15.637c2.571,-6.059 9.578,-8.891 15.637,-6.319Z"
style="
fill: #dfe9f4;
" />
<g>
<path
d="M473.695,150.963c5.26,2.232 7.718,8.315 5.485,13.576c-2.232,5.26 -8.315,7.718 -13.575,5.485c-5.261,-2.232 -7.719,-8.315 -5.486,-13.576c2.232,-5.26 8.316,-7.718 13.576,-5.485Z"
style="
fill: #21af00;
" />
<g>
<path
d="M589.555,78.219l-18.628,-7.906c-2.54,-1.078 -5.468,0.105 -6.546,2.645c-1.078,2.539 0.105,5.467 2.645,6.545l18.628,7.907c0.714,0.302 1.045,1.132 0.742,1.845l-12.277,28.927c-0.303,0.714 -1.128,1.047 -1.841,0.744c-48.948,-20.774 -58.962,-25.024 -108.18,-45.913c-0.713,-0.303 -1.051,-1.13 -0.748,-1.843l12.278,-28.928c0.302,-0.713 1.133,-1.049 1.847,-0.746l72.345,30.705c2.54,1.078 5.468,-0.105 6.546,-2.645c1.078,-2.54 -0.105,-5.468 -2.645,-6.546l-72.345,-30.705c-5.785,-2.455 -12.484,0.252 -14.939,6.036l-12.277,28.928c-2.454,5.78 0.253,12.479 6.038,14.934l3.159,1.341l-11.156,26.284c-1.078,2.54 0.106,5.468 2.645,6.546c2.54,1.078 5.468,-0.105 6.546,-2.645l11.156,-26.284l83.479,35.43l-31.114,73.309l-36.489,-0.826c-2.754,-0.062 -5.041,2.119 -5.1,4.879c-0.067,2.753 2.097,5.042 4.879,5.101l32.511,0.738l-50.618,119.264c-9.769,23.017 -36.438,33.794 -59.455,24.025c-23.017,-9.769 -33.794,-36.438 -24.025,-59.455l36.518,-86.042l44.574,1.007c2.754,0.062 5.041,-2.119 5.1,-4.879c0.067,-2.752 -2.103,-5.039 -4.879,-5.1l-40.596,-0.92l25.271,-59.541c1.078,-2.54 -0.105,-5.468 -2.645,-6.546c-2.54,-1.077 -5.468,0.106 -6.546,2.645l-65.987,155.475c-11.919,28.084 1.231,60.627 29.315,72.546c28.083,11.92 60.627,-1.23 72.546,-29.314c19.018,-44.809 66.682,-157.112 85.931,-202.465l3.159,1.341c5.78,2.453 12.479,-0.253 14.933,-6.034l12.277,-28.927c2.455,-5.785 -0.252,-12.484 -6.032,-14.937Z"
style="
fill: #003;
fill-rule: nonzero;
" />
<path
d="M510.531,160.069c9.585,4.068 20.688,-0.419 24.757,-10.004c4.066,-9.581 -0.42,-20.685 -10.006,-24.753c-9.585,-4.068 -20.689,0.418 -24.755,10c-4.069,9.586 0.418,20.688 10.004,24.757Zm10.85,-25.566c4.515,1.916 6.632,7.147 4.716,11.662c-1.916,4.515 -7.151,6.63 -11.666,4.714c-4.514,-1.916 -6.63,-7.152 -4.714,-11.666c1.916,-4.515 7.15,-6.626 11.664,-4.71Z"
style="
fill: #003;
fill-rule: nonzero;
" />
<path
d="M483.107,254.679c4.309,-10.151 -0.447,-21.919 -10.598,-26.228c-10.15,-4.308 -21.915,0.449 -26.223,10.6c-4.308,10.151 0.444,21.913 10.596,26.222c10.151,4.308 21.917,-0.443 26.225,-10.594Zm-22.325,1.403c-5.084,-2.158 -7.463,-8.045 -5.305,-13.13c2.158,-5.085 8.047,-7.468 13.132,-5.31c5.084,2.158 7.465,8.052 5.308,13.136c-2.158,5.084 -8.05,7.462 -13.135,5.304Z"
style="
fill: #003;
fill-rule: nonzero;
" />
<path
d="M430.768,288.391c-8.585,-3.644 -18.538,0.378 -22.181,8.963c-3.644,8.585 0.378,18.538 8.963,22.182c8.585,3.643 18.537,-0.378 22.181,-8.963c3.644,-8.585 -0.378,-18.538 -8.963,-22.182Zm-9.318,21.954c-3.518,-1.493 -5.166,-5.571 -3.673,-9.09c1.494,-3.519 5.572,-5.167 9.09,-3.673c3.519,1.493 5.167,5.571 3.673,9.09c-1.493,3.518 -5.571,5.166 -9.09,3.673Z"
style="
fill: #003;
fill-rule: nonzero;
" />
<path
d="M483.773,166.486c3.305,-7.786 -0.343,-16.812 -8.128,-20.116c-7.791,-3.307 -16.817,0.341 -20.121,8.126c-3.305,7.786 0.342,16.812 8.133,20.119c7.786,3.304 16.812,-0.343 20.116,-8.129Zm-19.059,-8.089c1.155,-2.719 4.306,-3.993 7.03,-2.836c2.719,1.154 3.993,4.305 2.838,7.024c-1.154,2.72 -4.305,3.993 -7.024,2.839c-2.725,-1.156 -3.998,-4.307 -2.844,-7.027Z"
style="
fill: #003;
fill-rule: nonzero;
" />
</g>
</g>
</g>
</g>
</g>
</g>
</g>
</g>
</g>
<g>
<g>
<path
d="M1338.77,177.49l-14.203,208.687l45.422,0.422l7.453,-101.11l23.063,94.782c5.531,6.187 11.859,7.5 18.984,3.937l43.031,-97.875l-5.765,99.844l45.703,-0.422l12.797,-208.547c-12.563,-3.281 -25.594,-3.187 -39.094,0.282l-57.797,118.406l-32.766,-118.125c-19.5,-5.344 -35.109,-5.438 -46.828,-0.281Z"
style="
fill: #00a5f1;
fill-rule: nonzero;
" />
<path
d="M1536.29,178.474c-3.375,1.594 -5.578,4.219 -6.609,7.875l-12.657,192.797c0.938,5.156 3.422,8.109 7.454,8.859l131.484,-5.203c9.938,-14.531 11.016,-30.094 3.234,-46.687l-94.359,2.812l3.797,-43.594l76.922,-3.234c8.156,-12.188 8.718,-25.125 1.687,-38.813l-75.515,1.547l2.531,-36l91.969,-2.953c6.187,-12.844 6.984,-26.625 2.39,-41.344l-132.328,3.938Z"
style="
fill: #00a5f1;
fill-rule: nonzero;
" />
<path
d="M1829.72,195.068c3.281,16.875 -1.125,30.375 -13.219,40.5c-8.156,-5.25 -20.953,-9.422 -38.39,-12.516c-15.282,-1.5 -27.75,2.93 -37.407,13.289c-9.656,10.359 -15.562,23.789 -17.718,40.289c-3.188,10.406 -1.219,23.766 5.906,40.078c7.125,14.813 17.976,22.641 32.554,23.485c14.579,0.843 32.04,-2.016 52.383,-8.578c7.313,14.437 8.016,28.593 2.11,42.468c-24.094,11.063 -43.641,16.313 -58.641,15.75c-27.656,-3.469 -47.812,-13.5 -60.469,-30.094c-16.875,-22.218 -22.593,-53.578 -17.156,-94.078c4.313,-32.062 14.484,-56.297 30.516,-72.703c20.906,-16.969 46.265,-23.906 76.078,-20.812c20.719,1.312 35.203,8.953 43.453,22.922Z"
style="
fill: #00a5f1;
fill-rule: nonzero;
" />
<path
d="M1845.63,183.958l-12.093,197.578c10.312,7.407 25.546,8.532 45.703,3.375l5.062,-85.781l65.953,-2.812l-4.078,85.922c12.938,7.968 28.078,7.593 45.422,-1.125l9.563,-200.11c-13.313,-7.219 -27.188,-7.312 -41.625,-0.281l-5.344,73.266l-66.797,3.375l4.078,-78.61c-16.875,-6.469 -32.156,-4.734 -45.844,5.203Z"
style="
fill: #00a5f1;
fill-rule: nonzero;
" />
<path
d="M2073.74,175.099l-73.688,205.453c10.219,8.719 25.782,11.672 46.688,8.859l12.938,-37.968l52.171,-6.469l10.125,37.687c20.532,3.938 37.219,1.688 50.063,-6.75l-56.25,-202.5c-13.219,-8.156 -27.235,-7.593 -42.047,1.688Zm30.234,130.5l-34.312,4.078l22.078,-69.187l12.234,65.109Z"
style="
fill: #00a5f1;
fill-rule: nonzero;
" />
<path
d="M2185.9,183.958l-12.093,197.578c10.312,7.407 25.547,8.532 45.703,3.375l7.875,-115.031l62.859,114.75c16.5,6.563 29.532,5.531 39.094,-3.094l12.094,-200.531c-13.313,-7.219 -27.188,-7.312 -41.625,-0.281l-6.75,110.812l-66.938,-113.484c-20.156,-5.25 -33.562,-3.281 -40.219,5.906Z"
style="
fill: #00a5f1;
fill-rule: nonzero;
" />
<path
d="M2340.33,383.083c13.218,6.844 29.39,7.547 48.515,2.11l16.594,-206.86c-16.5,-4.781 -31.875,-5.484 -46.125,-2.109l-18.984,206.859Z"
style="
fill: #00a5f1;
fill-rule: nonzero;
" />
<path
d="M2562.53,195.068c3.281,16.875 -1.125,30.375 -13.219,40.5c-8.156,-5.25 -20.953,-9.422 -38.39,-12.516c-15.282,-1.5 -27.75,2.93 -37.407,13.289c-9.656,10.359 -15.562,23.789 -17.718,40.289c-3.188,10.406 -1.219,23.766 5.906,40.078c7.125,14.813 17.976,22.641 32.555,23.485c14.578,0.843 32.039,-2.016 52.382,-8.578c7.313,14.437 8.016,28.593 2.11,42.468c-24.094,11.063 -43.641,16.313 -58.641,15.75c-27.656,-3.469 -47.812,-13.5 -60.469,-30.094c-16.875,-22.218 -22.593,-53.578 -17.156,-94.078c4.313,-32.062 14.484,-56.297 30.516,-72.703c20.906,-16.969 46.265,-23.906 76.078,-20.812c20.719,1.312 35.203,8.953 43.453,22.922Z"
style="
fill: #00a5f1;
fill-rule: nonzero;
" />
<path
d="M2572.61,363.536c-6.093,-21.281 -2.531,-37.781 10.688,-49.5c24.094,14.157 41.578,22.875 52.453,26.157c20.25,-0.188 31.359,-5.25 33.328,-15.188c0.469,-7.969 -5.719,-15.656 -18.562,-23.062l-36.141,-17.578c-22.594,-12 -34.313,-28.922 -35.156,-50.766c0.843,-12.563 5.179,-23.859 13.008,-33.891c7.828,-10.031 16.64,-16.617 26.437,-19.758c9.797,-3.14 21.445,-4.289 34.945,-3.445c24.75,1.5 46.453,8.531 65.11,21.094c0.75,17.719 -4.782,33.141 -16.594,46.266c-22.781,-17.063 -41.016,-25.032 -54.703,-23.907c-12.844,-1.5 -19.641,3.094 -20.391,13.782c-0.469,6.093 8.578,13.968 27.141,23.625c21.281,8.625 37.055,18.515 47.32,29.671c10.266,11.157 14.836,24.704 13.711,40.641c-1.5,21.563 -11.531,38.109 -30.094,49.641c-11.812,8.25 -28.875,11.671 -51.187,10.265c-21.281,-2.719 -41.719,-10.734 -61.313,-24.047Z"
style="
fill: #00a5f1;
fill-rule: nonzero;
" />
</g>
<g>
<path
d="M644.827,363.536c-6.094,-21.281 -2.531,-37.781 10.688,-49.5c24.093,14.157 41.578,22.875 52.453,26.157c20.25,-0.188 31.359,-5.25 33.328,-15.188c0.469,-7.969 -5.719,-15.656 -18.563,-23.062l-36.14,-17.578c-22.594,-12 -34.313,-28.922 -35.157,-50.766c0.844,-12.563 5.18,-23.859 13.008,-33.891c7.828,-10.031 16.641,-16.617 26.438,-19.758c9.797,-3.14 21.445,-4.289 34.945,-3.445c24.75,1.5 46.453,8.531 65.109,21.094c0.75,17.719 -4.781,33.141 -16.593,46.266c-22.782,-17.063 -41.016,-25.032 -54.704,-23.907c-12.843,-1.5 -19.64,3.094 -20.39,13.782c-0.469,6.093 8.578,13.968 27.14,23.625c21.282,8.625 37.055,18.515 47.321,29.671c10.265,11.157 14.836,24.704 13.711,40.641c-1.5,21.563 -11.532,38.109 -30.094,49.641c-11.813,8.25 -28.875,11.671 -51.188,10.265c-21.281,-2.719 -41.718,-10.734 -61.312,-24.047Z"
fill="currentColor"
style="fill-rule: nonzero" />
<path
d="M805.297,178.896c-9.563,12.281 -9.141,27.187 1.266,44.719l51.047,-1.125l-9.141,161.437c16.969,6.281 32.578,6.375 46.828,0.281l7.594,-163.547l51.89,-0.703c7.219,-15.094 7.594,-30.14 1.125,-45.14l-150.609,4.078Z"
fill="currentColor"
style="fill-rule: nonzero" />
<path
d="M983.767,178.474c-3.375,1.594 -5.578,4.219 -6.609,7.875l-12.657,192.797c0.938,5.156 3.422,8.109 7.454,8.859l131.484,-5.203c9.937,-14.531 11.016,-30.094 3.234,-46.687l-94.359,2.812l3.797,-43.594l76.922,-3.234c8.156,-12.188 8.718,-25.125 1.687,-38.813l-75.515,1.547l2.531,-36l91.969,-2.953c6.187,-12.844 6.984,-26.625 2.39,-41.344l-132.328,3.938Z"
fill="currentColor"
style="fill-rule: nonzero" />
<path
d="M1136.08,177.49l-14.203,208.687l45.421,0.422l7.454,-101.11l23.062,94.782c5.531,6.187 11.86,7.5 18.985,3.937l43.031,-97.875l-5.766,99.844l45.703,-0.422l12.797,-208.547c-12.562,-3.281 -25.594,-3.187 -39.094,0.282l-57.796,118.406l-32.766,-118.125c-19.5,-5.344 -35.109,-5.438 -46.828,-0.281Z"
fill="currentColor"
style="fill-rule: nonzero" />
</g>
</g>
</g>
</svg>
</router-link>
<div class="flex flex-items-center flex-justify-end w-full">
<router-link
:to="{ name: 'workshops' }"
role="button"
class="font-medium px-6 py-1.5 rounded-md hover:shadow-md transition text-sm bg-sky-600 hover:bg-sky-500 text-white hidden sm:block">
Find Workshops
</router-link>
<button
type="button"
title="Toggle nav"
id="navbar-toggle"
class="leading-0 ml-4 mr-2 bg-transparent cursor-pointer"
@click.stop="toggleNav">
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6"
viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M3 5a1 1 0 0 1 1-1h12a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1zm0 5a1 1 0 0 1 1-1h12a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1zm0 5a1 1 0 0 1 1-1h12a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1z"
clip-rule="evenodd"
fill="currentColor" />
</svg>
</button>
<!-- <router-link
:to="{ name: 'cart' }"
id="navbar-cart"
class="block cursor-pointer select-none relative"
><svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="w-6 h-6 pointer-events-none">
<path
d="M220-80q-24 0-42-18t-18-42v-520q0-24 18-42t42-18h110v-10q0-63 43.5-106.5T480-880q63 0 106.5 43.5T630-730v10h110q24 0 42 18t18 42v520q0 24-18 42t-42 18H220Zm0-60h520v-520H630v90q0 12.75-8.675 21.375-8.676 8.625-21.5 8.625-12.825 0-21.325-8.625T570-570v-90H390v90q0 12.75-8.675 21.375-8.676 8.625-21.5 8.625-12.825 0-21.325-8.625T330-570v-90H220v520Zm170-580h180v-10q0-38-26-64t-64-26q-38 0-64 26t-26 64v10ZM220-140v-520 520Z"
fill="currentColor" />
</svg>
<div
class="absolute flex items-center flex-justify-center -top-2 -right-4 bg-red text-3 p-1 w-5 h-5 rounded-9 text-white">
14
</div>
</router-link> -->
</div>
</div>
<div
id="navbar-draw"
class="absolute top-full left-0 right-0 px-4 pt-4 pb-6 shadow-xl w-full bg-white dark:bg-dark-9 z-1"
v-show="isNavVisible">
<div class="max-w-7xl mx-auto flex flex-col">
<router-link
:to="{ name: 'blog' }"
class="hover:text-sky-600 hover:bg-gray-1 dark:hover:text-sky-400 text-sm transition-colors px-2.5 py-2 inline-flex items-center">
Blog
</router-link>
<router-link
:to="{ name: 'workshops' }"
class="hover:text-sky-600 hover:bg-gray-1 dark:hover:text-sky-400 text-sm transition-colors px-2.5 py-2 inline-flex items-center">
Workshops
</router-link>
<router-link
:to="{ name: 'community' }"
class="hover:text-sky-600 hover:bg-gray-1 dark:hover:text-sky-400 text-sm transition-colors px-2.5 py-2 inline-flex items-center">
Community
</router-link>
<router-link
:to="{ name: 'contact' }"
class="hover:text-sky-600 hover:bg-gray-1 dark:hover:text-sky-400 text-sm transition-colors px-2.5 py-2 inline-flex items-center">
Contact
</router-link>
<router-link
:to="{ name: 'register' }"
class="hover:text-sky-600 hover:bg-gray-1 dark:hover:text-sky-400 text-sm transition-colors px-2.5 py-2 inline-flex items-center"
v-show="!userStore.id">
Register
</router-link>
<router-link
:to="{ name: 'login' }"
class="hover:text-sky-600 hover:bg-gray-1 dark:hover:text-sky-400 text-sm transition-colors px-2.5 py-2 inline-flex items-center"
v-show="!userStore.id">
Log in
</router-link>
<router-link
:to="{ name: 'dashboard' }"
class="hover:text-sky-600 hover:bg-gray-1 dark:hover:text-sky-400 text-sm transition-colors px-2.5 py-2 inline-flex items-center"
v-show="userStore.id">
Dashboard
</router-link>
<router-link
:to="{ name: 'logout' }"
class="hover:text-sky-600 hover:bg-gray-1 dark:hover:text-sky-400 text-sm transition-colors px-2.5 py-2 inline-flex items-center"
v-show="userStore.id">
Log out
</router-link>
</div>
</div>
</div>
</nav>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue";
import { useUserStore } from "../store/UserStore";
const userStore = useUserStore();
const isNavVisible = ref(false);
const isNavTransparent = ref(false);
/**
* Toggle the navbar visiblity
*/
const toggleNav = () => {
isNavVisible.value = !isNavVisible.value;
};
/**
* Hide the navbar
*/
const hideNav = () => {
isNavVisible.value = false;
};
/**
* Handle user scrolling
*/
const handleScroll = () => {
isNavTransparent.value = window.scrollY >= 60;
};
onMounted(() => {
document.body.addEventListener("click", hideNav);
window.addEventListener("scroll", handleScroll);
});
onUnmounted(() => {
document.body.removeEventListener("click", hideNav);
window.removeEventListener("scroll", handleScroll);
});
</script>
<style lang="scss">
#navbar {
&.is-open {
background-color: #fff;
#navbar-logo,
#navbar-toggle,
#navbar-cart {
color: #000 !important;
}
}
#navbar-logo,
#navbar-toggle,
#navbar-cart {
color: #000;
}
#navbar-draw a {
color: #000;
text-decoration: none;
&:hover {
color: #0284c7;
}
}
}
.page-home {
#navbar {
-webkit-backdrop-filter: blur(4px);
backdrop-filter: blur(4px);
box-shadow: 0 0 4px rgba(0, 0, 0, 0.2);
#navbar-logo,
#navbar-toggle,
#navbar-cart {
color: #fff;
}
}
}
</style>

View File

@@ -1,645 +0,0 @@
<template>
<footer class="bg-dark-800 py-12 mt-36">
<div class="max-w-7xl m-auto px-4">
<div
class="grid gap-10 grid-cols-1 sm:grid-cols-2 md:grid-cols-4 text-sm text-white text-center md:text-left">
<div class="sm:col-span-2 flex items-center">
<p class="mt-4 md:mr-12 text-gray-400">
STEMMechanics Australia acknowledges the Traditional
Owners of Country throughout Australia and the
continuing connection to land, cultures and communities.
We pay our respect to Aboriginal and Torres Strait
Islander cultures; and to Elders both past, present and
emerging.
</p>
</div>
<div>
<span class="font-semibold text-lg text-gray-4"
>Community</span
>
<ul class="mt-4 leading-5 space-y-2">
<li><a href="/community">Our Community</a></li>
<li>
<a
href="https://github.com/STEMMechanics"
target="_blank"
rel="noreferrer">
GitHub
</a>
</li>
<li>
<a
href="https://discord.gg/yNzk4x7mpD"
target="_blank"
rel="noreferrer">
Discord
</a>
</li>
<li>
<a
href="https://facebook.com/stemmechanics"
target="_blank"
rel="noreferrer">
Facebook
</a>
</li>
<li>
<router-link :to="{ name: 'minecraft' }">
Minecraft
</router-link>
</li>
<li>
<a
href="https://twitter.com/stemmechanics"
target="_blank"
rel="noreferrer">
Twitter
</a>
</li>
<li>
<a
href="https://youtube.com/@STEMMechanics"
target="_blank"
rel="noreferrer">
YouTube
</a>
</li>
</ul>
</div>
<div>
<span class="font-semibold text-lg text-gray-4"
>STEMMechanics</span
>
<ul class="mt-4 leading-5 space-y-2">
<li>
<router-link :to="{ name: 'contact' }">
Contact Us
</router-link>
</li>
<li>
<router-link :to="{ name: 'code-of-conduct' }">
Code of Conduct
</router-link>
</li>
<li>
<router-link :to="{ name: 'terms-and-conditions' }">
Terms &amp; Conditions
</router-link>
</li>
<li>
<router-link :to="{ name: 'privacy' }">
Privacy Policy
</router-link>
</li>
</ul>
</div>
</div>
<div
class="flex flex-col md:flex-row items-center gap-2 border-t border-gray-600/50 mt-8 pt-10">
<router-link :to="{ name: 'home' }">
<svg
id="navbar-logo"
width="100%"
height="100%"
viewBox="0 0 2762 491"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xml:space="preserve"
xmlns:serif="http://www.serif.com/"
style="
fill-rule: evenodd;
clip-rule: evenodd;
stroke-linecap: round;
stroke-linejoin: round;
stroke-miterlimit: 10;
"
alt="STEMMechanics">
<g>
<g>
<g id="g7146">
<g id="g7154"></g>
<g id="g7158"></g>
<g id="g7162">
<g id="g7164">
<g id="g7188"></g>
<g id="g7192">
<path
id="path7194"
d="M520.706,133.507l0,263.567l32.794,-0l0,-257.505c0,-3.348 -2.714,-6.062 -6.061,-6.062l-26.733,-0Z"
style="
fill: #b00;
fill-rule: nonzero;
" />
</g>
<g id="g7196">
<path
id="path7198"
d="M56.5,139.568l-0,257.506l32.794,0l-0,-263.568l-26.733,0c-3.348,0 -6.061,2.714 -6.061,6.062"
style="
fill: #b00;
fill-rule: nonzero;
" />
</g>
<g id="g7200">
<path
id="path7202"
d="M553.5,216.404l-0,-76.836c-0,-3.348 -2.714,-6.061 -6.061,-6.061l-26.733,0l-0,263.567l32.794,0l-0,-145.67"
style="
fill: none;
fill-rule: nonzero;
stroke: #000;
stroke-width: 15px;
" />
</g>
<g id="g7204">
<path
id="path7206"
d="M89.294,216.404l-0,-82.897l-26.733,0c-3.348,0 -6.061,2.713 -6.061,6.061l-0,257.506l32.794,0l-0,-145.67"
style="
fill: none;
fill-rule: nonzero;
stroke: #000;
stroke-width: 15px;
" />
</g>
<g id="g7208">
<path
id="path7210"
d="M112.715,74.519l55.15,259.463l39.438,-8.383l-55.15,-259.463l-39.438,8.383Z"
style="
fill: #f89d00;
fill-rule: nonzero;
" />
</g>
<g id="g7212">
<path
id="path7214"
d="M112.715,74.519l8.464,39.818l39.437,-8.382l-8.463,-39.819l-39.438,8.383Z"
style="
fill: #d58700;
fill-rule: nonzero;
" />
</g>
<g id="g7216">
<path
id="path7218"
d="M207.304,325.599l-55.151,-259.463l-39.437,8.383l55.15,259.463"
style="
fill: none;
fill-rule: nonzero;
stroke: #000;
stroke-width: 15px;
" />
</g>
<g id="g7220">
<path
id="path7222"
d="M78.998,49.805l-32.558,6.92c-1.252,0.267 -2.484,-0.533 -2.75,-1.786l-1.96,-9.222c-0.291,-1.369 -1.636,-2.242 -3.005,-1.952l-24.19,5.143c-1.369,0.29 -2.242,1.635 -1.951,3.003l11.328,53.293c0.291,1.37 1.636,2.243 3.004,1.952l24.19,-5.142c1.369,-0.291 2.243,-1.636 1.952,-3.005l-1.961,-9.222c-0.266,-1.253 0.534,-2.484 1.786,-2.751l32.558,-6.919l-6.443,-30.312Z"
style="
fill: #b7cee9;
fill-rule: nonzero;
" />
</g>
<g id="g7224">
<path
id="path7226"
d="M78.998,49.805l-32.558,6.92c-1.252,0.267 -2.484,-0.533 -2.75,-1.786l-1.96,-9.222c-0.291,-1.369 -1.636,-2.242 -3.005,-1.952l-24.19,5.143c-1.369,0.29 -2.242,1.635 -1.951,3.003l11.328,53.293c0.291,1.37 1.636,2.243 3.004,1.952l24.19,-5.142c1.369,-0.291 2.243,-1.636 1.952,-3.005l-1.961,-9.222c-0.266,-1.253 0.534,-2.484 1.786,-2.751l32.558,-6.919l-6.443,-30.312Z"
style="
fill: none;
fill-rule: nonzero;
stroke: #000;
stroke-width: 15px;
" />
</g>
<g id="g7228">
<path
id="path7230"
d="M164.359,17.061l-91.655,19.481c-3.116,0.663 -5.105,3.726 -4.443,6.843l9.982,46.963c0.663,3.116 3.726,5.107 6.843,4.443l110.229,-23.429c4.739,-1.008 9.577,1.218 11.896,5.472l8.86,16.253c1.528,2.802 5.038,3.835 7.84,2.308l7.786,-3.384c7.942,-4.329 11.079,-13.3 6.748,-21.242l-14.286,-26.203c-11.658,-21.383 -35.978,-32.568 -59.8,-27.505"
style="
fill: #d7e3f2;
fill-rule: nonzero;
" />
</g>
<g id="g72281" serif:id="g7228">
<path
id="path72301"
serif:id="path7230"
d="M164.359,17.061l-91.655,19.481c-3.116,0.663 -5.105,3.726 -4.443,6.843l9.982,46.963c0.663,3.116 3.726,5.107 6.843,4.443l110.229,-23.429c4.739,-1.008 9.577,1.218 11.896,5.472l8.86,16.253c1.528,2.802 5.038,3.835 7.84,2.308l7.786,-3.384c7.942,-4.329 11.079,-13.3 6.748,-21.242l-14.286,-26.203c-11.658,-21.383 -35.978,-32.568 -59.8,-27.505"
style="
fill: #d7e3f2;
fill-rule: nonzero;
" />
</g>
<g id="g7232">
<path
id="path7234"
d="M102.999,30.103l-30.294,6.439c-3.117,0.663 -5.106,3.726 -4.444,6.843l9.983,46.963c0.662,3.116 3.726,5.107 6.842,4.443l110.229,-23.429c4.739,-1.008 9.578,1.218 11.896,5.472l8.86,16.253c1.528,2.802 5.038,3.835 7.84,2.308l7.786,-3.384c7.943,-4.329 11.079,-13.3 6.749,-21.242l-14.287,-26.203c-11.658,-21.383 -35.977,-32.568 -59.8,-27.505l-26.359,5.603"
style="
fill: none;
fill-rule: nonzero;
stroke: #000;
stroke-width: 15px;
" />
</g>
<g id="g7240"></g>
<g id="g7244">
<path
id="path7246"
d="M553.5,295.445l-497,0l-0,172.631c-0,3.905 3.166,7.07 7.071,7.07l482.858,0c3.905,0 7.071,-3.165 7.071,-7.07l-0,-172.631Z"
style="
fill: #e00000;
fill-rule: nonzero;
" />
</g>
<g id="g7248">
<path
id="path7250"
d="M546.429,444.992l-482.857,0c-3.906,0 -7.072,-3.166 -7.072,-7.071l-0,30.155c-0,3.905 3.166,7.07 7.072,7.07l482.857,0c3.905,0 7.071,-3.165 7.071,-7.07l-0,-30.155c-0,3.905 -3.166,7.071 -7.071,7.071"
style="
fill: #b00;
fill-rule: nonzero;
" />
</g>
<g id="g7252">
<path
id="path7254"
d="M553.5,295.445l-497,0l-0,172.631c-0,3.905 3.166,7.07 7.071,7.07l482.858,0c3.905,0 7.071,-3.165 7.071,-7.07l-0,-172.631Z"
style="
fill: none;
fill-rule: nonzero;
stroke: #000;
stroke-width: 15px;
" />
</g>
<g id="g7256">
<path
id="path7258"
d="M249.87,347.278l-0,33c-0,2.762 2.239,5 5,5l100.261,0c2.761,0 5,-2.238 5,-5l-0,-33c-0,-2.762 -2.239,-5 -5,-5l-100.261,0c-2.761,0 -5,2.238 -5,5"
style="
fill: #b00;
fill-rule: nonzero;
" />
</g>
<g id="g7260">
<path
id="path7262"
d="M249.87,347.278l-0,33c-0,2.762 2.239,5 5,5l100.261,0c2.761,0 5,-2.238 5,-5l-0,-33c-0,-2.762 -2.239,-5 -5,-5l-100.261,0c-2.761,0 -5,2.238 -5,5Z"
style="
fill: none;
fill-rule: nonzero;
stroke: #000;
stroke-width: 15px;
" />
</g>
</g>
<rect
id="path7148"
x="89.294"
y="133.507"
width="431.412"
height="36.366"
style="
fill: #7d8c97;
fill-rule: nonzero;
stroke: #000;
stroke-width: 15px;
" />
</g>
</g>
<g>
<clipPath id="_clip1">
<rect
x="48.391"
y="1"
width="554.191"
height="287.599" />
</clipPath>
<g clip-path="url(#_clip1)">
<clipPath id="_clip2">
<polygon
points="122.46,223.269 389.191,141.565 470.894,408.297 204.163,490 122.46,223.269 " />
</clipPath>
<g clip-path="url(#_clip2)">
<path
d="M401.112,309.199l20.426,-6.257c5.753,-1.762 8.987,-7.856 7.222,-13.609l-7.905,-25.756c-1.764,-5.748 -7.853,-8.979 -13.602,-7.218l-20.445,6.262c-5.67,-9.632 -12.768,-18.137 -20.928,-25.318l10.016,-18.862c2.822,-5.314 0.8,-11.909 -4.515,-14.729l-23.799,-12.627c-5.313,-2.819 -11.904,-0.798 -14.724,4.512l-10.021,18.874c-10.513,-2.725 -21.525,-3.831 -32.67,-3.127l-6.26,-20.436c-1.762,-5.751 -7.852,-8.985 -13.603,-7.223l-25.76,7.89c-5.752,1.762 -8.986,7.852 -7.224,13.604l6.26,20.435c-9.628,5.659 -18.132,12.743 -25.314,20.889l-18.874,-10.022c-5.311,-2.82 -11.903,-0.803 -14.725,4.508l-12.644,23.789c-2.824,5.313 -0.805,11.909 4.509,14.731l18.862,10.016c-2.738,10.52 -3.855,21.541 -3.158,32.697l-20.446,6.262c-5.748,1.761 -8.983,7.848 -7.225,13.598l7.876,25.764c1.76,5.755 7.852,8.992 13.605,7.23l20.427,-6.257c5.665,9.653 12.762,18.178 20.925,25.376l-10.022,18.873c-2.821,5.311 -0.803,11.903 4.507,14.725l23.79,12.645c5.314,2.823 11.909,0.805 14.73,-4.509l10.017,-18.862c10.542,2.744 21.588,3.86 32.768,3.153l6.26,20.436c1.762,5.751 7.852,8.985 13.603,7.223l25.76,-7.89c5.752,-1.762 8.986,-7.852 7.224,-13.603l-6.26,-20.436c9.658,-5.677 18.185,-12.788 25.381,-20.965l18.862,10.016c5.314,2.822 11.909,0.8 14.729,-4.515l12.627,-23.8c2.819,-5.312 0.798,-11.903 -4.513,-14.723l-18.873,-10.022c2.731,-10.535 3.837,-21.572 3.124,-32.742Z"
style="
fill: #ffdb05;
fill-rule: nonzero;
" />
<path
d="M276.901,251.218c35.634,-10.915 73.425,9.154 84.34,44.787c10.915,35.634 -9.153,73.426 -44.787,84.341c-35.633,10.915 -73.425,-9.153 -84.34,-44.787c-10.915,-35.634 9.153,-73.426 44.787,-84.341Z"
style="fill: #b3b6c3" />
<path
d="M283.918,274.128c22.99,-7.042 47.372,5.905 54.414,28.895c7.042,22.989 -5.906,47.371 -28.895,54.413c-22.99,7.042 -47.371,-5.905 -54.413,-28.895c-7.042,-22.989 5.905,-47.371 28.894,-54.413Z"
style="fill: #fff" />
<path
d="M318.049,385.553c-18.636,5.708 -38.38,3.818 -55.594,-5.323c-17.215,-9.142 -29.839,-24.44 -35.548,-43.076c-5.708,-18.636 -3.818,-38.38 5.324,-55.595c14.472,-27.253 44.692,-42.511 75.198,-37.969c2.974,0.443 5.026,3.213 4.584,6.188c-0.444,2.974 -3.214,5.026 -6.189,4.584c-25.952,-3.865 -51.662,9.118 -63.975,32.305c-7.777,14.645 -9.386,31.442 -4.529,47.298c4.856,15.855 15.597,28.87 30.242,36.646c14.645,7.777 31.442,9.386 47.297,4.529c15.855,-4.857 28.87,-15.597 36.646,-30.242c12.783,-24.071 8.534,-53.276 -10.571,-72.676c-2.111,-2.142 -2.084,-5.59 0.059,-7.701c2.142,-2.111 5.59,-2.084 7.701,0.059c10.813,10.98 17.77,24.873 20.121,40.179c2.397,15.612 -0.262,31.258 -7.69,45.247c-9.141,17.214 -24.439,29.838 -43.076,35.547Z"
style="fill-rule: nonzero" />
<path
d="M330.222,261.501c-1.342,0.411 -2.841,0.306 -4.174,-0.411l-0.255,-0.136c-2.656,-1.41 -3.666,-4.707 -2.256,-7.364c1.411,-2.656 4.707,-3.665 7.364,-2.255l0.306,0.164c2.649,1.424 3.641,4.726 2.216,7.375c-0.708,1.315 -1.878,2.221 -3.201,2.627Z"
style="fill-rule: nonzero" />
<path
d="M311.144,362.648c-5.021,1.538 -10.346,2.282 -15.812,2.136c-3.006,-0.08 -5.378,-2.583 -5.298,-5.589c0.081,-3.007 2.583,-5.38 5.589,-5.298c14.58,0.389 27.884,-7.364 34.72,-20.237c4.776,-8.992 5.764,-19.306 2.782,-29.042c-2.983,-9.735 -9.577,-17.726 -18.57,-22.502c-8.993,-4.775 -19.306,-5.763 -29.042,-2.781c-9.736,2.982 -17.727,9.577 -22.502,18.57c-6.772,12.752 -5.815,28.016 2.496,39.837c1.73,2.46 1.138,5.857 -1.322,7.586c-2.46,1.731 -5.856,1.139 -7.586,-1.322c-10.683,-15.194 -11.912,-34.816 -3.206,-51.209c6.139,-11.563 16.413,-20.041 28.931,-23.875c12.517,-3.835 25.777,-2.565 37.34,3.575c11.562,6.14 20.041,16.414 23.875,28.931c3.834,12.518 2.564,25.778 -3.576,37.341c-6.225,11.722 -16.623,20.143 -28.819,23.879Z"
style="fill-rule: nonzero" />
<path
d="M278.051,359.577c-1.325,0.405 -2.803,0.309 -4.126,-0.386l-0.233,-0.123c-2.656,-1.411 -3.666,-4.707 -2.256,-7.364c1.411,-2.656 4.707,-3.666 7.364,-2.256l0.188,0.1c2.663,1.398 3.688,4.689 2.29,7.352c-0.703,1.34 -1.886,2.266 -3.227,2.677Z"
style="fill-rule: nonzero" />
<path
d="M350.408,445.197l-25.761,7.89c-8.613,2.639 -17.767,-2.222 -20.405,-10.835l-5.026,-16.409c-8.725,0.207 -17.424,-0.631 -25.964,-2.499l-8.039,15.139c-2.047,3.854 -5.472,6.68 -9.644,7.958c-0.001,0.001 -0.001,0.001 -0.002,0.001c-4.174,1.278 -8.595,0.853 -12.449,-1.195l-23.79,-12.645c-7.951,-4.226 -10.985,-14.134 -6.761,-22.088l8.047,-15.154c-6.317,-6.026 -11.871,-12.761 -16.576,-20.1l-16.397,5.022c-4.174,1.279 -8.595,0.855 -12.45,-1.194c-3.855,-2.048 -6.681,-5.475 -7.958,-9.65l-7.876,-25.765c-2.632,-8.61 2.23,-17.759 10.838,-20.396l16.421,-5.03c-0.2,-8.7 0.637,-17.376 2.5,-25.89l-15.139,-8.039c-3.854,-2.048 -6.681,-5.474 -7.959,-9.647c-1.278,-4.174 -0.853,-8.594 1.195,-12.449l12.645,-23.79c4.225,-7.952 14.134,-10.985 22.087,-6.761l15.154,8.047c6.009,-6.299 12.724,-11.84 20.039,-16.537l-5.026,-16.407c-2.638,-8.613 2.223,-17.767 10.836,-20.406l25.761,-7.89c8.613,-2.639 17.767,2.222 20.405,10.835l5.026,16.409c8.691,-0.205 17.357,0.624 25.863,2.476l8.047,-15.154c4.224,-7.953 14.131,-10.99 22.085,-6.769l23.8,12.627c3.856,2.046 6.684,5.47 7.962,9.644c1.279,4.173 0.857,8.594 -1.19,12.449l-8.04,15.139c6.312,6.009 11.865,12.728 16.572,20.048l16.421,-5.03c8.608,-2.637 17.761,2.221 20.402,10.827l7.905,25.756c1.281,4.173 0.86,8.595 -1.187,12.451c-2.046,3.856 -5.472,6.684 -9.646,7.963l-16.396,5.022c0.212,8.715 -0.617,17.405 -2.475,25.936l15.154,8.047c7.954,4.224 10.99,14.131 6.769,22.085l-12.626,23.8c-2.046,3.856 -5.471,6.684 -9.644,7.962c-0,0.001 -0.002,0.001 -0.003,0.001c-4.172,1.278 -8.592,0.855 -12.446,-1.192l-15.139,-8.039c-6.027,6.33 -12.766,11.897 -20.11,16.612l5.027,16.408c2.636,8.613 -2.224,17.767 -10.837,20.406Zm-81.581,-33.338c0.94,-0.287 1.962,-0.323 2.965,-0.062c10.156,2.643 20.604,3.648 31.054,2.988c2.515,-0.159 4.812,1.43 5.55,3.84l6.26,20.436c0.879,2.871 3.931,4.492 6.802,3.612l25.76,-7.891c2.871,-0.879 4.492,-3.931 3.612,-6.802l-6.259,-20.435c-0.739,-2.41 0.274,-5.012 2.447,-6.29c9.026,-5.306 17.118,-11.991 24.052,-19.869c1.666,-1.893 4.415,-2.394 6.642,-1.212l18.862,10.016c1.285,0.682 2.759,0.823 4.15,0.397c1.391,-0.426 2.532,-1.369 3.214,-2.654l12.627,-23.8c1.407,-2.651 0.395,-5.953 -2.256,-7.361l-18.874,-10.023c-2.225,-1.181 -3.349,-3.736 -2.717,-6.175c2.631,-10.149 3.627,-20.588 2.96,-31.029c-0.161,-2.517 1.428,-4.816 3.84,-5.555l20.426,-6.256c1.392,-0.427 2.533,-1.369 3.215,-2.654c0.683,-1.286 0.823,-2.76 0.396,-4.15l-7.905,-25.757c-0.88,-2.869 -3.931,-4.487 -6.801,-3.608l-20.445,6.262c-2.408,0.738 -5.009,-0.273 -6.288,-2.444c-5.301,-9.004 -11.973,-17.076 -19.834,-23.993c-1.893,-1.667 -2.394,-4.415 -1.212,-6.642l10.016,-18.862c0.683,-1.285 0.823,-2.759 0.397,-4.15c-0.426,-1.391 -1.369,-2.532 -2.654,-3.214l-23.799,-12.627c-2.652,-1.407 -5.954,-0.396 -7.362,2.256l-10.022,18.875c-1.181,2.225 -3.736,3.35 -6.176,2.717c-10.125,-2.625 -20.541,-3.622 -30.96,-2.964c-2.516,0.159 -4.812,-1.429 -5.551,-3.84l-6.26,-20.436c-0.879,-2.871 -3.931,-4.491 -6.802,-3.612l-25.76,7.891c-2.871,0.88 -4.491,3.931 -3.612,6.802l6.26,20.436c0.738,2.41 -0.274,5.012 -2.448,6.29c-8.999,5.29 -17.071,11.95 -23.989,19.795c-1.667,1.89 -4.413,2.389 -6.638,1.208l-18.873,-10.022c-2.652,-1.408 -5.954,-0.397 -7.363,2.253l-12.645,23.791c-0.683,1.285 -0.824,2.758 -0.398,4.149c0.426,1.391 1.368,2.534 2.653,3.216l18.862,10.016c2.227,1.183 3.351,3.74 2.716,6.181c-2.638,10.134 -3.645,20.558 -2.993,30.986c0.157,2.514 -1.431,4.808 -3.841,5.546l-20.443,6.262c-2.869,0.879 -4.49,3.929 -3.613,6.8l7.877,25.764c0.426,1.391 1.367,2.534 2.652,3.216c1.285,0.683 2.759,0.824 4.15,0.398l20.427,-6.257c2.411,-0.738 5.014,0.276 6.291,2.451c5.295,9.023 11.968,17.114 19.831,24.048c1.89,1.667 2.39,4.413 1.208,6.638l-10.022,18.874c-1.408,2.651 -0.397,5.954 2.253,7.362l23.791,12.645c1.285,0.683 2.758,0.824 4.149,0.398c0,-0 0.001,-0 0.001,-0c1.391,-0.426 2.532,-1.368 3.215,-2.653l10.016,-18.862c0.696,-1.312 1.87,-2.241 3.216,-2.654Z"
style="fill-rule: nonzero" />
</g>
<path
d="M331.028,101.547l46.069,15.854c-0,-0 31.781,-6.692 34.148,-47.156c2.41,-40.449 -35.739,-58.435 -35.739,-58.435c-0.711,18.016 -5.801,18.692 -20.98,31.049c-15.178,12.356 -33.75,37.677 -23.498,58.688Z"
style="
fill: #7c5748;
fill-rule: nonzero;
" />
<path
d="M359.686,111.409l17.412,5.992c0,0 31.782,-6.691 34.148,-47.156c2.411,-40.449 -35.738,-58.434 -35.738,-58.434c32.273,36.893 -5.32,85.875 -15.822,99.598Z"
style="
fill: #5f4c44;
fill-rule: nonzero;
" />
<path
d="M407.419,88.985c1.997,-5.238 3.393,-11.41 3.827,-18.74c2.41,-40.449 -35.739,-58.434 -35.739,-58.434c-0.712,18.015 -5.801,18.691 -20.98,31.048c-2.503,2.052 -5.126,4.452 -7.65,7.129c2.17,2.446 3.834,6.078 3.708,11.522c0,0 14.139,-2.418 26.644,1.885c2.997,1.032 5.203,2.665 7.143,4.644c6.184,6.304 9.196,16.179 23.047,20.946Z"
style="
fill: #00a5f1;
fill-rule: nonzero;
" />
<path
d="M407.419,88.985c1.997,-5.238 3.393,-11.41 3.827,-18.74c2.41,-40.449 -35.739,-58.434 -35.739,-58.434c15.513,17.722 14.878,38.192 8.865,56.228c6.184,6.304 9.196,16.179 23.047,20.946Z"
style="
fill: #0094d8;
fill-rule: nonzero;
" />
<path
d="M321.254,134.516l47.508,16.35c3.765,1.295 7.868,-0.707 9.163,-4.472l6.488,-18.85c1.296,-3.766 -0.706,-7.869 -4.472,-9.164l-47.508,-16.349c-3.765,-1.296 -7.868,0.706 -9.163,4.471l-6.488,18.851c-1.296,3.765 0.706,7.867 4.472,9.163Z"
style="
fill: #c0c9d2;
fill-rule: nonzero;
" />
<path
d="M365.308,121.077l-6.42,18.657c-1.314,3.819 -5.476,5.849 -9.295,4.535l19.072,6.563c3.819,1.314 7.98,-0.716 9.294,-4.535l6.421,-18.657c1.314,-3.818 -0.716,-7.979 -4.535,-9.294l-19.072,-6.563c3.819,1.314 5.849,5.475 4.535,9.294Z"
style="
fill: #a6aeba;
fill-rule: nonzero;
" />
<path
d="M324.347,135.58c-11.322,7.729 -23.596,27.224 -38.115,69.412c-25.168,73.133 -35.058,150.447 -16.621,156.791c18.437,6.345 58.219,-60.681 83.387,-133.814c14.518,-42.188 16.842,-65.108 12.674,-78.168"
style="
fill: #f89e00;
fill-rule: nonzero;
" />
<path
d="M336.293,222.22c14.518,-42.187 19.332,-64.251 19.042,-75.976l10.336,3.557c4.169,13.06 1.845,35.979 -12.674,78.168c-25.168,73.133 -64.949,140.158 -83.387,133.814c9.214,3.17 41.515,-66.43 66.683,-139.563Z"
style="
fill: #d38302;
fill-rule: nonzero;
" />
<path
d="M268.259,365.699c7.978,2.746 17.565,-3.002 29.311,-17.572c9.288,-11.519 19.504,-28.127 29.666,-48.198c1.039,-2.053 0.203,-4.563 -1.858,-5.584c-2.039,-1.01 -4.511,-0.181 -5.539,1.851c-24.764,48.965 -42.73,63.785 -48.883,61.667c-4.802,-1.652 -9.061,-15.466 -5.268,-48.963c3.376,-29.812 12.292,-67.194 24.461,-102.559c12.241,-35.571 23.68,-57.245 34.909,-66.134l37.205,12.804c3.381,13.915 -0.94,38.038 -13.182,73.611c-6.071,17.64 -13.273,35.637 -20.923,52.308c-0.947,2.063 -0.045,4.504 2.011,5.467l0.001,0c2.086,0.978 4.566,0.064 5.528,-2.031c7.76,-16.91 15.063,-35.162 21.219,-53.047c11.784,-34.244 16.388,-58.069 14.209,-73.928c4.703,-0.013 9.103,-2.957 10.718,-7.648l6.487,-18.852c1.289,-3.745 0.493,-7.707 -1.76,-10.633c2.175,-1.078 4.612,-2.487 7.124,-4.31c1.975,-1.432 2.296,-4.251 0.69,-6.087l-0.001,-0c-1.42,-1.623 -3.833,-1.867 -5.58,-0.603c-4.971,3.597 -9.556,5.235 -11.518,5.826l-43.209,-14.869c-5.449,-13.415 2.155,-29.18 11.956,-41.016c0.302,1.235 0.446,2.589 0.409,4.068c-0.018,0.708 0.09,1.422 0.406,2.055c0.858,1.719 2.675,2.571 4.43,2.271c0.131,-0.023 13.226,-2.195 24.597,1.719c4.422,1.521 6.85,4.886 9.924,9.144c3.522,4.882 7.796,10.799 16.226,14.799c-0.487,1.023 -1.008,2.019 -1.562,2.987c-1.058,1.851 -0.504,4.206 1.246,5.424l0.003,0.002c1.999,1.39 4.759,0.766 5.968,-1.348c4.508,-7.88 7.105,-17.207 7.732,-27.794c1.046,-17.73 -5.222,-34.16 -18.13,-47.513c-9.647,-9.982 -19.558,-14.717 -19.974,-14.914c-1.143,-0.538 -2.461,-0.524 -3.585,0.024c-0.112,0.055 -0.223,0.115 -0.332,0.181c-1.188,0.72 -1.936,1.988 -1.992,3.377c-0.529,13.196 -2.91,15.039 -12.929,22.801c-1.925,1.491 -4.106,3.181 -6.546,5.17c-9.257,7.544 -17.414,17.555 -22.381,27.468c-5.619,11.215 -7.016,21.958 -4.146,31.399c-0.096,0.044 -0.195,0.079 -0.291,0.125c-2.726,1.33 -4.77,3.642 -5.757,6.51l-6.488,18.851c-1.614,4.692 0.043,9.722 3.743,12.625c-11.479,11.161 -22.51,32.773 -34.294,67.016c-12.357,35.912 -21.417,73.936 -24.859,104.322c-3.904,34.496 -0.269,53.919 10.808,57.731Zm88.902,-319.624c2.36,-1.924 4.499,-3.58 6.386,-5.042c9.052,-7.013 13.907,-10.773 15.56,-22.321c9.841,6.455 29.701,23.083 28.033,51.325c-0.283,4.8 -1.019,9.281 -2.195,13.428c-6.063,-3.044 -9.17,-7.346 -12.426,-11.858c-3.461,-4.796 -7.039,-9.755 -13.947,-12.133c-9.004,-3.098 -18.626,-2.998 -24.131,-2.572c-0.373,-2.353 -1.104,-4.505 -2.184,-6.446c1.651,-1.604 3.301,-3.074 4.904,-4.381Zm-36.46,80.626l6.487,-18.852c0.267,-0.775 0.818,-1.399 1.555,-1.758c0.736,-0.36 1.567,-0.411 2.343,-0.143l47.508,16.348c1.6,0.551 2.453,2.3 1.903,3.899l-6.488,18.851c-0.275,0.8 -0.849,1.413 -1.555,1.758c-0.706,0.344 -1.543,0.42 -2.343,0.144l-47.509,-16.35c-1.599,-0.55 -2.452,-2.298 -1.901,-3.897Z"
style="fill-rule: nonzero" />
<clipPath id="_clip3">
<polygon
points="338.743,110.391 329.8,355.224 84.967,346.281 93.91,101.448 338.743,110.391 " />
</clipPath>
<g clip-path="url(#_clip3)">
<path
d="M199.991,236.632l21.148,-19.657c2.063,-1.919 2.181,-5.148 0.263,-7.213l-67.366,-72.473l0.295,-8.09c0.071,-1.93 -0.954,-3.735 -2.649,-4.663l-40.059,-21.893c-1.928,-1.052 -4.313,-0.752 -5.921,0.744l-10.574,9.828c-1.61,1.495 -2.082,3.852 -1.174,5.851l18.912,41.551c0.802,1.757 2.527,2.911 4.458,2.982l8.089,0.295l67.365,72.475c1.919,2.064 5.148,2.182 7.213,0.263Z"
style="
fill: #cfd8dc;
fill-rule: nonzero;
" />
<path
d="M315.572,345.996l6.207,-5.769c11.646,-11.351 12.322,-29.849 1.535,-42.019l-61.852,-66.563c-0.923,-0.99 -2.203,-1.574 -3.557,-1.621c-8.451,-0.309 -15.051,-7.41 -14.743,-15.861c0.052,-1.354 -0.436,-2.672 -1.357,-3.666l-9.829,-10.573c-1.919,-2.064 -5.147,-2.182 -7.212,-0.264l-42.296,39.315c-2.064,1.919 -2.182,5.147 -0.263,7.212l9.828,10.574c0.92,0.992 2.195,1.579 3.547,1.631c8.451,0.309 15.051,7.41 14.743,15.861c-0.052,1.354 0.436,2.673 1.357,3.666l61.862,66.542c11.183,12.019 29.99,12.706 42.02,1.535l0.01,0Z"
style="
fill: #ff02ad;
fill-rule: nonzero;
" />
<g>
<path
d="M241.858,249.868c0.098,-2.817 2.461,-5.022 5.279,-4.924c1.358,0.047 2.641,0.634 3.565,1.631l58.972,63.443c1.955,2.032 1.893,5.262 -0.138,7.218c-2.031,1.954 -5.262,1.892 -7.217,-0.139c-0.041,-0.042 -0.081,-0.085 -0.121,-0.129l-58.972,-63.444c-0.922,-0.99 -1.414,-2.304 -1.368,-3.656Z"
style="
fill: #c62828;
fill-rule: nonzero;
" />
<path
d="M225.997,264.611c0.098,-2.817 2.461,-5.022 5.279,-4.924c1.358,0.047 2.641,0.634 3.565,1.631l58.972,63.444c1.955,2.031 1.893,5.262 -0.138,7.217c-2.031,1.955 -5.262,1.892 -7.217,-0.139c-0.041,-0.042 -0.081,-0.085 -0.121,-0.129l-58.972,-63.444c-0.922,-0.99 -1.414,-2.304 -1.368,-3.656Z"
style="
fill: #c62828;
fill-rule: nonzero;
" />
</g>
<path
d="M180.837,242.532c0.047,-1.354 0.63,-2.634 1.621,-3.557l42.296,-39.315c2.064,-1.918 5.293,-1.8 7.212,0.264l9.829,10.574c0.924,0.991 1.416,2.31 1.367,3.666c-0.308,8.451 6.292,15.552 14.743,15.86c1.354,0.048 2.634,0.631 3.557,1.622l61.852,66.552c10.627,12.227 9.956,30.599 -1.535,42.019l-6.217,5.779c-12.03,11.171 -30.837,10.484 -42.02,-1.535l-61.861,-66.552c-0.919,-0.991 -1.407,-2.305 -1.358,-3.656c0.308,-8.451 -6.292,-15.552 -14.743,-15.861c-1.354,-0.047 -2.634,-0.63 -3.557,-1.621l-9.829,-10.574c-0.921,-0.993 -1.409,-2.312 -1.357,-3.665Zm47.133,-31.917l-34.821,32.366l5.013,5.393c12.293,1.528 21.714,11.663 22.341,24.035l60.515,65.104c7.345,7.891 19.693,8.342 27.595,1.008l6.217,-5.78c7.536,-7.503 7.976,-19.561 1.008,-27.594l-60.515,-65.104c-12.293,-1.527 -21.714,-11.663 -22.341,-24.035l-5.012,-5.393Z"
style="fill-rule: nonzero" />
<path
d="M93.496,116.762c0.047,-1.354 0.63,-2.633 1.622,-3.557l10.573,-9.829c1.608,-1.496 3.993,-1.795 5.921,-0.743l40.06,21.893c1.702,0.927 2.732,2.737 2.659,4.673l-0.295,8.09l67.366,72.474c1.883,2.097 1.709,5.324 -0.389,7.208c-2.046,1.836 -5.18,1.722 -7.086,-0.259l-68.802,-74.018c-0.924,-0.992 -1.416,-2.311 -1.367,-3.666l0.257,-7.049l-34.051,-18.61l-5.181,4.816l16.076,35.318l7.039,0.257c1.353,0.047 2.633,0.63 3.557,1.622l68.8,74.017c1.883,2.098 1.709,5.325 -0.389,7.208c-2.046,1.837 -5.18,1.722 -7.086,-0.259l-67.368,-72.453l-8.089,-0.295c-1.93,-0.071 -3.656,-1.225 -4.457,-2.982l-18.912,-41.551c-0.33,-0.722 -0.487,-1.511 -0.458,-2.305Z"
style="fill-rule: nonzero" />
<path
d="M241.858,249.868c0.098,-2.817 2.461,-5.022 5.279,-4.924c1.358,0.047 2.641,0.634 3.565,1.631l58.972,63.443c1.955,2.032 1.893,5.262 -0.138,7.218c-2.031,1.954 -5.262,1.892 -7.217,-0.139c-0.041,-0.042 -0.081,-0.085 -0.121,-0.129l-58.972,-63.444c-0.922,-0.99 -1.414,-2.304 -1.368,-3.656Z"
style="fill-rule: nonzero" />
<path
d="M225.997,264.611c0.098,-2.817 2.461,-5.022 5.279,-4.924c1.358,0.047 2.641,0.634 3.565,1.631l58.972,63.444c1.955,2.031 1.893,5.262 -0.138,7.217c-2.031,1.955 -5.262,1.892 -7.217,-0.139c-0.041,-0.042 -0.081,-0.085 -0.121,-0.129l-58.972,-63.444c-0.922,-0.99 -1.414,-2.304 -1.368,-3.656Z"
style="fill-rule: nonzero" />
</g>
<g>
<g>
<g>
<g>
<path
d="M562.573,120.232l-87.881,207.058c-10.861,25.591 -40.411,37.532 -66.002,26.671c-25.591,-10.862 -37.532,-40.412 -26.671,-66.003l87.881,-207.058l92.673,39.332Z"
style="
fill: #dfe9f4;
fill-rule: nonzero;
" />
</g>
<g>
<path
d="M419.861,198.798l-37.842,89.16c-10.861,25.591 1.08,55.141 26.671,66.002c25.59,10.861 55.14,-1.079 66.002,-26.67l53.493,-126.037l-108.324,-2.455Z"
style="
fill: #24c100;
fill-rule: nonzero;
" />
</g>
<g>
<path
d="M528.18,201.254l-53.499,126.05c-10.864,25.596 -40.385,37.525 -65.981,26.662c-16.865,-7.158 -27.793,-22.426 -30.186,-39.277c4.202,3.899 9.15,7.192 14.756,9.571c25.596,10.864 55.163,-1.045 66.027,-26.642l41.172,-97.008l27.711,0.644Z"
style="
fill: #21af00;
fill-rule: nonzero;
" />
</g>
<g>
<path
d="M578.717,120.131l12.276,-28.925c1.381,-3.254 -0.137,-7.011 -3.391,-8.392l-108.177,-45.912c-3.253,-1.381 -7.01,0.137 -8.391,3.39l-12.277,28.926c-1.381,3.254 0.137,7.011 3.391,8.392l108.177,45.913c3.253,1.38 7.01,-0.138 8.392,-3.392Z"
style="
fill: #2dcef6;
fill-rule: nonzero;
" />
</g>
<g>
<path
d="M590.997,91.198l-12.288,28.95c-1.365,3.217 -5.116,4.771 -8.378,3.386l-108.175,-45.912c-3.263,-1.385 -4.751,-5.162 -3.385,-8.378l3.12,-7.353l100.179,42.518c3.263,1.385 7.013,-0.169 8.398,-3.431l9.147,-21.552l7.996,3.393c3.263,1.384 4.77,5.116 3.386,8.379Z"
style="
fill: #1ec5e0;
fill-rule: nonzero;
" />
</g>
<g>
<path
d="M523.331,129.908c7.056,2.995 10.353,11.155 7.359,18.21c-2.995,7.056 -11.155,10.353 -18.21,7.359c-7.056,-2.995 -10.353,-11.155 -7.359,-18.21c2.995,-7.056 11.155,-10.353 18.21,-7.359Z"
style="
fill: #21af00;
" />
<path
d="M470.558,233.05c7.624,3.236 11.187,12.053 7.951,19.677c-3.235,7.624 -12.052,11.186 -19.676,7.951c-7.624,-3.236 -11.187,-12.053 -7.951,-19.677c3.236,-7.624 12.053,-11.186 19.676,-7.951Z"
style="
fill: #dfe9f4;
" />
<path
d="M428.819,292.984c6.059,2.571 8.891,9.579 6.319,15.638c-2.571,6.059 -9.578,8.89 -15.638,6.318c-6.059,-2.571 -8.89,-9.578 -6.318,-15.637c2.571,-6.059 9.578,-8.891 15.637,-6.319Z"
style="
fill: #dfe9f4;
" />
<g>
<path
d="M473.695,150.963c5.26,2.232 7.718,8.315 5.485,13.576c-2.232,5.26 -8.315,7.718 -13.575,5.485c-5.261,-2.232 -7.719,-8.315 -5.486,-13.576c2.232,-5.26 8.316,-7.718 13.576,-5.485Z"
style="
fill: #21af00;
" />
<g>
<path
d="M589.555,78.219l-18.628,-7.906c-2.54,-1.078 -5.468,0.105 -6.546,2.645c-1.078,2.539 0.105,5.467 2.645,6.545l18.628,7.907c0.714,0.302 1.045,1.132 0.742,1.845l-12.277,28.927c-0.303,0.714 -1.128,1.047 -1.841,0.744c-48.948,-20.774 -58.962,-25.024 -108.18,-45.913c-0.713,-0.303 -1.051,-1.13 -0.748,-1.843l12.278,-28.928c0.302,-0.713 1.133,-1.049 1.847,-0.746l72.345,30.705c2.54,1.078 5.468,-0.105 6.546,-2.645c1.078,-2.54 -0.105,-5.468 -2.645,-6.546l-72.345,-30.705c-5.785,-2.455 -12.484,0.252 -14.939,6.036l-12.277,28.928c-2.454,5.78 0.253,12.479 6.038,14.934l3.159,1.341l-11.156,26.284c-1.078,2.54 0.106,5.468 2.645,6.546c2.54,1.078 5.468,-0.105 6.546,-2.645l11.156,-26.284l83.479,35.43l-31.114,73.309l-36.489,-0.826c-2.754,-0.062 -5.041,2.119 -5.1,4.879c-0.067,2.753 2.097,5.042 4.879,5.101l32.511,0.738l-50.618,119.264c-9.769,23.017 -36.438,33.794 -59.455,24.025c-23.017,-9.769 -33.794,-36.438 -24.025,-59.455l36.518,-86.042l44.574,1.007c2.754,0.062 5.041,-2.119 5.1,-4.879c0.067,-2.752 -2.103,-5.039 -4.879,-5.1l-40.596,-0.92l25.271,-59.541c1.078,-2.54 -0.105,-5.468 -2.645,-6.546c-2.54,-1.077 -5.468,0.106 -6.546,2.645l-65.987,155.475c-11.919,28.084 1.231,60.627 29.315,72.546c28.083,11.92 60.627,-1.23 72.546,-29.314c19.018,-44.809 66.682,-157.112 85.931,-202.465l3.159,1.341c5.78,2.453 12.479,-0.253 14.933,-6.034l12.277,-28.927c2.455,-5.785 -0.252,-12.484 -6.032,-14.937Z"
style="
fill: #003;
fill-rule: nonzero;
" />
<path
d="M510.531,160.069c9.585,4.068 20.688,-0.419 24.757,-10.004c4.066,-9.581 -0.42,-20.685 -10.006,-24.753c-9.585,-4.068 -20.689,0.418 -24.755,10c-4.069,9.586 0.418,20.688 10.004,24.757Zm10.85,-25.566c4.515,1.916 6.632,7.147 4.716,11.662c-1.916,4.515 -7.151,6.63 -11.666,4.714c-4.514,-1.916 -6.63,-7.152 -4.714,-11.666c1.916,-4.515 7.15,-6.626 11.664,-4.71Z"
style="
fill: #003;
fill-rule: nonzero;
" />
<path
d="M483.107,254.679c4.309,-10.151 -0.447,-21.919 -10.598,-26.228c-10.15,-4.308 -21.915,0.449 -26.223,10.6c-4.308,10.151 0.444,21.913 10.596,26.222c10.151,4.308 21.917,-0.443 26.225,-10.594Zm-22.325,1.403c-5.084,-2.158 -7.463,-8.045 -5.305,-13.13c2.158,-5.085 8.047,-7.468 13.132,-5.31c5.084,2.158 7.465,8.052 5.308,13.136c-2.158,5.084 -8.05,7.462 -13.135,5.304Z"
style="
fill: #003;
fill-rule: nonzero;
" />
<path
d="M430.768,288.391c-8.585,-3.644 -18.538,0.378 -22.181,8.963c-3.644,8.585 0.378,18.538 8.963,22.182c8.585,3.643 18.537,-0.378 22.181,-8.963c3.644,-8.585 -0.378,-18.538 -8.963,-22.182Zm-9.318,21.954c-3.518,-1.493 -5.166,-5.571 -3.673,-9.09c1.494,-3.519 5.572,-5.167 9.09,-3.673c3.519,1.493 5.167,5.571 3.673,9.09c-1.493,3.518 -5.571,5.166 -9.09,3.673Z"
style="
fill: #003;
fill-rule: nonzero;
" />
<path
d="M483.773,166.486c3.305,-7.786 -0.343,-16.812 -8.128,-20.116c-7.791,-3.307 -16.817,0.341 -20.121,8.126c-3.305,7.786 0.342,16.812 8.133,20.119c7.786,3.304 16.812,-0.343 20.116,-8.129Zm-19.059,-8.089c1.155,-2.719 4.306,-3.993 7.03,-2.836c2.719,1.154 3.993,4.305 2.838,7.024c-1.154,2.72 -4.305,3.993 -7.024,2.839c-2.725,-1.156 -3.998,-4.307 -2.844,-7.027Z"
style="
fill: #003;
fill-rule: nonzero;
" />
</g>
</g>
</g>
</g>
</g>
</g>
</g>
</g>
</g>
<g>
<g>
<path
d="M1338.77,177.49l-14.203,208.687l45.422,0.422l7.453,-101.11l23.063,94.782c5.531,6.187 11.859,7.5 18.984,3.937l43.031,-97.875l-5.765,99.844l45.703,-0.422l12.797,-208.547c-12.563,-3.281 -25.594,-3.187 -39.094,0.282l-57.797,118.406l-32.766,-118.125c-19.5,-5.344 -35.109,-5.438 -46.828,-0.281Z"
style="
fill: #00a5f1;
fill-rule: nonzero;
" />
<path
d="M1536.29,178.474c-3.375,1.594 -5.578,4.219 -6.609,7.875l-12.657,192.797c0.938,5.156 3.422,8.109 7.454,8.859l131.484,-5.203c9.938,-14.531 11.016,-30.094 3.234,-46.687l-94.359,2.812l3.797,-43.594l76.922,-3.234c8.156,-12.188 8.718,-25.125 1.687,-38.813l-75.515,1.547l2.531,-36l91.969,-2.953c6.187,-12.844 6.984,-26.625 2.39,-41.344l-132.328,3.938Z"
style="
fill: #00a5f1;
fill-rule: nonzero;
" />
<path
d="M1829.72,195.068c3.281,16.875 -1.125,30.375 -13.219,40.5c-8.156,-5.25 -20.953,-9.422 -38.39,-12.516c-15.282,-1.5 -27.75,2.93 -37.407,13.289c-9.656,10.359 -15.562,23.789 -17.718,40.289c-3.188,10.406 -1.219,23.766 5.906,40.078c7.125,14.813 17.976,22.641 32.554,23.485c14.579,0.843 32.04,-2.016 52.383,-8.578c7.313,14.437 8.016,28.593 2.11,42.468c-24.094,11.063 -43.641,16.313 -58.641,15.75c-27.656,-3.469 -47.812,-13.5 -60.469,-30.094c-16.875,-22.218 -22.593,-53.578 -17.156,-94.078c4.313,-32.062 14.484,-56.297 30.516,-72.703c20.906,-16.969 46.265,-23.906 76.078,-20.812c20.719,1.312 35.203,8.953 43.453,22.922Z"
style="
fill: #00a5f1;
fill-rule: nonzero;
" />
<path
d="M1845.63,183.958l-12.093,197.578c10.312,7.407 25.546,8.532 45.703,3.375l5.062,-85.781l65.953,-2.812l-4.078,85.922c12.938,7.968 28.078,7.593 45.422,-1.125l9.563,-200.11c-13.313,-7.219 -27.188,-7.312 -41.625,-0.281l-5.344,73.266l-66.797,3.375l4.078,-78.61c-16.875,-6.469 -32.156,-4.734 -45.844,5.203Z"
style="
fill: #00a5f1;
fill-rule: nonzero;
" />
<path
d="M2073.74,175.099l-73.688,205.453c10.219,8.719 25.782,11.672 46.688,8.859l12.938,-37.968l52.171,-6.469l10.125,37.687c20.532,3.938 37.219,1.688 50.063,-6.75l-56.25,-202.5c-13.219,-8.156 -27.235,-7.593 -42.047,1.688Zm30.234,130.5l-34.312,4.078l22.078,-69.187l12.234,65.109Z"
style="
fill: #00a5f1;
fill-rule: nonzero;
" />
<path
d="M2185.9,183.958l-12.093,197.578c10.312,7.407 25.547,8.532 45.703,3.375l7.875,-115.031l62.859,114.75c16.5,6.563 29.532,5.531 39.094,-3.094l12.094,-200.531c-13.313,-7.219 -27.188,-7.312 -41.625,-0.281l-6.75,110.812l-66.938,-113.484c-20.156,-5.25 -33.562,-3.281 -40.219,5.906Z"
style="
fill: #00a5f1;
fill-rule: nonzero;
" />
<path
d="M2340.33,383.083c13.218,6.844 29.39,7.547 48.515,2.11l16.594,-206.86c-16.5,-4.781 -31.875,-5.484 -46.125,-2.109l-18.984,206.859Z"
style="
fill: #00a5f1;
fill-rule: nonzero;
" />
<path
d="M2562.53,195.068c3.281,16.875 -1.125,30.375 -13.219,40.5c-8.156,-5.25 -20.953,-9.422 -38.39,-12.516c-15.282,-1.5 -27.75,2.93 -37.407,13.289c-9.656,10.359 -15.562,23.789 -17.718,40.289c-3.188,10.406 -1.219,23.766 5.906,40.078c7.125,14.813 17.976,22.641 32.555,23.485c14.578,0.843 32.039,-2.016 52.382,-8.578c7.313,14.437 8.016,28.593 2.11,42.468c-24.094,11.063 -43.641,16.313 -58.641,15.75c-27.656,-3.469 -47.812,-13.5 -60.469,-30.094c-16.875,-22.218 -22.593,-53.578 -17.156,-94.078c4.313,-32.062 14.484,-56.297 30.516,-72.703c20.906,-16.969 46.265,-23.906 76.078,-20.812c20.719,1.312 35.203,8.953 43.453,22.922Z"
style="
fill: #00a5f1;
fill-rule: nonzero;
" />
<path
d="M2572.61,363.536c-6.093,-21.281 -2.531,-37.781 10.688,-49.5c24.094,14.157 41.578,22.875 52.453,26.157c20.25,-0.188 31.359,-5.25 33.328,-15.188c0.469,-7.969 -5.719,-15.656 -18.562,-23.062l-36.141,-17.578c-22.594,-12 -34.313,-28.922 -35.156,-50.766c0.843,-12.563 5.179,-23.859 13.008,-33.891c7.828,-10.031 16.64,-16.617 26.437,-19.758c9.797,-3.14 21.445,-4.289 34.945,-3.445c24.75,1.5 46.453,8.531 65.11,21.094c0.75,17.719 -4.782,33.141 -16.594,46.266c-22.781,-17.063 -41.016,-25.032 -54.703,-23.907c-12.844,-1.5 -19.641,3.094 -20.391,13.782c-0.469,6.093 8.578,13.968 27.141,23.625c21.281,8.625 37.055,18.515 47.32,29.671c10.266,11.157 14.836,24.704 13.711,40.641c-1.5,21.563 -11.531,38.109 -30.094,49.641c-11.812,8.25 -28.875,11.671 -51.187,10.265c-21.281,-2.719 -41.719,-10.734 -61.313,-24.047Z"
style="
fill: #00a5f1;
fill-rule: nonzero;
" />
</g>
<g>
<path
d="M644.827,363.536c-6.094,-21.281 -2.531,-37.781 10.688,-49.5c24.093,14.157 41.578,22.875 52.453,26.157c20.25,-0.188 31.359,-5.25 33.328,-15.188c0.469,-7.969 -5.719,-15.656 -18.563,-23.062l-36.14,-17.578c-22.594,-12 -34.313,-28.922 -35.157,-50.766c0.844,-12.563 5.18,-23.859 13.008,-33.891c7.828,-10.031 16.641,-16.617 26.438,-19.758c9.797,-3.14 21.445,-4.289 34.945,-3.445c24.75,1.5 46.453,8.531 65.109,21.094c0.75,17.719 -4.781,33.141 -16.593,46.266c-22.782,-17.063 -41.016,-25.032 -54.704,-23.907c-12.843,-1.5 -19.64,3.094 -20.39,13.782c-0.469,6.093 8.578,13.968 27.14,23.625c21.282,8.625 37.055,18.515 47.321,29.671c10.265,11.157 14.836,24.704 13.711,40.641c-1.5,21.563 -11.532,38.109 -30.094,49.641c-11.813,8.25 -28.875,11.671 -51.188,10.265c-21.281,-2.719 -41.718,-10.734 -61.312,-24.047Z"
fill="#fff"
style="fill-rule: nonzero" />
<path
d="M805.297,178.896c-9.563,12.281 -9.141,27.187 1.266,44.719l51.047,-1.125l-9.141,161.437c16.969,6.281 32.578,6.375 46.828,0.281l7.594,-163.547l51.89,-0.703c7.219,-15.094 7.594,-30.14 1.125,-45.14l-150.609,4.078Z"
fill="#fff"
style="fill-rule: nonzero" />
<path
d="M983.767,178.474c-3.375,1.594 -5.578,4.219 -6.609,7.875l-12.657,192.797c0.938,5.156 3.422,8.109 7.454,8.859l131.484,-5.203c9.937,-14.531 11.016,-30.094 3.234,-46.687l-94.359,2.812l3.797,-43.594l76.922,-3.234c8.156,-12.188 8.718,-25.125 1.687,-38.813l-75.515,1.547l2.531,-36l91.969,-2.953c6.187,-12.844 6.984,-26.625 2.39,-41.344l-132.328,3.938Z"
fill="#fff"
style="fill-rule: nonzero" />
<path
d="M1136.08,177.49l-14.203,208.687l45.421,0.422l7.454,-101.11l23.062,94.782c5.531,6.187 11.86,7.5 18.985,3.937l43.031,-97.875l-5.766,99.844l45.703,-0.422l12.797,-208.547c-12.562,-3.281 -25.594,-3.187 -39.094,0.282l-57.796,118.406l-32.766,-118.125c-19.5,-5.344 -35.109,-5.438 -46.828,-0.281Z"
fill="#fff"
style="fill-rule: nonzero" />
</g>
</g>
</g>
</svg>
</router-link>
<div class="flex-1"></div>
<span class="text-gray-300 text-sm">
Made with &nbsp; &copy; 2023 STEMMechanics
</span>
</div>
</div>
</footer>
</template>
<style lang="scss">
footer ul li a:not([role="button"]) {
color: rgba(156, 163, 175);
text-decoration: none;
}
</style>

View File

@@ -1,37 +0,0 @@
<template>
<div class="flex flex-items-center justify-center px-8 py-48 text-gray-8">
<h2 class="border-r border-gray pr-3 mr-3 font-500">
{{ props.status }}
</h2>
<p>{{ statusText }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
const props = defineProps({
status: {
type: Number,
default: 200,
required: true,
},
});
const statusText = ref("");
switch (props.status) {
case 403:
statusText.value = "You are not permitted to view this page";
break;
case 404:
statusText.value = "This page was not found";
break;
case 503:
statusText.value = "The server is currently under maintenance";
break;
default:
statusText.value = "An unknown error occurred";
break;
}
</script>

View File

@@ -1,210 +0,0 @@
<template>
<div class="flex flex-justify-center">
<div
:class="[
'flex',
'items-center',
'border-y-1',
'border-l-1',
'rounded-l-2',
'transition',
small
? ['text-sm', 'px-2', 'py-1']
: ['text-lg', 'px-4', 'py-2'],
computedDisablePrevButton
? [
'bg-gray-2',
'text-gray-4',
'border-gray-3',
'cursor-not-allowed',
]
: [
'hover:bg-sky-200',
'cursor-pointer',
'bg-white',
'border-gray',
],
]"
@click="handleClickPrev">
<svg
viewBox="0 0 960 960"
xmlns="http://www.w3.org/2000/svg"
:class="[small ? 'h-4' : 'h-6']">
<path
d="M648,78l56,57l-343,343l343,343l-56,57l-400,-400l400,-400Z"
fill="currentColor" />
</svg>
<span class="hidden sm:inline-block">Prev</span>
</div>
<div
:class="[
'flex',
'items-center',
'border-y-1',
'border-l-1',
'border-gray',
'transition',
small
? ['text-sm', 'px-2', 'py-1']
: ['text-lg', 'px-4', 'py-2'],
page == props.modelValue
? ['bg-sky-600', 'text-white']
: ['hover:bg-sky-200', 'cursor-pointer', 'bg-white'],
]"
v-for="(page, idx) of computedPages"
:key="idx"
@click="handleClickPage(page)">
{{ page }}
</div>
<div
:class="[
'flex',
'items-center',
'border-1',
'rounded-r-2',
'transition',
small
? ['text-sm', 'px-2', 'py-1']
: ['text-lg', 'px-4', 'py-2'],
computedDisableNextButton
? [
'bg-gray-2',
'text-gray-4',
'border-gray-3',
'cursor-not-allowed',
]
: [
'hover:bg-sky-200',
'cursor-pointer',
'bg-white',
'border-gray',
],
,
]"
@click="handleClickNext">
<span class="hidden sm:inline-block">Next</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
:class="[small ? 'h-4' : 'h-6']">
<path
d="m304-82-56-57 343-343-343-343 56-57 400 400L304-82Z"
fill="currentColor" />
</svg>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
const props = defineProps({
modelValue: {
type: Number,
required: true,
},
total: {
type: Number,
required: true,
},
perPage: {
type: Number,
required: true,
},
small: {
type: Boolean,
required: false,
default: false,
},
});
const emits = defineEmits(["update:modelValue"]);
/**
* Returns the pagination info
*/
const computedPages = computed(() => {
let pages = [];
let pagesRemaining =
Math.ceil(props.total / props.perPage) - props.modelValue;
let pagesBefore = Math.max(0, props.modelValue - 1);
if (pagesRemaining + pagesBefore > 4) {
if (pagesRemaining < 2) {
pagesBefore = Math.min(pagesBefore, 4 - pagesRemaining);
} else if (pagesBefore < 2) {
pagesRemaining = Math.min(pagesRemaining, 4 - pagesBefore);
} else {
pagesRemaining = 2;
pagesBefore = 2;
}
}
for (; pagesBefore > 0; pagesBefore--) {
pages.push(props.modelValue - pagesBefore);
}
pages.push(props.modelValue);
for (let i = 1; i <= pagesRemaining; i++) {
pages.push(props.modelValue + i);
}
return pages;
});
/**
* Return the total number of pages.
*/
const computedTotalPages = computed(() => {
return Math.ceil(props.total / props.perPage);
});
/**
* Return if the previous button should be disabled.
*/
const computedDisablePrevButton = computed(() => {
return props.modelValue <= 1;
});
/**
* Return if the next button should be disabled.
*/
const computedDisableNextButton = computed(() => {
return props.modelValue >= computedTotalPages.value;
});
/**
* Handle click on previous button
*/
const handleClickPrev = (): void => {
if (computedDisablePrevButton.value == false) {
emits("update:modelValue", props.modelValue - 1);
}
};
/**
* Handle click on next button
*/
const handleClickNext = (): void => {
if (computedDisableNextButton.value == false) {
emits("update:modelValue", props.modelValue + 1);
}
};
/**
* Handle click on page button
* @param {number} page The page number to display.
*/
const handleClickPage = (page: number): void => {
emits("update:modelValue", page);
};
const totalPages = computedTotalPages.value;
if (props.modelValue < 1 || totalPages < 1) {
emits("update:modelValue", 1);
} else {
if (totalPages < props.modelValue) {
emits("update:modelValue", totalPages);
}
}
</script>

View File

@@ -1,876 +0,0 @@
<template>
<SMControl
:class="[
'control-type-input',
{
'input-active': active,
'has-prepend': slots.prepend,
'has-append': slots.append,
},
props.size,
]"
:invalid="feedbackInvalid"
:no-help="props.noHelp">
<div v-if="slots.prepend" class="input-control-prepend">
<slot name="prepend"></slot>
</div>
<div class="control-item">
<template v-if="props.type == 'checkbox'">
<label
:class="[
'control-label',
'control-label-checkbox',
{ disabled: disabled },
]"
v-bind="{ for: id }"
><input
:id="id"
type="checkbox"
class="checkbox-control"
:disabled="disabled"
:checked="value"
@input="handleCheckbox" />
<span class="checkbox-control-box">
<span class="checkbox-control-tick"></span> </span
>{{ label }}</label
>
</template>
<template v-else-if="props.type == 'range'">
<label
class="control-label control-label-range"
v-bind="{ for: id }"
>{{ label }}</label
>
<input
:id="id"
type="range"
class="range-control"
:disabled="disabled"
v-bind="{
min: props.min,
max: props.max,
step: props.step,
}"
:value="value"
@input="handleInput" />
<span class="range-control-value">{{ value }}</span>
</template>
<template v-else-if="props.type == 'select'">
<label
class="control-label control-label-select"
v-bind="{ for: id }"
>{{ label }}</label
>
<ion-icon
class="select-dropdown-icon"
name="caret-down-outline" />
<select
class="select-input-control"
:disabled="disabled"
@input="handleInput">
<option
v-for="option in Object.entries(props.options)"
:key="option[0]"
:value="option[0]"
:selected="option[0] == value">
{{ option[1] }}
</option>
</select>
</template>
<template v-else>
<label class="control-label" v-bind="{ for: id }">{{
label
}}</label>
<template v-if="props.type == 'static'">
<div class="static-input-control" v-bind="{ id: id }">
<span class="text">
{{ value }}
</span>
</div>
</template>
<template v-else-if="props.type == 'file'">
<input
:id="id"
type="file"
class="file-input-control"
:accept="props.accept"
:disabled="disabled"
@change="handleChange" />
<div class="file-input-control-value">
{{ value?.name ? value.name : value }}
</div>
<label
:class="[
'button',
'primary',
'file-input-control-button',
{ disabled: disabled },
]"
:for="id"
>Select file</label
>
</template>
<template v-else-if="props.type == 'textarea'">
<ion-icon
class="invalid-icon"
name="alert-circle-outline"></ion-icon>
<textarea
:type="props.type"
class="input-control"
:disabled="disabled"
v-bind="{ id: id, autofocus: props.autofocus }"
v-model="value"
rows="5"
@focus="handleFocus"
@blur="handleBlur"
@input="handleInput"
@keyup="handleKeyup"></textarea>
</template>
<template v-else-if="props.type == 'media'">
<div class="media-input-control">
<img
v-if="mediaUrl?.length > 0"
:src="mediaGetVariantUrl(value, 'medium')" />
<ion-icon v-else name="image-outline" />
</div>
</template>
<template v-else>
<ion-icon
class="invalid-icon"
name="alert-circle-outline"></ion-icon>
<ion-icon
v-if="
props.showClear &&
value?.length > 0 &&
!feedbackInvalid
"
class="clear-icon"
name="close-outline"
@click.stop="handleClear"></ion-icon>
<input
:type="props.type"
class="input-control"
:disabled="disabled"
v-bind="{
id: id,
autofocus: props.autofocus,
autocomplete:
props.type === 'email' ? 'email' : null,
spellcheck: props.type === 'email' ? false : null,
autocorrect: props.type === 'email' ? 'on' : null,
autocapitalize:
props.type === 'email' ? 'off' : null,
}"
v-model="value"
@focus="handleFocus"
@blur="handleBlur"
@input="handleInput"
@keyup="handleKeyup" />
<ul
class="autocomplete-list"
v-if="computedAutocompleteItems.length > 0 && focused">
<li
v-for="item in computedAutocompleteItems"
:key="item"
@mousedown="handleAutocompleteClick(item)">
{{ item }}
</li>
</ul>
</template>
</template>
</div>
<div v-if="slots.append" class="input-control-append">
<slot name="append"></slot>
</div>
<template v-if="slots.help" #help><slot name="help"></slot></template>
</SMControl>
</template>
<script setup lang="ts">
import { inject, watch, ref, useSlots, computed } from "vue";
import { isEmpty, generateRandomElementId } from "../helpers/utils";
import { toTitleCase } from "../helpers/string";
import { mediaGetVariantUrl } from "../helpers/media";
import SMControl from "./SMControl.vue";
import { openDialog } from "./SMDialog";
import SMDialogMedia from "./dialogs/SMDialogMedia.vue";
import { Media } from "../helpers/api.types";
const emits = defineEmits(["update:modelValue", "blur", "keyup"]);
const props = defineProps({
form: {
type: Object,
default: undefined,
required: false,
},
control: {
type: [String, Object],
default: "",
},
label: {
type: String,
default: undefined,
required: false,
},
modelValue: {
type: [String, Number, Boolean],
default: undefined,
required: false,
},
type: {
type: String,
default: "text",
required: false,
},
id: {
type: String,
default: undefined,
required: false,
},
disabled: {
type: Boolean,
default: false,
required: false,
},
button: {
type: String,
default: "",
required: false,
},
showClear: {
type: Boolean,
default: false,
required: false,
},
feedbackInvalid: {
type: String,
default: "",
required: false,
},
autofocus: {
type: Boolean,
default: false,
required: false,
},
accept: {
type: String,
default: "",
required: false,
},
options: {
type: Object,
default: null,
required: false,
},
size: {
type: String,
default: "",
required: false,
},
min: {
type: Number,
default: undefined,
required: false,
},
max: {
type: Number,
default: undefined,
required: false,
},
step: {
type: Number,
default: undefined,
required: false,
},
noHelp: {
type: Boolean,
default: false,
required: false,
},
formId: {
type: String,
default: "form",
required: false,
},
autocomplete: {
type: [Array<string>, Function],
default: () => {
[];
},
required: false,
},
});
const slots = useSlots();
const form = inject(props.formId, props.form);
const control =
typeof props.control === "object"
? props.control
: form &&
!isEmpty(form) &&
typeof props.control === "string" &&
props.control !== "" &&
Object.prototype.hasOwnProperty.call(form.controls, props.control)
? form.controls[props.control]
: null;
const label = ref(
props.label != undefined
? props.label
: typeof props.control == "string"
? toTitleCase(props.control)
: ""
);
const value = ref(
props.modelValue != undefined
? props.modelValue
: control != null
? control.value
: ""
);
const id = ref(
props.id != undefined
? props.id
: typeof props.control == "string" && props.control.length > 0
? props.control
: generateRandomElementId()
);
const feedbackInvalid = ref(props.feedbackInvalid);
const active = ref(value.value?.toString().length ?? 0 > 0);
const focused = ref(false);
const disabled = ref(props.disabled);
watch(
() => value.value,
(newValue) => {
if (props.type === "media") {
mediaUrl.value = value.value.url ?? "";
}
active.value =
newValue.toString().length > 0 ||
newValue instanceof File ||
focused.value == true;
}
);
if (props.modelValue != undefined) {
watch(
() => props.modelValue,
(newValue) => {
value.value = newValue;
}
);
}
watch(
() => props.feedbackInvalid,
(newValue) => {
feedbackInvalid.value = newValue;
}
);
watch(
() => props.disabled,
(newValue) => {
disabled.value = newValue;
}
);
if (typeof control === "object" && control !== null) {
watch(
() => control.validation.result.valid,
(newValue) => {
feedbackInvalid.value = newValue
? ""
: control.validation.result.invalidMessages[0];
},
{ deep: true }
);
watch(
() => control.value,
(newValue) => {
value.value = newValue;
},
{ deep: true }
);
}
if (form) {
watch(
() => form.loading(),
(newValue) => {
disabled.value = newValue;
}
);
}
const mediaUrl = ref(value.value.url ?? "");
const handleFocus = () => {
active.value = true;
focused.value = true;
};
const handleBlur = async () => {
active.value = value.value?.length ?? 0 > 0;
focused.value = false;
emits("blur");
if (control) {
await control.validate();
control.isValid();
}
};
const handleCheckbox = (event: Event) => {
const target = event.target as HTMLInputElement;
value.value = target.checked;
emits("update:modelValue", target.checked);
if (control) {
control.value = target.checked;
feedbackInvalid.value = "";
}
};
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement;
value.value = target.value;
emits("update:modelValue", target.value);
if (control) {
control.value = target.value;
feedbackInvalid.value = "";
}
};
const handleKeyup = (event: Event) => {
emits("keyup", event);
};
const handleClear = () => {
value.value = "";
emits("update:modelValue", "");
};
const handleChange = (event) => {
if (control) {
control.value = event.target.files[0];
feedbackInvalid.value = "";
}
};
const handleMediaSelect = async () => {
let result = await openDialog(SMDialogMedia);
if (result) {
const mediaResult = result as Media;
mediaUrl.value = mediaResult.url;
emits("update:modelValue", mediaResult);
if (control) {
control.value = mediaResult;
feedbackInvalid.value = "";
}
}
};
const computedAutocompleteItems = computed(() => {
let autocompleteList = [];
if (props.autocomplete) {
if (typeof props.autocomplete === "function") {
autocompleteList = props.autocomplete(value.value);
} else {
autocompleteList = props.autocomplete.filter((str) =>
str.includes(value.value)
);
}
return autocompleteList.sort((a, b) => a.localeCompare(b));
}
return autocompleteList;
});
const handleAutocompleteClick = (item) => {
value.value = item;
emits("update:modelValue", item);
};
</script>
<style lang="scss">
.control-group.control-type-input {
.control-row {
.input-control-prepend {
p {
display: block;
color: var(--base-color-text);
background-color: var(--base-color-dark);
border-width: 1px 0 1px 1px;
border-style: solid;
border-color: var(--base-color-darker);
border-radius: 8px 0 0 8px;
padding: 16px 16px 16px 16px;
}
.button {
border-width: 1px 0 1px 1px;
border-style: solid;
border-color: var(--base-color-darker);
border-radius: 8px 0 0 8px;
}
& + .control-item .input-control {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
.input-control-append {
p {
display: block;
color: var(--base-color-text);
background-color: var(--base-color-dark);
border-width: 1px 1px 1px 0;
border-style: solid;
border-color: var(--base-color-darker);
border-radius: 0 8px 8px 0;
padding: 16px 16px 16px 16px;
}
.button {
border-width: 1px 1px 1px 0;
border-style: solid;
border-color: var(--base-color-darker);
height: 50px;
border-radius: 0 8px 8px 0;
}
}
.control-item {
max-width: 100%;
align-items: start;
.control-label {
position: absolute;
display: block;
transform-origin: top left;
transform: translate(16px, 16px) scale(1);
transition: all 0.1s ease-in-out;
color: var(--base-color-darker);
pointer-events: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.invalid-icon {
position: absolute;
display: none;
right: 10px;
top: 14px;
color: var(--danger-color);
font-size: 150%;
}
.clear-icon {
position: absolute;
right: 12px;
top: 18px;
background-color: var(--input-clear-icon-color);
border-radius: 50%;
font-size: 80%;
padding: 1px 1px 1px 0px;
&:hover {
color: var(--input-clear-icon-color-hover);
}
}
.input-control {
display: block;
width: 100%;
padding: 20px 16px 10px 16px;
border: 1px solid var(--base-color-darker);
border-radius: 8px;
background-color: var(--base-color-light);
color: var(--base-color-text);
&:disabled {
background-color: hsl(0, 0%, 92%);
cursor: not-allowed;
}
}
.autocomplete-list {
position: absolute;
list-style-type: none;
top: 100%;
width: 100%;
margin: 0;
padding: 0;
border: 1px solid var(--base-color-darker);
background-color: var(--base-color-light);
color: var(--primary-color);
z-index: 1;
max-height: 200px;
overflow: scroll;
scroll-behavior: smooth;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
li {
cursor: pointer;
padding: 8px 16px;
margin: 2px;
&:hover {
background-color: var(--base-color);
}
}
}
.static-input-control {
width: 100%;
padding: 22px 16px 8px 16px;
border: 1px solid var(--base-color-darker);
border-radius: 8px;
background-color: var(--base-color);
height: 52px;
overflow: auto;
scroll-behavior: smooth;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.file-input-control {
opacity: 0;
width: 0.1px;
height: 0.1px;
position: absolute;
margin-left: -9999px;
}
.file-input-control-value {
width: 100%;
padding: 22px 16px 8px 16px;
border: 1px solid var(--base-color-darker);
border-radius: 8px 0 0 8px;
background-color: var(--base-color);
height: 52px;
overflow: auto;
scroll-behavior: smooth;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.file-input-control-button {
border-width: 1px 1px 1px 0;
border-style: solid;
border-color: var(--base-color-darker);
border-radius: 0 8px 8px 0;
padding: 16px 30px;
width: auto;
}
.control-label-select {
transform: translate(16px, 6px) scale(0.7);
}
.select-dropdown-icon {
position: absolute;
top: 50%;
right: 0;
transform: translate(-50%, -50%);
font-size: 110%;
}
.select-input-control {
appearance: none;
width: 100%;
padding: 20px 16px 8px 14px;
border: 1px solid var(--base-color-darker);
border-radius: 8px;
background-color: var(--base-color-light);
height: 52px;
color: var(--base-color-text);
}
.control-label-checkbox {
position: relative;
display: flex;
align-items: center;
padding: 16px 0 16px 32px;
pointer-events: all;
transform: none;
color: var(--base-color-text);
&.disabled {
color: var(--base-color-darker);
cursor: not-allowed;
.checkbox-control-box {
background-color: var(--base-color);
}
}
}
.checkbox-control {
opacity: 0;
width: 0;
height: 0;
&:checked + .checkbox-control-box {
.checkbox-control-tick {
display: block;
}
}
}
.checkbox-control-box {
position: absolute;
top: 14px;
left: 0;
width: 24px;
height: 24px;
border: 1px solid var(--base-color-darker);
border-radius: 2px;
background-color: var(--base-color-light);
.checkbox-control-tick {
position: absolute;
display: none;
border-right: 3px solid var(--base-color-text);
border-bottom: 3px solid var(--base-color-text);
top: 1px;
left: 7px;
width: 8px;
height: 16px;
transform: rotate(45deg);
}
}
.media-input-control {
width: 100%;
text-align: center;
img,
ion-icon {
display: block;
margin: 48px auto 8px auto;
border-radius: 8px;
font-size: 800%;
max-height: 300px;
}
}
.control-label-range {
transform: none !important;
}
.range-control {
margin-top: 24px;
width: 100%;
}
.range-control-value {
margin-top: 22px;
padding-left: 16px;
font-size: 90%;
font-weight: 600;
width: 48px;
text-align: right;
}
}
}
&.has-append .control-item .input-control {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
&.input-active {
.control-item {
.control-label:not(.control-label-checkbox) {
transform: translate(16px, 6px) scale(0.7);
}
}
}
&.control-invalid {
.control-row .control-item {
.invalid-icon {
display: block;
}
.input-control {
border: 2px solid var(--danger-color);
}
}
}
&.small {
&.input-active {
.control-row .control-item .control-label {
transform: translate(16px, 6px) scale(0.7);
}
}
.control-row {
.control-item {
.control-label {
transform: translate(16px, 12px) scale(1);
}
.input-control {
padding: 16px 8px 4px 14px;
}
}
.input-control-append {
.button {
.button-label {
ion-icon {
height: 16px;
width: 16px;
}
}
height: 36px;
padding: 3px 24px 13px 24px;
}
}
}
}
}
@media (prefers-color-scheme: dark) {
.control-group.control-type-input {
.control-row {
.control-item {
.input-control {
&:disabled {
background-color: hsl(0, 0%, 8%);
}
}
}
}
}
}
</style>

View File

@@ -1,653 +0,0 @@
<template>
<div class="flex flex-col flex-1 flex-align-center">
<label class="control-label" v-bind="{ for: id }">{{ label }}</label>
<div v-if="value" class="flex flex-justify-center mb-4">
<SMLoading v-if="!imgError && !imgLoaded" class="w-48 h-48" small />
<svg
v-if="imgError && imgLoaded"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="h-48 text-gray">
<path
d="M20 17H22V15H20V17M20 7V13H22V7M6 16H11V18H6M6 12H14V14H6M4 2C2.89 2 2 2.89 2 4V20C2 21.11 2.89 22 4 22H16C17.11 22 18 21.11 18 20V8L12 2M4 4H11V9H16V20H4Z"
fill="currentColor" />
</svg>
<img
:class="[
'max-w-48',
'max-h-48',
'p-2',
'w-full',
'h-full',
{
'border-red-6': feedbackInvalid,
'border-2': feedbackInvalid,
},
]"
@load="handleImageLoaded"
@error="handleImageError"
:style="{ display: image == '' ? 'none' : 'block' }"
:src="image" />
</div>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-48 text-gray">
<path
d="M180-120q-24 0-42-18t-18-42v-600q0-24 18-42t42-18h600q24 0 42 18t18 42v600q0 24-18 42t-42 18H180Zm0-60h600v-600H180v600Zm56-97h489L578-473 446-302l-93-127-117 152Zm-56 97v-600 600Zm160.118-390Q361-570 375.5-584.618q14.5-14.617 14.5-35.5Q390-641 375.382-655.5q-14.617-14.5-35.5-14.5Q319-670 304.5-655.382q-14.5 14.617-14.5 35.5Q290-599 304.618-584.5q14.617 14.5 35.5 14.5Z"
fill="currentColor" />
</svg>
<div class="text-center">
<p
v-if="feedbackInvalid"
class="px-2 -mt-2 pb-2 text-xs text-red-6">
{{ feedbackInvalid }}
</p>
<button
type="button"
class="font-medium px-6 py-1.5 rounded-md hover:shadow-md transition text-sm bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
:disabled="disabled"
@click="handleMediaSelect">
Select File
</button>
</div>
<template v-if="slots.help"><slot name="help"></slot></template>
<input
id="file"
ref="refUploadInput"
type="file"
style="display: none"
:accept="props.accepts"
@change="handleChangeSelectFile" />
</div>
</template>
<script setup lang="ts">
import { inject, watch, ref, useSlots, onMounted } from "vue";
import { isEmpty, generateRandomElementId } from "../helpers/utils";
import { toTitleCase } from "../helpers/string";
import { mediaGetThumbnail } from "../helpers/media";
import { openDialog } from "./SMDialog";
import SMDialogMedia from "./dialogs/SMDialogMedia.vue";
import { Media } from "../helpers/api.types";
import SMLoading from "./SMLoading.vue";
const emits = defineEmits(["update:modelValue", "blur", "keyup"]);
const props = defineProps({
form: {
type: Object,
default: undefined,
required: false,
},
control: {
type: [String, Object],
default: "",
},
label: {
type: String,
default: undefined,
required: false,
},
modelValue: {
type: [String, Number, Boolean],
default: undefined,
required: false,
},
type: {
type: String,
default: "text",
required: false,
},
id: {
type: String,
default: undefined,
required: false,
},
disabled: {
type: Boolean,
default: false,
required: false,
},
button: {
type: String,
default: "",
required: false,
},
showClear: {
type: Boolean,
default: false,
required: false,
},
feedbackInvalid: {
type: String,
default: "",
required: false,
},
autofocus: {
type: Boolean,
default: false,
required: false,
},
accepts: {
type: String,
default: "image/*",
required: false,
},
options: {
type: Object,
default: null,
required: false,
},
size: {
type: String,
default: "",
required: false,
},
min: {
type: Number,
default: undefined,
required: false,
},
max: {
type: Number,
default: undefined,
required: false,
},
step: {
type: Number,
default: undefined,
required: false,
},
noHelp: {
type: Boolean,
default: false,
required: false,
},
formId: {
type: String,
default: "form",
required: false,
},
autocomplete: {
type: [Array<string>, Function],
default: () => {
[];
},
required: false,
},
allowUpload: {
type: Boolean,
default: false,
required: false,
},
uploadOnly: {
type: Boolean,
default: false,
required: false,
},
});
const slots = useSlots();
const refUploadInput = ref(null);
const image = ref("");
const form = inject(props.formId, props.form);
const control =
typeof props.control === "object"
? props.control
: form &&
!isEmpty(form) &&
typeof props.control === "string" &&
props.control !== "" &&
Object.prototype.hasOwnProperty.call(form.controls, props.control)
? form.controls[props.control]
: null;
const label = ref(
props.label != undefined
? props.label
: typeof props.control == "string"
? toTitleCase(props.control)
: "",
);
const value = ref(
props.modelValue != undefined
? props.modelValue
: control != null
? control.value
: "",
);
const id = ref(
props.id != undefined
? props.id
: typeof props.control == "string" && props.control.length > 0
? props.control
: generateRandomElementId(),
);
const feedbackInvalid = ref(props.feedbackInvalid);
const disabled = ref(props.disabled);
const imgLoaded = ref(false);
const imgError = ref(false);
if (props.modelValue != undefined) {
watch(
() => props.modelValue,
(newValue) => {
imgLoaded.value = false;
imgError.value = false;
value.value = newValue;
},
);
}
watch(
() => props.feedbackInvalid,
(newValue) => {
feedbackInvalid.value = newValue;
},
);
watch(
() => props.disabled,
(newValue) => {
disabled.value = newValue;
},
);
watch(
() => value.value,
(newValue) => {
mediaGetThumbnail(newValue, "medium", (e) => {
image.value = e;
imgLoaded.value = true;
});
},
);
if (typeof control === "object" && control !== null) {
watch(
() => control.validation.result.valid,
(newValue) => {
feedbackInvalid.value = newValue
? ""
: control.validation.result.invalidMessages[0];
},
{ deep: true },
);
watch(
() => control.value,
(newValue) => {
value.value = newValue;
},
{ deep: true },
);
}
if (form) {
watch(
() => form.loading(),
(newValue) => {
disabled.value = newValue;
},
);
}
const handleMediaSelect = async () => {
let result = null;
if (props.uploadOnly == false) {
result = await openDialog(SMDialogMedia, {
allowUpload: props.allowUpload,
accepts: props.accepts,
});
if (result) {
const mediaResult = result as Media;
emits("update:modelValue", mediaResult);
if (control) {
control.value = mediaResult;
feedbackInvalid.value = "";
}
}
} else {
if (refUploadInput.value != null) {
refUploadInput.value.click();
}
}
};
const handleChangeSelectFile = async () => {
if (refUploadInput.value != null && refUploadInput.value.files != null) {
imgLoaded.value = false;
imgError.value = false;
const fileList = Array.from(refUploadInput.value.files);
let file = fileList.length > 0 ? fileList[0] : null;
emits("update:modelValue", file);
if (control) {
control.value = file;
feedbackInvalid.value = "";
}
}
};
onMounted(() => {
window.setTimeout(() => {
mediaGetThumbnail(value.value, "medium", (e) => {
image.value = e;
});
}, 500);
});
const handleImageLoaded = () => {
imgLoaded.value = true;
imgError.value = false;
};
const handleImageError = () => {
if (image.value !== "") {
imgLoaded.value = true;
imgError.value = true;
}
};
</script>
<style lang="scss">
.input-control-prepend {
p {
display: block;
color: var(--base-color-text);
background-color: var(--base-color-dark);
border-width: 1px 0 1px 1px;
border-style: solid;
border-color: var(--base-color-darker);
border-radius: 8px 0 0 8px;
padding: 16px 16px 16px 16px;
}
.button {
border-width: 1px 0 1px 1px;
border-style: solid;
border-color: var(--base-color-darker);
border-radius: 8px 0 0 8px;
}
& + .control-item .input-control {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
.input-control-append {
p {
display: block;
color: var(--base-color-text);
background-color: var(--base-color-dark);
border-width: 1px 1px 1px 0;
border-style: solid;
border-color: var(--base-color-darker);
border-radius: 0 8px 8px 0;
padding: 16px 16px 16px 16px;
}
.button {
border-width: 1px 1px 1px 0;
border-style: solid;
border-color: var(--base-color-darker);
height: 50px;
border-radius: 0 8px 8px 0;
}
}
.control-item {
max-width: 100%;
align-items: start;
.control-label {
position: absolute;
display: block;
transform-origin: top left;
transform: translate(16px, 16px) scale(1);
transition: all 0.1s ease-in-out;
color: var(--base-color-darker);
pointer-events: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.invalid-icon {
position: absolute;
display: none;
right: 10px;
top: 14px;
color: var(--danger-color);
font-size: 150%;
}
.clear-icon {
position: absolute;
right: 12px;
top: 18px;
background-color: var(--input-clear-icon-color);
border-radius: 50%;
font-size: 80%;
padding: 1px 1px 1px 0px;
&:hover {
color: var(--input-clear-icon-color-hover);
}
}
.input-control {
display: block;
width: 100%;
padding: 20px 16px 10px 16px;
border: 1px solid var(--base-color-darker);
border-radius: 8px;
background-color: var(--base-color-light);
color: var(--base-color-text);
&:disabled {
background-color: hsl(0, 0%, 92%);
cursor: not-allowed;
}
}
.autocomplete-list {
position: absolute;
list-style-type: none;
top: 100%;
width: 100%;
margin: 0;
padding: 0;
border: 1px solid var(--base-color-darker);
background-color: var(--base-color-light);
color: var(--primary-color);
z-index: 1;
max-height: 200px;
overflow: scroll;
scroll-behavior: smooth;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
li {
cursor: pointer;
padding: 8px 16px;
margin: 2px;
&:hover {
background-color: var(--base-color);
}
}
}
.static-input-control {
width: 100%;
padding: 22px 16px 8px 16px;
border: 1px solid var(--base-color-darker);
border-radius: 8px;
background-color: var(--base-color);
height: 52px;
overflow: auto;
scroll-behavior: smooth;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.file-input-control {
opacity: 0;
width: 0.1px;
height: 0.1px;
position: absolute;
margin-left: -9999px;
}
.file-input-control-value {
width: 100%;
padding: 22px 16px 8px 16px;
border: 1px solid var(--base-color-darker);
border-radius: 8px 0 0 8px;
background-color: var(--base-color);
height: 52px;
overflow: auto;
scroll-behavior: smooth;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.file-input-control-button {
border-width: 1px 1px 1px 0;
border-style: solid;
border-color: var(--base-color-darker);
border-radius: 0 8px 8px 0;
padding: 16px 30px;
width: auto;
}
.control-label-select {
transform: translate(16px, 6px) scale(0.7);
}
.select-dropdown-icon {
position: absolute;
top: 50%;
right: 0;
transform: translate(-50%, -50%);
font-size: 110%;
}
.select-input-control {
appearance: none;
width: 100%;
padding: 20px 16px 8px 14px;
border: 1px solid var(--base-color-darker);
border-radius: 8px;
background-color: var(--base-color-light);
height: 52px;
color: var(--base-color-text);
}
.control-label-checkbox {
position: relative;
display: flex;
align-items: center;
padding: 16px 0 16px 32px;
pointer-events: all;
transform: none;
color: var(--base-color-text);
&.disabled {
color: var(--base-color-darker);
cursor: not-allowed;
.checkbox-control-box {
background-color: var(--base-color);
}
}
}
.checkbox-control {
opacity: 0;
width: 0;
height: 0;
&:checked + .checkbox-control-box {
.checkbox-control-tick {
display: block;
}
}
}
.checkbox-control-box {
position: absolute;
top: 14px;
left: 0;
width: 24px;
height: 24px;
border: 1px solid var(--base-color-darker);
border-radius: 2px;
background-color: var(--base-color-light);
.checkbox-control-tick {
position: absolute;
display: none;
border-right: 3px solid var(--base-color-text);
border-bottom: 3px solid var(--base-color-text);
top: 1px;
left: 7px;
width: 8px;
height: 16px;
transform: rotate(45deg);
}
}
.media-input-control {
width: 100%;
text-align: center;
img,
ion-icon {
display: block;
margin: 48px auto 8px auto;
border-radius: 8px;
font-size: 800%;
max-height: 300px;
}
}
.control-label-range {
transform: none !important;
}
.range-control {
margin-top: 24px;
width: 100%;
}
.range-control-value {
margin-top: 22px;
padding-left: 16px;
font-size: 90%;
font-weight: 600;
width: 48px;
text-align: right;
}
}
</style>

View File

@@ -1,61 +0,0 @@
<template>
<ul class="social-icons">
<li>
<a href="https://facebook.com/stemmechanics"
><ion-icon name="logo-facebook"></ion-icon
></a>
</li>
<li>
<a href="https://mastodon.au/@stemmechanics"
><ion-icon name="logo-mastodon"></ion-icon
></a>
</li>
<li>
<a href="https://www.youtube.com/@stemmechanics"
><ion-icon name="logo-youtube"></ion-icon
></a>
</li>
<li>
<a href="https://twitter.com/stemmechanics"
><ion-icon name="logo-twitter"></ion-icon
></a>
</li>
<li>
<a href="https://github.com/stemmechanics"
><ion-icon name="logo-github"></ion-icon
></a>
</li>
<li>
<a href="https://discord.gg/yNzk4x7mpD"
><ion-icon name="logo-discord"></ion-icon
></a>
</li>
<li>
<a href="https://www.linkedin.com/company/stemmechanics"
><ion-icon name="logo-linkedin"></ion-icon
></a>
</li>
</ul>
</template>
<style lang="scss">
.social-icons {
list-style-type: none;
padding: 0;
display: flex;
font-size: 200%;
justify-content: center;
gap: 32px;
flex-wrap: wrap;
li {
margin: 0 !important;
}
}
@media only screen and (max-width: 768px) {
.social-icons {
gap: 16px;
}
}
</style>

View File

@@ -1,29 +0,0 @@
<template>
<div
v-show="id == selectedTab"
class="border-1 border-gray rounded-b-2 rounded-tr-2 p-4">
<slot></slot>
</div>
</template>
<script setup lang="ts">
import { inject } from "vue";
defineProps({
label: {
type: String,
required: true,
},
id: {
type: String,
required: true,
},
hide: {
type: Boolean,
default: true,
required: false,
},
});
const selectedTab = inject("selectedTab");
</script>

View File

@@ -1,87 +0,0 @@
<template>
<div class="mb-4">
<ul class="flex relative">
<li
v-for="tab in tabs"
:key="tab.id"
:class="[
'flex',
'flex-items-center',
'text-center',
'px-4',
'py-2',
'-mb-1px',
'border-1',
'rounded-t-2',
'border-gray',
selectedTab == tab.id
? ['border-b-white']
: [
'border-x-white',
'border-t-white',
'hover:border-x-gray-3',
'hover:border-t-gray-3',
],
]"
@click="selectedTab = tab.id">
{{ tab.label }}
</li>
</ul>
<slot></slot>
</div>
</template>
<script setup lang="ts">
import { provide, ref, useSlots, watch } from "vue";
const props = defineProps({
modelValue: {
type: String,
default: "",
required: false,
},
});
const emits = defineEmits(["tabChanged", "update:modelValue"]);
const slots = useSlots();
const tabs = ref(
slots
.default()
.map((tab) => {
const { label, id, hide } = tab.props;
if (hide !== true) {
return {
label,
id,
};
}
})
.filter(Boolean),
);
const selectedTab = ref(
props.modelValue.length == 0 ? tabs.value[0].id : props.modelValue,
);
if (props.modelValue.length == 0) {
emits("update:modelValue", selectedTab.value);
}
watch(
() => selectedTab.value,
(newValue) => {
emits("tabChanged", newValue);
emits("update:modelValue", newValue);
},
);
watch(
() => props.modelValue,
(newValue) => {
selectedTab.value = newValue;
},
);
provide("selectedTab", selectedTab);
</script>

View File

@@ -1,193 +0,0 @@
<template>
<table class="sm-table">
<thead>
<tr>
<th v-for="header in headers" :key="header['value']">
{{ header["text"] }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(item, index) in items"
:key="`item-row-${index}`"
@click="handleRowClick(item)">
<td
v-for="header in headers"
:data-title="header['text']"
:key="`item-row-${index}-${header['value']}`">
<template v-if="slots[`item-${header['value']}`]">
<slot :name="`item-${header['value']}`" v-bind="item">
</slot>
</template>
<template v-else>
{{ getItemValue(item, header["value"]) }}
</template>
</td>
</tr>
</tbody>
</table>
</template>
<script setup lang="ts">
import { useSlots } from "vue";
defineProps({
headers: {
type: Array,
default: () => [],
required: true,
},
items: {
type: Array,
default: () => [],
required: true,
},
});
const emits = defineEmits(["rowClick"]);
const slots = useSlots();
const handleRowClick = (item) => {
emits("rowClick", item);
};
const getItemValue = (data: unknown, key: string): string => {
if (typeof data === "object" && data !== null) {
return key.split(".").reduce((item, key) => item[key], data);
}
return "";
};
const hasClassLong = (text: unknown): boolean => {
if (typeof text == "string") {
return text.length >= 35;
}
return false;
};
</script>
<style lang="scss">
.sm-table {
border-spacing: 0;
border-left-width: 1px;
border-right-width: 1px;
border-radius: 0.75rem;
border-color: rgba(209, 213, 219);
width: 100%;
thead th {
background-color: rgba(229, 231, 235, 0.75);
border-top-width: 1px;
text-align: left;
&:first-child {
border-top-left-radius: 0.75rem;
}
&:last-child {
border-top-right-radius: 0.75rem;
}
}
th,
td {
padding: 1rem;
font-size: 0.875rem;
line-height: 1.25rem;
color: rgba(55, 65, 81);
border-bottom-width: 1px;
border-color: rgba(209, 213, 219);
}
tbody {
tr:nth-child(even) td {
background-color: rgba(229, 231, 235, 0.5);
}
tr:last-child td:first-child {
border-bottom-left-radius: 0.75rem;
}
tr:last-child td:last-child {
border-bottom-right-radius: 0.75rem;
}
}
}
@media only screen and (max-width: 800px) {
.sm-table {
display: block;
thead,
tbody,
th,
td,
tr {
display: block;
}
thead tr {
position: absolute;
top: -9999px;
left: -9999px;
}
tbody tr {
border-bottom: 1px solid rgba(209, 213, 219);
&:first-child {
td:first-child {
border-top: 1px solid rgba(209, 213, 219);
border-radius: 8px 8px 0 0;
}
}
&:last-child {
border-bottom: 0;
td {
&:first-child {
border-radius: 0;
}
&:last-child {
border-bottom: 1px solid rgba(209, 213, 219);
border-radius: 0 0 8px 8px;
}
}
}
td {
border-bottom: 0;
position: relative;
padding: 8px 12px 8px 140px;
white-space: normal;
word-wrap: break-word;
text-align: left;
&:before {
position: absolute;
display: flex;
align-items: center;
padding-left: 12px;
top: 0;
bottom: 0;
left: 0;
width: 125px;
white-space: nowrap;
text-align: left;
font-weight: 600;
content: attr(data-title);
}
}
&:nth-child(even) td {
background-color: rgba(250, 250, 250);
}
}
}
}
</style>

View File

@@ -1,151 +0,0 @@
<template>
<div
ref="toast"
class="border-1 border-gray-2 bg-white rounded-md p-4 mt-4 mb-4 pointer-events-auto"
:style="styles">
<div :class="['max-w-48', 'border-l-5', 'pl-4', 'relative', colour]">
<svg
v-if="!props.loader"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-4 absolute right-0 hover:text-red-7 cursor-pointer"
@click="handleClickClose">
<path
d="m249-207-42-42 231-231-231-231 42-42 231 231 231-231 42 42-231 231 231 231-42 42-231-231-231 231Z"
fill="currentColor" />
</svg>
<h5 class="mt-0 mb-2 pr-6" v-if="title && title.length > 0">
{{ title }}
</h5>
<div class="flex">
<svg
v-if="props.loader"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="spin h-4 color-gray mr-2 flex-align-middle">
<path
d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z"
fill="currentColor" />
</svg>
<p class="text-xs">
{{ content }}
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useToastStore } from "../store/ToastStore";
const props = defineProps({
id: {
type: Number,
required: true,
},
title: {
type: String,
default: "",
required: false,
},
type: {
type: String,
default: "primary",
required: false,
},
content: {
type: String,
required: true,
},
loader: {
type: Boolean,
required: false,
default: false,
},
});
const toastStore = useToastStore();
const toast = ref(null);
let height = 40;
let hideTimeoutID: number | null = null;
const styles = ref({
transition: "opacity 0.2s ease-in, margin 0.2s ease-in",
opacity: 0,
marginTop: "40px",
});
let colour = computed(() => {
switch (props.type) {
case "danger":
return "border-red-7";
case "success":
return "border-green-7";
case "warning":
return "border-yellow-4";
}
return "border-sky-5";
});
const handleClickClose = () => {
if (hideTimeoutID != null) {
window.clearTimeout(hideTimeoutID);
hideTimeoutID = null;
}
removeToast();
};
const removeToast = () => {
styles.value.opacity = 0;
styles.value.marginTop = `-${height}px`;
window.setTimeout(() => {
toastStore.clearToast(props.id);
}, 500);
};
const cancelRemoveCountdown = () => {
if (hideTimeoutID != null) {
window.clearTimeout(hideTimeoutID);
hideTimeoutID = null;
}
};
const startRemoveCountdown = () => {
if (hideTimeoutID == null) {
hideTimeoutID = window.setTimeout(() => {
hideTimeoutID = null;
removeToast();
}, 8000);
}
};
onMounted(() => {
window.setTimeout(() => {
styles.value.opacity = 1;
styles.value.marginTop = "0";
if (toast.value != null) {
const styles = window.getComputedStyle(toast.value);
const marginBottom = parseFloat(styles.marginBottom);
height = toast.value.offsetHeight + marginBottom || 0;
}
if (!props.loader) {
startRemoveCountdown();
}
}, 200);
});
watch(
() => props.loader,
(newValue) => {
if (newValue) {
cancelRemoveCountdown();
} else {
startRemoveCountdown();
}
},
);
</script>

View File

@@ -1,21 +0,0 @@
<template>
<div
class="fixed top-10 right-10 z-10 overflow-hidden pointer-events-none"
v-if="toastStore.toasts">
<SMToast
v-for="toast of toastStore.toasts"
:id="toast.id"
:key="toast.id"
:type="toast.type"
:title="toast.title"
:content="toast.content"
:loader="toast.loader" />
</div>
</template>
<script setup lang="ts">
import { useToastStore } from "../store/ToastStore";
import SMToast from "./SMToast.vue";
const toastStore = useToastStore();
</script>

View File

@@ -1,121 +0,0 @@
<template>
<div
class="fixed top-0 left-0 w-full h-full bg-black bg-op-20 backdrop-blur"></div>
<div class="fixed top-0 left-0 w-full flex-justify-center flex pt-36">
<div
class="max-w-2xl mx-auto border-1 bg-white rounded-xl mt-7xl text-gray-5 px-12 py-8">
<SMForm :model-value="form" @submit="handleSubmit">
<h3 class="mb-2">Change Password</h3>
<p class="mb-4">Enter your new password below</p>
<SMInput
control="password"
type="password"
label="New Password"
autofocus />
<div class="flex flex-justify-between pt-4">
<button
class="font-medium block w-full md:inline-block md:w-auto px-6 py-1.5 rounded-md hover:shadow-md transition text-sm bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
type="button"
@click="handleClickCancel">
Cancel
</button>
<input
class="font-medium block w-full md:inline-block md:w-auto px-6 py-1.5 rounded-md hover:shadow-md transition text-sm bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
role="button"
type="submit"
value="Update" />
</div>
</SMForm>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, reactive, ref } from "vue";
import { closeDialog } from "../SMDialog";
import { api } from "../../helpers/api";
import { Form, FormControl, FormObject } from "../../helpers/form";
import { And, Password, Required } from "../../helpers/validate";
import { useApplicationStore } from "../../store/ApplicationStore";
import { useToastStore } from "../../store/ToastStore";
import { useUserStore } from "../../store/UserStore";
import SMForm from "../SMForm.vue";
import SMInput from "../SMInput.vue";
const form: FormObject = reactive(
Form({
password: FormControl("", And([Required(), Password()])),
}),
);
const applicationStore = useApplicationStore();
const userStore = useUserStore();
const dialogLoading = ref(false);
/**
* User clicks cancel button to close dialog
*/
const handleClickCancel = () => {
closeDialog(false);
};
/**
* User clicks form submit button
*/
const handleSubmit = async () => {
try {
dialogLoading.value = true;
await api.put({
url: "/users/{id}",
params: {
id: userStore.id,
},
body: {
password: form.controls.password.value,
},
});
const toastStore = useToastStore();
toastStore.addToast({
title: "Password Reset",
content: "Your password has been reset",
type: "success",
});
closeDialog(false);
} catch (error) {
form.apiErrors(error, (message) => {
useToastStore().addToast({
title: "An error occurred",
content: message,
type: "danger",
});
});
} finally {
dialogLoading.value = false;
}
};
/**
* 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") {
handleClickCancel();
return true;
}
return false;
};
onMounted(() => {
applicationStore.addKeyUpListener(eventKeyUp);
});
onUnmounted(() => {
applicationStore.removeKeyUpListener(eventKeyUp);
});
</script>

View File

@@ -1,130 +0,0 @@
<template>
<div
class="fixed top-0 left-0 w-full h-full bg-black bg-op-20 backdrop-blur"></div>
<div class="fixed top-0 left-0 w-full flex-justify-center flex pt-36">
<div
class="max-w-2xl mx-auto border-1 bg-white rounded-xl mt-7xl text-gray-5 px-12 py-8">
<h1 class="mb-4">{{ props.title }}</h1>
<p class="mb-4" v-html="props.text"></p>
<div class="flex flex-justify-between pt-4">
<button
type="button"
:class="buttonClass(props.cancel.type)"
@click="handleClickCancel()">
{{ props.cancel.label }}
</button>
<button
type="button"
:class="buttonClass(props.confirm.type)"
@click="handleClickConfirm()">
{{ props.confirm.label }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted } from "vue";
import { closeDialog } from "../SMDialog";
import { useApplicationStore } from "../../store/ApplicationStore";
const props = defineProps({
title: {
type: String,
required: true,
},
text: {
type: String,
required: true,
},
cancel: {
type: Object,
default() {
return {
type: "secondary",
label: "No",
};
},
},
confirm: {
type: Object,
default() {
return {
type: "primary",
label: "Yes",
};
},
},
});
const applicationStore = useApplicationStore();
/**
* Handle the user clicking the cancel button.
*/
const handleClickCancel = () => {
closeDialog(false);
};
/**
* Handle the user clicking the confirm button.
*/
const handleClickConfirm = () => {
closeDialog(true);
};
/**
* 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") {
handleClickCancel();
return true;
} else if (event.key === "Enter") {
handleClickConfirm();
return true;
}
return false;
};
const buttonClass = (type: string): Array<string> => {
let baseClasses = [
"font-medium",
"px-6",
"py-1.5",
"rounded-md",
"hover:shadow-md",
"transition",
"text-sm",
"text-white",
"cursor-pointer",
];
if (type === "secondary") {
baseClasses = baseClasses.concat(["bg-gray-400", "hover:bg-gray-300"]);
} else if (type === "danger") {
baseClasses = baseClasses.concat(["bg-red-600", "hover:bg-red-500"]);
} else if (type === "success") {
baseClasses = baseClasses.concat([
"bg-green-600",
"hover:bg-green-500",
]);
} else {
baseClasses = baseClasses.concat(["bg-sky-600", "hover:bg-sky-500"]);
}
return baseClasses;
};
onMounted(() => {
applicationStore.addKeyUpListener(eventKeyUp);
});
onUnmounted(() => {
applicationStore.removeKeyUpListener(eventKeyUp);
});
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -1,45 +0,0 @@
<template>
<div
class="fixed top-0 left-0 w-full h-full bg-black bg-op-20 backdrop-blur"></div>
<div
class="fixed top-0 left-0 right-0 bottom-0 flex-justify-center flex-items-center flex">
<div
class="flex flex-col m-4 border-1 bg-white rounded-xl text-gray-5 px-4 md:px-12 py-4 md:py-8 max-w-200 w-full overflow-hidden">
<h2 class="mb-2">{{ props.title }}</h2>
<div
v-for="(row, index) in props.rows"
class="flex flex-col text-xs my-4"
:key="index">
<div class="w-full bg-gray-3 h-3 mb-2 rounded-2">
<div
class="bg-sky-600 h-3 rounded-2"
:style="{
width: `${props.progress[index]}%`,
}"></div>
</div>
<p class="m-0"></p>
{{ row }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps({
title: {
type: String,
default: "",
required: false,
},
rows: {
type: Array,
default: () => [],
required: false,
},
progress: {
type: Array,
default: () => [],
required: false,
},
});
</script>

View File

@@ -1,60 +0,0 @@
import { mergeAttributes, Node } from "@tiptap/core";
export interface DangerOptions {
HTMLAttributes: Record<string, any>;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
danger: {
/**
* Toggle a paragraph
*/
setDanger: () => ReturnType;
toggleDanger: () => ReturnType;
};
}
}
export const Danger = Node.create<DangerOptions>({
name: "danger",
priority: 1000,
addOptions() {
return {
HTMLAttributes: { class: "danger" },
};
},
group: "block",
content: "inline*",
parseHTML() {
return [{ tag: "p", class: "danger" }];
},
renderHTML({ HTMLAttributes }) {
return [
"p",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0,
];
},
addCommands() {
return {
setDanger:
() =>
({ commands }) => {
return commands.setNode(this.name);
},
toggleDanger:
() =>
({ commands }) => {
return commands.toggleNode(this.name, "paragraph");
},
};
},
});

View File

@@ -1,60 +0,0 @@
import { mergeAttributes, Node } from "@tiptap/core";
export interface InfoOptions {
HTMLAttributes: Record<string, any>;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
info: {
/**
* Toggle a paragraph
*/
setInfo: () => ReturnType;
toggleInfo: () => ReturnType;
};
}
}
export const Info = Node.create<InfoOptions>({
name: "info",
priority: 1000,
addOptions() {
return {
HTMLAttributes: { class: "info" },
};
},
group: "block",
content: "inline*",
parseHTML() {
return [{ tag: "p", class: "info" }];
},
renderHTML({ HTMLAttributes }) {
return [
"p",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0,
];
},
addCommands() {
return {
setInfo:
() =>
({ commands }) => {
return commands.setNode(this.name);
},
toggleInfo:
() =>
({ commands }) => {
return commands.toggleNode(this.name, "paragraph");
},
};
},
});

View File

@@ -1,59 +0,0 @@
import { mergeAttributes, Node } from "@tiptap/core";
export interface SmallOptions {
HTMLAttributes: Record<string, any>;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
small: {
/**
* Set a small mark
*/
setSmall: () => ReturnType;
/**
* Toggle a small mark
*/
toggleSmall: () => ReturnType;
};
}
}
export const Small = Node.create<SmallOptions>({
name: "small",
group: "block",
content: "inline*",
addOptions() {
return {
HTMLAttributes: { class: "small" },
};
},
parseHTML() {
return [{ tag: "p.small", priority: 100 }];
},
renderHTML({ HTMLAttributes }) {
return [
"p",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0,
];
},
addCommands() {
return {
setSmall:
() =>
({ commands }) => {
return commands.setNode(this.name);
},
toggleSmall:
() =>
({ commands }) => {
return commands.toggleNode(this.name, "paragraph");
},
};
},
});

View File

@@ -1,60 +0,0 @@
import { mergeAttributes, Node } from "@tiptap/core";
export interface SuccessOptions {
HTMLAttributes: Record<string, any>;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
success: {
/**
* Toggle a paragraph
*/
setSuccess: () => ReturnType;
toggleSuccess: () => ReturnType;
};
}
}
export const Success = Node.create<SuccessOptions>({
name: "success",
priority: 1000,
addOptions() {
return {
HTMLAttributes: { class: "success" },
};
},
group: "block",
content: "inline*",
parseHTML() {
return [{ tag: "p", class: "success" }];
},
renderHTML({ HTMLAttributes }) {
return [
"p",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0,
];
},
addCommands() {
return {
setSuccess:
() =>
({ commands }) => {
return commands.setNode(this.name);
},
toggleSuccess:
() =>
({ commands }) => {
return commands.toggleNode(this.name, "paragraph");
},
};
},
});

View File

@@ -1,60 +0,0 @@
import { mergeAttributes, Node } from "@tiptap/core";
export interface WarningOptions {
HTMLAttributes: Record<string, any>;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
warning: {
/**
* Toggle a paragraph
*/
setWarning: () => ReturnType;
toggleWarning: () => ReturnType;
};
}
}
export const Warning = Node.create<WarningOptions>({
name: "warning",
priority: 1000,
addOptions() {
return {
HTMLAttributes: { class: "warning" },
};
},
group: "block",
content: "inline*",
parseHTML() {
return [{ tag: "p", class: "warning" }];
},
renderHTML({ HTMLAttributes }) {
return [
"p",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0,
];
},
addCommands() {
return {
setWarning:
() =>
({ commands }) => {
return commands.setNode(this.name);
},
toggleWarning:
() =>
({ commands }) => {
return commands.toggleNode(this.name, "paragraph");
},
};
},
});

View File

@@ -1,476 +0,0 @@
import { useUserStore } from "../store/UserStore";
import { useApplicationStore } from "../store/ApplicationStore";
import { useCacheStore } from "../store/CacheStore";
import { ImportMetaExtras } from "../../../import-meta";
interface ApiProgressData {
loaded: number;
total: number;
}
interface ApiCallbackData {
status: number;
statusText: string;
url: string;
headers: unknown;
data: unknown;
}
type ApiProgressCallback = (progress: ApiProgressData) => void;
type ApiResultCallback = (data: ApiCallbackData) => void;
export interface ApiOptions {
url: string;
params?: object;
method?: string;
headers?: HeadersInit;
body?: string | object | FormData | ArrayBuffer | Blob;
signal?: AbortSignal | null;
progress?: ApiProgressCallback;
callback?: ApiResultCallback;
chunk?: string;
}
export interface ApiResponse {
status: number;
message: string;
data: unknown;
json?: Record<string, unknown>;
}
const apiDefaultHeaders = {
Accept: "application/json",
"Content-Type": "application/json;charset=UTF-8",
};
export const api = {
timeout: 8000,
baseUrl: (import.meta as ImportMetaExtras).env.APP_URL_API,
send: function (options: ApiOptions) {
return new Promise((resolve, reject) => {
let url = this.baseUrl + options.url;
if (options.params) {
let params = "";
for (const [key, value] of Object.entries(options.params)) {
const placeholder = `{${key}}`;
if (url.includes(placeholder)) {
url = url.replace(
placeholder,
encodeURIComponent(value),
);
} else {
params += `&${encodeURIComponent(
key,
)}=${encodeURIComponent(value)}`;
}
}
url = url.replace(/{(.*?)}/g, "$1");
if (params.length > 0) {
url += (url.includes("?") ? "" : "?") + params.substring(1);
}
}
options.headers = {
...apiDefaultHeaders,
...(options.headers || {}),
};
const userStore = useUserStore();
if (userStore.id) {
options.headers["Authorization"] = `Bearer ${userStore.token}`;
}
options.method = options.method.toUpperCase() || "GET";
if (options.body && typeof options.body === "object") {
if (options.body instanceof FormData) {
if (
Object.prototype.hasOwnProperty.call(
options.headers,
"Content-Type",
)
) {
// remove the "Content-Type" key from the headers object
delete options.headers["Content-Type"];
}
if (options.method != "POST") {
options.body.append("_method", options.method);
options.method = "POST";
}
} else if (
options.body instanceof Blob ||
options.body instanceof ArrayBuffer
) {
// do nothing, let XHR handle these types of bodies without a Content-Type header
} else {
options.body = JSON.stringify(options.body);
options.headers["Content-Type"] = "application/json";
}
}
if (
(options.method == "POST" ||
options.method == "PUT" ||
options.method == "PATCH") &&
options.progress
) {
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = function (event) {
if (event.lengthComputable) {
options.progress({
loaded: event.loaded,
total: event.total,
});
}
};
xhr.open(options.method, url);
for (const header in options.headers) {
xhr.setRequestHeader(header, options.headers[header]);
}
xhr.onload = function () {
const result = {
status: xhr.status,
statusText: xhr.statusText,
url: url,
headers: {},
data: "",
};
const headersString = xhr.getAllResponseHeaders();
const headersArray = headersString.trim().split("\n");
headersArray.forEach((header) => {
const [name, value] = header.trim().split(":");
result.headers[name] = value.trim();
});
if (
xhr.response &&
result.headers["content-type"] == "application/json"
) {
try {
result.data = JSON.parse(xhr.response);
} catch (error) {
result.data = xhr.response;
}
} else {
result.data = xhr.response;
}
useApplicationStore().unavailable = false;
if (xhr.status < 300) {
if (options.callback) {
options.callback(result);
} else {
resolve(result);
}
return;
} else {
if (xhr.status == 503) {
useApplicationStore().unavailable = true;
}
if (options.callback) {
options.callback(result);
} else {
reject(result);
}
return;
}
};
try {
xhr.send(options.body as XMLHttpRequestBodyInit);
} catch (e) {
console.log(e);
}
} else {
const fetchOptions: RequestInit = {
method: options.method.toUpperCase() || "GET",
headers: options.headers,
signal: options.signal || null,
};
if (
(typeof options.body == "string" &&
options.body.length > 0) ||
options.body instanceof FormData
) {
fetchOptions.body = options.body;
}
if (fetchOptions.method == "GET" && options.callback) {
const cache = useCacheStore().getCacheByUrl(url);
if (cache != null) {
options.callback(cache);
}
}
fetch(url, fetchOptions)
.then(async (response) => {
let data: string | object = "";
if (response.headers.get("content-length") !== "0") {
if (
response &&
response.headers.get("content-type") == null
) {
try {
data = response.json
? await response.json()
: {};
} catch (error) {
try {
data = response.text
? await response.text()
: "";
} catch (error) {
data = "";
}
}
} else {
data =
response && response.json
? await response.json()
: {};
}
}
const result = {
status: response.status,
statusText: response.statusText,
url: response.url,
headers: response.headers,
data: data,
};
useApplicationStore().unavailable = false;
if (response.status >= 300) {
if (response.status === 503) {
useApplicationStore().unavailable = true;
}
if (options.callback) {
options.callback(result);
} else {
reject(result);
}
return;
}
if (options.callback) {
if (fetchOptions.method == "GET") {
const modified = useCacheStore().updateCache(
url,
result,
);
if (modified == false) {
return;
}
}
options.callback(result);
return;
}
resolve(result);
})
.catch((error) => {
// Handle any errors thrown during the fetch process
const { response, ...rest } = error;
const result = {
...rest,
response: response && response.json(),
};
if (options.callback) {
options.callback(result);
} else {
reject(result);
}
return;
});
}
});
},
get: async function (options: ApiOptions | string): Promise<ApiResponse> {
let apiOptions = {} as ApiOptions;
if (typeof options == "string") {
apiOptions.url = options;
} else {
apiOptions = options;
}
apiOptions.method = "GET";
return await this.send(apiOptions);
},
post: async function (options: ApiOptions | string): Promise<ApiResponse> {
let apiOptions = {} as ApiOptions;
if (typeof options == "string") {
apiOptions.url = options;
} else {
apiOptions = options;
}
apiOptions.method = "POST";
return await this.send(apiOptions);
},
put: async function (options: ApiOptions | string): Promise<ApiResponse> {
let apiOptions = {} as ApiOptions;
if (typeof options == "string") {
apiOptions.url = options;
} else {
apiOptions = options;
}
apiOptions.method = "PUT";
return await this.send(apiOptions);
},
delete: async function (
options: ApiOptions | string,
): Promise<ApiResponse> {
let apiOptions = {} as ApiOptions;
if (typeof options == "string") {
apiOptions.url = options;
} else {
apiOptions = options;
}
apiOptions.method = "DELETE";
return await this.send(apiOptions);
},
chunk: async function (options: ApiOptions | string): Promise<ApiResponse> {
let apiOptions = {} as ApiOptions;
// setup api options
if (typeof options == "string") {
apiOptions.url = options;
} else {
apiOptions = options;
}
// set method to post by default
if (!Object.prototype.hasOwnProperty.call(apiOptions, "method")) {
apiOptions.method = "POST";
}
// check for chunk option
if (
Object.prototype.hasOwnProperty.call(apiOptions, "chunk") &&
Object.prototype.hasOwnProperty.call(apiOptions, "body") &&
apiOptions.body instanceof FormData
) {
if (apiOptions.body.has(apiOptions.chunk)) {
const file = apiOptions.body.get(apiOptions.chunk);
if (file instanceof File) {
const chunkSize = 2 * 1024 * 1024;
let chunk = 0;
let chunkCount = 1;
let job_id = -1;
if (file.size > chunkSize) {
chunkCount = Math.ceil(file.size / chunkSize);
}
let result = null;
for (chunk = 0; chunk < chunkCount; chunk++) {
const offset = chunk * chunkSize;
const fileChunk = file.slice(
offset,
offset + chunkSize,
);
const chunkFormData = new FormData();
if (job_id == -1) {
for (const [field, value] of apiOptions.body) {
chunkFormData.append(field, value);
}
chunkFormData.append("name", file.name);
chunkFormData.append("size", file.size.toString());
chunkFormData.append("mime_type", file.type);
} else {
chunkFormData.append("job_id", job_id.toString());
}
chunkFormData.set(apiOptions.chunk, fileChunk);
chunkFormData.append("chunk", (chunk + 1).toString());
chunkFormData.append(
"chunk_count",
chunkCount.toString(),
);
const chunkOptions = {
method: apiOptions.method,
url: apiOptions.url,
params: apiOptions.params || {},
body: chunkFormData,
headers: apiOptions.headers || {},
progress: (progressEvent) => {
if (
Object.prototype.hasOwnProperty.call(
apiOptions,
"progress",
)
) {
apiOptions.progress({
loaded:
chunk * chunkSize +
progressEvent.loaded,
total: file.size,
});
}
},
};
result = await this.send(chunkOptions);
job_id = result.data.media_job.id;
}
return result;
}
}
}
return await this.send(apiOptions);
},
};
/**
* Get an api result data as type.
* @param result The api result object.
* @param defaultValue The default data to return if no result exists.
* @returns Data object.
*/
export function getApiResultData<T>(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
result: any,
defaultValue: T | null = null,
): T | null {
if (!result || !Object.prototype.hasOwnProperty.call(result, "data")) {
return defaultValue;
}
const data = result.data as T;
return data instanceof Object ? data : defaultValue;
}

View File

@@ -1,190 +0,0 @@
export type Booleanish = boolean | "true" | "false";
export type EmptyObject = { [key: string]: never };
export interface SessionRequest {
id: number;
session_id: number;
type: string;
path: string;
created_at: string;
updated_at: string;
}
export interface Session {
id: number;
ip: string;
useragent: string;
created_at: string;
updated_at: string;
ended_at: string;
requests?: SessionRequest[];
}
export interface SessionCollection {
sessions: Session[];
total: number;
}
export interface SessionRequestCollection {
session: Session;
}
export interface Event {
id: string;
title: string;
hero: Media;
content: string;
start_at: string;
end_at: string;
publish_at: string;
location: string;
location_url: string;
address: string;
status: string;
registration_type: string;
registration_data: string;
price: string;
ages: string;
attachments: Array<Media>;
created_at: string;
updated_at: string;
}
export interface EventResponse {
event: Event;
}
export interface EventCollection {
events: Event[];
total: number;
}
export interface Media {
id: string;
user_id: string;
title: string;
name: string;
mime_type: string;
security_type: string;
size: number;
storage: string;
url: string;
thumbnail: string;
description: string;
dimensions: string;
variants: { [key: string]: string };
created_at: string;
updated_at: string;
jobs: Array<MediaJob>;
}
export interface MediaResponse {
medium: Media;
}
export interface MediaCollection {
media: Array<Media>;
total: number;
}
export interface MediaJob {
id: string;
media_id: string;
user_id: string;
status: string;
status_text: string;
progress: number;
progress_max: number;
}
export interface MediaJobResponse {
media_job: MediaJob;
}
export interface Article {
id: string;
title: string;
slug: string;
user_id: string;
user: User;
content: string;
publish_at: string;
hero: Media;
gallery: Array<Media>;
attachments: Array<Media>;
}
export interface Article {
id: string;
title: string;
slug: string;
user: User;
content: string;
publish_at: string;
hero: Media;
attachments: Array<Media>;
created_at: string;
updated_at: string;
}
export interface ArticleResponse {
article: Article;
}
export interface ArticleCollection {
articles: Array<Article>;
total: number;
}
export interface User {
id: string;
username: string;
email: string;
first_name: string;
last_name: string;
phone: string;
display_name: string;
}
export interface UserResponse {
user: User;
}
export interface UserCollection {
users: Array<User>;
total: number;
}
export interface LoginResponse {
user: User;
token: string;
}
export interface LogsDiscordResponse {
log: {
output: string;
error: string;
};
}
export interface Shortlink {
id: number;
code: string;
url: string;
used: number;
}
export interface ShortlinkCollection {
shortlinks: Array<Shortlink>;
total: number;
}
export interface ShortlinkResponse {
shortlink: Shortlink;
}
export interface ApiInfo {
version: string;
max_upload_size: number;
}

View File

@@ -1,24 +0,0 @@
/**
* Test if array has a match using basic search (* means anything)
*
* @param {Array<string>} arr The array to search.
* @param {string} str The string to find.
* @returns {boolean} if the array has the string.
*/
export const arrayHasBasicMatch = (
arr: Array<string>,
str: string
): boolean => {
let matches = false;
arr.every((elem) => {
elem = elem.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
const regex = new RegExp("^" + elem.replace("*", ".*?") + "$", "i");
if (str.match(regex)) {
matches = true;
}
return !matches;
});
return matches;
};

View File

@@ -1,462 +0,0 @@
export class SMDate {
date: Date | null = null;
dayString: string[] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
fullDayString: string[] = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
monthString: string[] = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
fullMonthString: string[] = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
constructor(
dateOrString: string | Date = "",
options: { format?: string; utc?: boolean } = {},
) {
this.date = null;
if (typeof dateOrString === "string") {
if (dateOrString.length > 0) {
this.parse(dateOrString, options);
}
} else if (
dateOrString instanceof Date &&
!Number.isNaN(dateOrString.getTime())
) {
this.date = dateOrString;
}
}
/**
* Parse a string date into a Date object
* @param {string} dateString The date string.
* @param {object} options (optional) Options object.
* @param {string} options.format (optional) The format of the date string.
* @param {boolean} options.utc (optional) The date string is UTC.
* @returns {SMDate} SMDate object.
*/
public parse(
dateString: string,
{ format = "dmy", utc = false } = {},
): SMDate {
const now = new Date();
let time = "";
if (dateString.toLowerCase() === "now") {
this.date = now;
return this;
}
// Cache regular expressions
const isoDateRegex =
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,10})?Z$/i;
const timeRegex =
/^(\d+)(?::(\d+))?(?::(\d+))? ?(am?|a\.m\.|pm?|p\.m\.)?$/i;
// Test if the dateString is in ISO 8601
if (isoDateRegex.test(dateString)) {
format = "YMd";
[dateString, time] = dateString.split("T");
time = time.slice(0, -8);
}
// Split the date string into an array of components based on the length of each date component
const components = dateString.split(/[ /-]/);
const [day, month, year] =
format === "dmy"
? components
: format === "mdy"
? [components[1], components[0], components[2]]
: [components[2], components[1], components[0]];
if (year === undefined || year.length === 3 || year.length >= 5) {
return this;
}
// numeric
for (const component of [day, month, year]) {
if (isNaN(parseInt(component))) {
return this;
}
}
const parsedDay = parseInt(day.padStart(2, "0"), 10);
const parsedMonth = this.getMonthAsNumber(month);
const parsedYear = parseInt(year.padStart(4, "20"), 10);
let parsedHours: number = 0,
parsedMinutes: number = 0,
parsedSeconds: number = 0;
if (time.length == 0 && components.length > 3) {
time = components.slice(3).join(" ");
}
const parsedTime = timeRegex.exec(time);
if (time && parsedTime) {
const [_, hourStr, minuteStr, secondStr, ampm] = parsedTime;
parsedHours = parseInt(hourStr);
parsedMinutes = parseInt(minuteStr || "0");
parsedSeconds = parseInt(secondStr || "0");
if (parsedHours < 0 || parsedHours > 23) {
return this;
}
if (ampm) {
if (/pm/i.test(ampm) && parsedHours < 12) {
parsedHours += 12;
} else if (/am/i.test(ampm) && parsedHours === 12) {
parsedHours = 0;
}
}
if (
parsedMinutes < 0 ||
parsedMinutes > 59 ||
parsedSeconds < 0 ||
parsedSeconds > 59
) {
return this;
}
time = `${parsedHours.toString().padStart(2, "0")}:${parsedMinutes
.toString()
.padStart(2, "0")}:${parsedSeconds
.toString()
.padStart(2, "0")}`;
} else {
time = "00:00:00";
}
const date = utc
? new Date(
Date.UTC(
parsedYear,
parsedMonth - 1,
parsedDay,
parsedHours,
parsedMinutes,
parsedSeconds,
),
)
: new Date(
parsedYear,
parsedMonth - 1,
parsedDay,
parsedHours,
parsedMinutes,
parsedSeconds,
);
if (isNaN(date.getTime())) {
return this;
}
if (utc) {
const isoDate = date.toISOString();
const checkYear = parseInt(isoDate.substring(0, 4), 10);
const checkMonth = parseInt(isoDate.substring(5, 7), 10);
const checkDay = new Date(isoDate).getUTCDate();
const checkHours = parseInt(isoDate.substring(11, 13), 10);
const checkMinutes = parseInt(isoDate.substring(14, 16), 10);
const checkSeconds = parseInt(isoDate.substring(17, 19), 10);
if (
checkYear !== parsedYear ||
checkMonth !== parsedMonth ||
checkDay !== parsedDay ||
checkHours !== parsedHours ||
checkMinutes !== parsedMinutes ||
checkSeconds !== parsedSeconds
) {
return this;
}
} else {
if (
date.getFullYear() !== parsedYear ||
date.getMonth() + 1 !== parsedMonth ||
date.getDate() !== parsedDay ||
date.getHours() !== parsedHours ||
date.getMinutes() !== parsedMinutes ||
date.getSeconds() !== parsedSeconds
) {
return this;
}
}
this.date = date;
return this;
}
/**
* Format the date to a string.
* @param {string} format The format to return.
* @param {object} options (optional) Function options.
* @param {boolean} options.utc (optional) Format the date to be as UTC instead of local.
* @returns {string} The formatted date.
*/
public format(format: string, options: { utc?: boolean } = {}): string {
if (this.date == null) {
return "";
}
let result = format;
let year: string,
month: string,
date: string,
day: number,
hour: string,
min: string,
sec: string;
if (options.utc) {
const isoDate = this.date.toISOString();
year = isoDate.substring(0, 4);
month = isoDate.substring(5, 7);
date = isoDate.substring(8, 10);
day = new Date(isoDate).getUTCDay();
hour = isoDate.substring(11, 13);
min = isoDate.substring(14, 16);
sec = isoDate.substring(17, 19);
} else {
year = this.date.getFullYear().toString();
month = (this.date.getMonth() + 1).toString();
date = this.date.getDate().toString();
day = this.date.getDay();
hour = this.date.getHours().toString();
min = this.date.getMinutes().toString();
sec = this.date.getSeconds().toString();
}
const apm = parseInt(hour, 10) >= 12 ? "pm" : "am";
/* eslint-disable indent */
const apmhours = (
parseInt(hour, 10) > 12
? parseInt(hour, 10) - 12
: parseInt(hour, 10) == 0
? 12
: parseInt(hour, 10)
).toString();
/* eslint-enable indent */
// year
result = result.replace(/\byy\b/g, year.slice(-2));
result = result.replace(/\byyyy\b/g, year);
// month
result = result.replace(/\bM\b/g, month);
result = result.replace(/\bMM\b/g, (0 + month).slice(-2));
result = result.replace(
/\bMMM\b/g,
this.monthString[parseInt(month) - 1],
);
result = result.replace(
/\bMMMM\b/g,
this.fullMonthString[parseInt(month) - 1],
);
// day
result = result.replace(/\bd\b/g, date);
result = result.replace(/\bdd\b/g, (0 + date).slice(-2));
result = result.replace(/\bEEE\b/g, this.dayString[day]);
result = result.replace(/\bEEEE\b/g, this.fullDayString[day]);
// hour
result = result.replace(/\bH\b/g, hour);
result = result.replace(/\bHH\b/g, (0 + hour).slice(-2));
result = result.replace(/\bh\b/g, apmhours);
result = result.replace(/\bhh\b/g, (0 + apmhours).slice(-2));
// min
result = result.replace(/\bm\b/g, min);
result = result.replace(/\bmm\b/g, (0 + min).slice(-2));
// sec
result = result.replace(/\bs\b/g, sec);
result = result.replace(/\bss\b/g, (0 + sec).slice(-2));
// am/pm
result = result.replace(/\baa\b/g, apm);
result = result.replace(/\bAA\b/g, apm.toUpperCase());
return result;
}
/**
* Return a relative date string from now.
* @returns {string} A relative date string.
*/
public relative(): string {
if (this.date === null) {
return "";
}
const now = new Date();
let dif = Math.round((now.getTime() - this.date.getTime()) / 1000);
const format = dif < 0 ? "in %" : "% ago";
dif = Math.abs(dif);
if (dif < 60) {
return "Just now";
} else if (dif < 3600) {
const v = Math.round(dif / 60);
return format.replace("%", `${v} min${v != 1 ? "s" : ""}`);
} else if (dif < 86400) {
const v = Math.round(dif / 3600);
return format.replace("%", `${v} hour${v != 1 ? "s" : ""}`);
} else if (dif < 604800) {
const v = Math.round(dif / 86400);
return format.replace("%", `${v} day${v != 1 ? "s" : ""}`);
} else if (dif < 2419200) {
const v = Math.round(dif / 604800);
return format.replace("%", `${v} week${v != 1 ? "s" : ""}`);
} else {
return (
this.monthString[this.date.getMonth()] +
" " +
this.date.getDate() +
", " +
this.date.getFullYear()
);
}
}
/**
* If the date is before the passed date.
* @param {Date|SMDate} d (optional) The date to check. If none, use now
* @returns {boolean} If the date is before the passed date.
*/
public isBefore(d: Date | SMDate = new SMDate("now")): boolean {
const otherDate = d instanceof SMDate ? d.date : d;
if (otherDate == null) {
return false;
}
if (this.date == null) {
return true;
}
return otherDate > this.date;
}
/**
* If the date is after the passed date.
* @param {Date|SMDate} d (optional) The date to check. If none, use now
* @returns {boolean} If the date is after the passed date.
*/
public isAfter(d: Date | SMDate = new SMDate("now")): boolean {
const otherDate = d instanceof SMDate ? d.date : d;
if (otherDate == null) {
return false;
}
if (this.date == null) {
return true;
}
return otherDate < this.date;
}
/**
* Return a month number from a string or a month number or month name
* @param {string} monthString The month string as number or name
* @returns {number} The month number
*/
private getMonthAsNumber(monthString: string): number {
const months = this.fullMonthString.map((month) => month.toLowerCase());
const shortMonths = months.map((month) => month.slice(0, 3));
const monthIndex = months.indexOf(monthString.toLowerCase());
if (monthIndex !== -1) {
return monthIndex + 1;
}
const shortMonthIndex = shortMonths.indexOf(monthString.toLowerCase());
if (shortMonthIndex !== -1) {
return shortMonthIndex + 1;
}
const monthNumber = parseInt(monthString, 10);
if (!isNaN(monthNumber) && monthNumber >= 1 && monthNumber <= 12) {
return monthNumber;
}
return 0;
}
/**
* Test if the current date is valid.
* @returns {boolean} If the current date is valid.
*/
public isValid(): boolean {
return this.date !== null;
}
/**
* Return a string with only the first occurrence of characters
* @param {string} str The string to modify.
* @param {string} characters The characters to use to test.
* @returns {string} A string that only contains the first occurrence of the characters.
*/
private onlyFirstOccurrence(
str: string,
characters: string = "dMy",
): string {
let findCharacters = characters.split("");
const replaceRegex = new RegExp("[^" + characters + "]", "g");
let result = "";
str = str.replace(replaceRegex, "");
if (str.length > 0) {
str.split("").forEach((strChar) => {
if (
findCharacters.length > 0 &&
findCharacters.includes(strChar)
) {
result += strChar;
const index = findCharacters.findIndex(
(findChar) => findChar === strChar,
);
if (index !== -1) {
findCharacters = findCharacters
.slice(0, index)
.concat(findCharacters.slice(index + 1));
}
}
});
}
return result;
}
}

View File

@@ -1,27 +0,0 @@
type DebounceCallback = (...args: unknown[]) => void;
type DebounceResult = (...args: unknown[]) => void;
/**
* Call a function after a delay once.
*
* @param {Function} fn The function to call.
* @param {number} delay The delay before calling function.
* @returns {void}
*/
export const debounce = (
fn: DebounceCallback,
delay: number
): DebounceResult => {
let timeoutID: NodeJS.Timeout | null = null;
return (...args) => {
if (timeoutID != null) {
clearTimeout(timeoutID);
}
// eslint-disable-next-line @typescript-eslint/no-this-alias
const that = this;
timeoutID = setTimeout(function () {
fn.apply(that, args);
}, delay);
};
};

View File

@@ -1,221 +0,0 @@
import { ApiResponse } from "./api";
import {
createValidationResult,
defaultValidationResult,
ValidationObject,
ValidationResult,
} from "./validate";
type FormObjectValidateFunction = (item?: string | null) => Promise<boolean>;
type FormObjectLoadingFunction = (state?: boolean) => boolean;
type FormObjectMessageFunction = (
message?: string,
type?: string,
icon?: string,
) => void;
type FormObjectErrorFunction = (message: string) => void;
type FormObjectApiErrorsFunction = (
apiErrors: ApiResponse,
callback?: (error: string, status: number) => void,
) => void;
export interface FormObject {
validate: FormObjectValidateFunction;
loading: FormObjectLoadingFunction;
message: FormObjectMessageFunction;
error: FormObjectErrorFunction;
apiErrors: FormObjectApiErrorsFunction;
_loading: boolean;
_message: string;
_messageType: string;
_messageIcon: string;
controls: { [key: string]: FormControlObject };
}
const defaultFormObject: FormObject = {
validate: async function (item = null) {
const keys = item ? [item] : Object.keys(this.controls);
let valid = true;
await Promise.all(
keys.map(async (key) => {
if (
typeof this.controls[key] == "object" &&
Object.keys(this.controls[key]).includes("validation")
) {
const validationResult = await this.controls[
key
].validation.validator.validate(this.controls[key].value);
this.controls[key].validation.result = validationResult;
if (!validationResult.valid) {
valid = false;
}
}
}),
);
return valid;
},
loading: function (state = undefined) {
if (state !== undefined) {
this._loading = state;
}
return this._loading;
},
message: function (message = "", type = "", icon = "") {
this._message = message;
if (type.length > 0) {
this._messageType = type;
}
if (icon.length > 0) {
this._messageIcon = icon;
}
},
error: function (message = "") {
if (message == "") {
this.message("");
} else {
this.message(message, "error", "alert-circle-outline");
}
},
apiErrors: function (
apiResponse: ApiResponse,
callback?: (error: string, status: number) => void,
) {
let foundKeys = false;
if (
apiResponse.data &&
typeof apiResponse.data === "object" &&
"errors" in apiResponse.data
) {
const errors = apiResponse.data.errors as Record<string, string>;
Object.keys(errors).forEach((key) => {
if (
typeof this.controls[key] === "object" &&
Object.keys(this.controls[key]).includes("validation")
) {
foundKeys = true;
this.controls[key].validation.result =
createValidationResult(false, errors[key]);
}
});
}
if (foundKeys == false) {
const errorMessage =
(apiResponse?.json?.message as string) ||
"An unknown server error occurred.\nPlease try again later.";
if (callback) {
callback(errorMessage, apiResponse.status);
} else {
this.error(errorMessage);
}
}
},
controls: {},
_loading: false,
_message: "",
_messageType: "primary",
_messageIcon: "",
};
/**
* Create a new Form object.
* @param {Record<string, FormControlObject>} controls The controls included in the form.
* @returns {FormObject} Returns a form object.
*/
export const Form = (
controls: Record<string, FormControlObject>,
): FormObject => {
const form = { ...defaultFormObject };
form.controls = controls;
form._loading = false;
form._message = "";
form._messageType = "primary";
form._messageIcon = "";
return form;
};
interface FormControlValidation {
validator: ValidationObject;
result: ValidationResult;
}
const getDefaultFormControlValidation = (): FormControlValidation => {
return {
validator: {
validate: async (): Promise<ValidationResult> => {
return defaultValidationResult;
},
},
result: defaultValidationResult,
};
};
type FormControlClearValidations = () => void;
type FormControlSetValidation = (
valid: boolean,
message?: string | Array<string>,
) => void;
type FormControlIsValid = () => boolean;
export interface FormControlObject {
value: unknown;
validate: () => Promise<ValidationResult>;
validation: FormControlValidation;
clearValidations: FormControlClearValidations;
setValidationResult: FormControlSetValidation;
isValid: FormControlIsValid;
}
/**
* Create a new form control object.
* @param {string} value The control name.
* @param {ValidationObject | null} validator The control validation rules.
* @returns {FormControlObject} The form control object.
*/
export const FormControl = (
value: unknown = "",
validator: ValidationObject | null = null,
): FormControlObject => {
return {
value: value,
validation:
validator == null
? getDefaultFormControlValidation()
: {
validator: validator,
result: defaultValidationResult,
},
clearValidations: function () {
this.validation.result = defaultValidationResult;
},
setValidationResult: function (
valid: boolean,
message?: string | Array<string>,
) {
this.validation.result = createValidationResult(valid, message);
},
validate: async function () {
if (this.validation.validator) {
this.validation.result =
await this.validation.validator.validate(this.value);
return this.validation.result;
}
return defaultValidationResult;
},
isValid: function () {
return this.validation.result.valid;
},
};
};

View File

@@ -1,81 +0,0 @@
import { ImportMetaExtras } from "../../../import-meta";
import { urlStripAttributes } from "./url";
type ImageLoadCallback = (url: string) => void;
export const imageLoad = (
url: string,
callback: ImageLoadCallback,
postfix = "size=thumb"
) => {
if (
url.startsWith((import.meta as ImportMetaExtras).env.APP_URL) === true
) {
callback(urlStripAttributes(url) + "?" + postfix);
const tmp = new Image();
tmp.onload = function () {
callback(url);
};
tmp.src = url;
} else {
// Image is not one we control
callback(url);
}
};
export const imageSize = (size: string, url: string) => {
const availableSizes = [
"thumb",
"small",
"medium",
"large",
"xlarge",
"xxlarge",
];
if (availableSizes.includes(size)) {
if (
url.startsWith((import.meta as ImportMetaExtras).env.APP_URL) ===
true ||
url.startsWith("/") === true
) {
return `${url}?size=${size}`;
}
}
return url;
};
// Thumb 150 x 150
export const imageThumb = (url: string) => {
return imageSize("thumb", url);
};
// Small 300 x 300
export const imageSmall = (url: string) => {
return imageSize("small", url);
};
// Small 640 x 640
export const imageMedium = (url: string) => {
return imageSize("medium", url);
};
// Large 1024 x 1024
export const imageLarge = (url: string) => {
return imageSize("large", url);
};
// Large 1536 x 1536
export const imageXLarge = (url: string) => {
return imageSize("xlarge", url);
};
// Large 2560 x 2560
export const imageXXLarge = (url: string) => {
return imageSize("xxlarge", url);
};
// Full size
export const imageFull = (url: string) => {
return imageSize("full", url);
};

View File

@@ -1,309 +0,0 @@
import { ImportMetaExtras } from "../../../import-meta";
import { Media, MediaJob } from "./api.types";
import { strCaseCmp, toTitleCase } from "./string";
export const mediaGetVariantUrl = (
media: Media,
variant = "scaled",
): string => {
if (!media) {
return "";
}
// If the variant is 'original', return the media url
if (variant === "original") {
return media.url;
}
// If the variant key exists in media.variants, return the corresponding variant URL
if (media.variants && media.variants[variant]) {
return media.url.replace(media.name, media.variants[variant]);
}
// If the variant key does not exist, return the 'scaled' variant
return media.variants && media.variants["scaled"]
? media.url.replace(media.name, media.variants["scaled"])
: media.url;
};
/**
* Convert a Media URL to a user friendly URL
* @param {Media|string} mediaOrString Media object or URL string
* @returns {string} User friendly URL
*/
export const mediaGetWebURL = (mediaOrString: Media | string): string => {
const webUrl = (import.meta as ImportMetaExtras).env.APP_URL;
const apiUrl = (import.meta as ImportMetaExtras).env.APP_URL_API;
let url =
typeof mediaOrString === "string"
? mediaOrString
: (mediaOrString as Media).url;
// If the input is a string, use it as the URL directly
if (typeof mediaOrString === "string") {
return url;
}
// Is the URL an API request?
if (url.startsWith(apiUrl)) {
const fileUrlPath = url.substring(apiUrl.length);
const fileUrlParts = fileUrlPath.split("/");
if (
fileUrlParts.length >= 4 &&
fileUrlParts[0].length === 0 &&
strCaseCmp("media", fileUrlParts[1]) === true &&
strCaseCmp("download", fileUrlParts[3]) === true
) {
url = webUrl + "/file/" + fileUrlParts[2];
}
}
return url;
};
/**
* Check if a mime matches.
* @param {string} mimeExpected The mime expected.
* @param {string} mimeToCheck The mime to check.
* @returns {boolean} The mimeToCheck matches mimeExpected.
*/
export const mimeMatches = (
mimeExpected: string,
mimeToCheck: string,
): boolean => {
if (mimeExpected.length == 0) {
mimeExpected = "*";
}
const escapedExpectation = mimeExpected.replace(
/[.*+?^${}()|[\]\\]/g,
"\\$&",
);
const pattern = escapedExpectation.replace(/\\\*/g, ".*");
const regex = new RegExp(`^${pattern}$`);
return regex.test(mimeToCheck);
};
/**
* MediaGetThumbnailCallback Type
*/
export type mediaGetThumbnailCallback = (url: string) => void;
/**
* Get Media/File Thumbnail.
* @param {Media|File} media The Media/File object.
* @param {string|null} useVariant The variable to use.
* @param {mediaGetThumbnailCallback|null} callback Callback with the thumbnail. Required when passing File.
* @returns {string} The thumbnail url.
*/
export const mediaGetThumbnail = (
media: Media | File,
useVariant: string | null = "",
callback: mediaGetThumbnailCallback | null = null,
): string => {
let url: string = "";
if (media) {
if (media instanceof File) {
if (callback != null) {
if (mimeMatches("image/*", media.type) == true) {
const reader = new FileReader();
reader.onload = function (e) {
callback(e.target.result.toString());
};
reader.readAsDataURL(media);
return "";
}
}
} else {
if (
useVariant &&
useVariant != "" &&
useVariant != null &&
media.variants &&
media.variants[useVariant]
) {
url = media.url.replace(media.name, media.variants[useVariant]);
} else if (media.thumbnail && media.thumbnail.length > 0) {
url = media.thumbnail;
} else if (media.variants && media.variants["thumb"]) {
url = media.url.replace(media.name, media.variants["thumb"]);
}
}
if (url === "") {
url = "/assets/fileicons/unknown.webp";
}
}
if (callback != null) {
callback(url);
return "";
}
return url;
};
/**
* Check if the media is currently busy.
* @param {Media} media The media item to check.
* @returns {boolean} If the media is busy.
*/
export const mediaIsBusy = (media: Media): boolean => {
let busy = false;
if (media.jobs) {
media.jobs.forEach((item) => {
if (
item.status != "invalid" &&
item.status != "complete" &&
item.status != "failed"
) {
busy = true;
}
});
}
return busy;
};
interface MediaStatus {
busy: boolean;
status: string;
status_text: string;
progress: number;
}
/**
* Get the current Media status
* @param {Media} media The media item to check.
* @returns {MediaStatus} The media status.
*/
export const getMediaStatus = (media: Media): MediaStatus => {
const status = {
busy: false,
status: "",
status_text: "",
progress: 0,
};
if (media.jobs) {
for (const item of media.jobs) {
if (
item.status != "invalid" &&
item.status != "complete" &&
item.status != "failed"
) {
status.busy = true;
status.status = item.status;
status.status_text = item.status_text;
status.progress = item.progress;
break;
}
}
}
return status;
};
/**
* Get the current Media status Text
* @param {Media} media The media item to check.
* @returns {string} Human readable string.
*/
export const getMediaStatusText = (media: Media): string => {
let status = "";
if (media.jobs.length > 0) {
if (
media.jobs[0].status != "invalid" &&
media.jobs[0].status != "failed" &&
media.jobs[0].status != "complete"
) {
if (media.jobs[0].status_text != "") {
status = toTitleCase(media.jobs[0].status_text);
} else {
status = toTitleCase(media.jobs[0].status);
}
if (media.jobs[0].progress_max != 0) {
status += ` ${Math.floor(
(media.jobs[0].progress / media.jobs[0].progress_max) * 100,
)}%`;
}
}
}
return status;
};
export interface MediaParams {
id?: string;
user_id?: string;
title?: string;
name?: string;
mime_type?: string;
permission?: string;
size?: number;
storage?: string;
url?: string;
thumbnail?: string;
description?: string;
dimensions?: string;
variants?: { [key: string]: string };
created_at?: string;
updated_at?: string;
jobs?: Array<MediaJob>;
}
export interface MediaJobParams {
id?: string;
media_id?: string;
user_id?: string;
status?: string;
status_text?: string;
progress?: number;
progress_max?: number;
}
export const createMediaItem = (params?: MediaParams): Media => {
const media = {
id: params.id || "",
user_id: params.user_id || "",
title: params.title || "",
name: params.name || "",
mime_type: params.mime_type || "",
permission: params.permission || "",
size: params.size !== undefined ? params.size : 0,
storage: params.storage || "",
url: params.url || "",
thumbnail: params.thumbnail || "",
description: params.description || "",
dimensions: params.dimensions || "",
variants: params.variants || {},
created_at: params.created_at || "",
updated_at: params.updated_at || "",
jobs: params.jobs || [],
};
return media;
};
export const createMediaJobItem = (params?: MediaJobParams): MediaJob => {
const job = {
id: params.id || "",
media_id: params.media_id || "",
user_id: params.user_id || "",
status: params.status || "",
status_text: params.status_text || "",
progress: params.progress || 0,
progress_max: params.progress_max || 0,
};
return job;
};

View File

@@ -1,29 +0,0 @@
/**
* Sort a objects properties alphabetically
*
* @param {Record<string, unknown>} obj The object to sort
* @returns {Record<string, unknown>} The object sorted
*/
export const sortProperties = (
obj: Record<string, unknown>
): Record<string, unknown> => {
// convert object into array
const sortable: [string, unknown][] = [];
for (const key in obj)
if (Object.prototype.hasOwnProperty.call(obj, key))
sortable.push([key, obj[key]]); // each item is an array in format [key, value]
// sort items by value
sortable.sort(function (a, b) {
const x = String(a[1]).toLowerCase(),
y = String(b[1]).toLowerCase();
return x < y ? -1 : x > y ? 1 : 0;
});
const sortedObj: Record<string, unknown> = {};
sortable.forEach((item) => {
sortedObj[item[0]] = item[1];
});
return sortedObj; // array in format [ [ key1, val1 ], [ key2, val2 ], ... ]
};

View File

@@ -1,53 +0,0 @@
export interface SEOTags {
title: string;
description: string;
keywords: string[];
robots: {
index: boolean;
follow: boolean;
};
url: string;
image: string;
}
export const updateSEOTags = (tags: SEOTags): void => {
const updateTag = (
tag: string,
queryAttribName: string,
queryAttribValue: string,
updateAttribName: string,
updateAttribValue: string
) => {
const existingTag = document.querySelector(
`${tag}[${queryAttribName}="${queryAttribValue}"]`
);
if (existingTag) {
existingTag.setAttribute(updateAttribName, updateAttribValue);
} else {
const metaTag = document.createElement(tag);
metaTag.setAttribute(queryAttribName, queryAttribValue);
metaTag.setAttribute(updateAttribName, updateAttribValue);
document.head.appendChild(metaTag);
}
};
const robotsIndexValue = tags.robots.index ? "index" : "noindex";
const robotsFollowValue = tags.robots.follow ? "follow" : "nofollow";
const robotsValue = `${robotsIndexValue}, ${robotsFollowValue}`;
document.title = `STEMMechanics | ${tags.title}`;
updateTag("meta", "name", "description", "content", tags.description);
updateTag("meta", "name", "keywords", "content", tags.keywords.join(", "));
updateTag("meta", "name", "robots", "content", robotsValue);
updateTag("link", "rel", "canonical", "href", tags.url);
updateTag("meta", "property", "og:title", "content", tags.title);
updateTag(
"meta",
"property",
"og:description",
"content",
tags.description
);
updateTag("meta", "property", "og:image", "content", tags.image);
updateTag("meta", "property", "og:url", "content", tags.url);
};

View File

@@ -1,36 +0,0 @@
const appId = "sandbox-sq0idb-FYI93DDPJk0wJvaU0ye4MQ";
const locationId = "LQ0C6GMZEWVQ0";
const square = null;
export const initCard = (): Object => {
const scriptSrc = "https://sandbox.web.squarecdn.com/v1/square.js";
if (!document.querySelector(`script[src="${scriptSrc}"]`)) {
const script = document.createElement("script");
script.type = "text/javascript";
script.src = scriptSrc;
script.onload = async () => {
if (!window.Square) {
console.log("Square failed to load properly");
}
let payments;
try {
payments = window.Square.payments(appId, locationId);
} catch (e) {
console.log("Square: Missing credentials", e);
return;
}
let card;
try {
card = await payments.card();
await card.attach("#card-container");
} catch (e) {
console.error("Initializing Card failed", e);
return;
}
};
document.head.appendChild(script);
}
};

View File

@@ -1,129 +0,0 @@
/**
* Transforms a string to title case.
* @param {string} str The string to transform.
* @returns {string} A string transformed to title case.
*/
export const toTitleCase = (str: string): string => {
// Replace underscores and hyphens with spaces
str = str.replace(/[_-]+/g, " ");
// Capitalize the first letter of each word and make the rest lowercase
str = str.replace(/\b\w+\b/g, (txt) => {
return txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase();
});
// Replace "cdn" with "CDN"
str = str.replace(/\bCdn\b/gi, "CDN");
return str;
};
/**
* Convert a string to a excerpt.
* @param {string} txt The text to convert.
* @param {number} maxLen (optional) The maximum length of the excerpt.
* @param {boolean} strip (optional) Strip HTML tags from the text.
* @param stripHtml
* @returns {string} The excerpt.
*/
export function excerpt(
txt: string,
maxLen: number = 150,
stripHtml: boolean = true,
): string {
if (stripHtml) {
txt = txt.replace(/<[^>]+>/g, "").replace(/&nbsp;/g, " ");
}
const words = txt.trim().split(/\s+/);
let curLen = 0;
const excerptWords: string[] = [];
for (const word of words) {
if (curLen + word.length + 1 > maxLen) {
break;
}
curLen += word.length + 1;
excerptWords.push(word);
}
let excerpt = excerptWords.join(" ");
if (curLen < txt.length) {
excerpt += "...";
}
return excerpt;
}
/**
* String HTML tags from text.
* @param {string} txt The text to strip tags.
* @returns {string} The stripped text.
*/
export const stripHtmlTags = (txt: string): string => {
return txt.replace(/<(p|br)([ /]*?>|[ /]+.*?>)|<[a-zA-Z/][^>]+(>|$)/g, " ");
};
/**
* Replace HTML entities with real characters.
* @param {string} txt The text to transform.
* @returns {string} Transformed text
*/
export const replaceHtmlEntities = (txt: string): string => {
const translate_re = /&(nbsp|amp|quot|lt|gt);/g;
return txt.replace(translate_re, function (match, entity) {
switch (entity) {
case "nbsp":
return " ";
case "amp":
return "&";
case "quot":
return '"';
case "lt":
return "<";
case "gt":
return ">";
default:
return match;
}
});
};
/**
* Convert a string to a number, ignoring items like dollar signs, etc.
* @param {string} str The string to convert to a number
* @returns {number} A number with the minimum amount of decimal places (or 0)
*/
export const stringToNumber = (str: string): number => {
str = str.replace(/[^\d.-]/g, "");
const num = parseFloat(str);
return isNaN(num) ? 0 : Number(num.toFixed(2));
};
/**
* Convert a number or string to a price (0 or 0.00).
* @param {number|string} numOrString The number of string to convert to a price.
* @returns {string} The converted result.
*/
export const toPrice = (numOrString: number | string): string => {
const num =
typeof numOrString === "string"
? stringToNumber(numOrString)
: numOrString;
return num.toFixed(num % 1 === 0 ? 0 : 2);
};
/**
* Compare 2 strings case insensitive
* @param {string} string1 The first string for comparison.
* @param {string} string2 The second string for comparison.
* @returns {boolean} If the strings match.
*/
export const strCaseCmp = (string1: string, string2: string): boolean => {
if (string1 !== undefined && string2 !== undefined) {
return string1.toLowerCase() === string2.toLowerCase();
}
return false;
};

View File

@@ -1,131 +0,0 @@
import { Ref } from "vue";
/**
* Return the browser transiton end name.
*
* @returns {string} The browser transition end name.
*/
const transitionEndEventName = (): string => {
const el = document.createElement("div"),
transitions: Record<string, string> = {
transition: "transitionend",
OTransition: "otransitionend",
MozTransition: "transitionend",
WebkitTransition: "webkitTransitionEnd",
};
for (const i in transitions) {
if (
Object.prototype.hasOwnProperty.call(transitions, i) &&
el.style[i] !== undefined
) {
return transitions[i];
}
}
return "";
};
/**
* Wait for the element to render as Promise
*
* @param elem The
* @returns
*/
const waitForElementRender = (elem: Ref): Promise<HTMLElement> => {
return new Promise((resolve) => {
if (document.contains(elem.value)) {
return resolve(elem.value as HTMLElement);
}
/* eslint-disable @typescript-eslint/no-explicit-any */
const MutationObserver =
window.MutationObserver ||
(window as any).WebKitMutationObserver ||
(window as any).MozMutationObserver;
/* eslint-enable @typescript-eslint/no-explicit-any */
const observer = new MutationObserver(() => {
if (document.contains(elem.value)) {
resolve(elem.value);
observer.disconnect();
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
});
};
/**
* Run the enter transition on a element.
*
* @param {Ref} elem The element to run the enter transition.
* @param {string} transition The transition name.
* @returns {void}
*/
export const transitionEnter = (elem: Ref, transition: string): void => {
waitForElementRender(elem)
.then((e: HTMLElement) => {
window.setTimeout(() => {
e.classList.replace(
transition + "-enter-from",
transition + "-enter-active"
);
const transitionName = transitionEndEventName();
e.addEventListener(
transitionName,
() => {
e.classList.replace(
transition + "-enter-active",
transition + "-enter-to"
);
},
false
);
}, 1);
})
.catch(() => {
/* empty */
});
};
/**
* Run the exit transition on a element then call a callback.
*
* @param {Ref} elem The element to run the enter transition.
* @param {string} transition The transition name.
* @param {TransitionLeaveCallback|null} callback The callback to run after the transition finishes.
* @returns {void}
*/
type TransitionLeaveCallback = () => void;
export const transitionLeave = (
elem: Ref,
transition: string,
callback: TransitionLeaveCallback | null = null
): void => {
elem.value.classList.remove(transition + "-enter-to");
elem.value.classList.add(transition + "-leave-from");
window.setTimeout(() => {
elem.value.classList.replace(
transition + "-leave-from",
transition + "-leave-active"
);
const transitionName = transitionEndEventName();
elem.value.addEventListener(
transitionName,
() => {
elem.value.classList.replace(
transition + "-leave-active",
transition + "-leave-to"
);
if (callback) {
callback();
}
},
false
);
}, 1);
};

View File

@@ -1,73 +0,0 @@
/**
* Test if target is a boolean
* @param {unknown} target The varible to test
* @returns {boolean} If the varible is a boolean type
*/
export function isBool(target: unknown): boolean {
return typeof target === "boolean";
}
/**
* Test if target is a number
* @param {unknown} target The varible to test
* @returns {boolean} If the varible is a number type
*/
export function isNumber(target: unknown): boolean {
return typeof target === "number";
}
/**
* Test if target is an object
* @param {unknown} target The varible to test
* @returns {boolean} If the varible is a object type
*/
export function isObject(target: unknown): boolean {
return typeof target === "object" && target !== null;
}
/**
* Test if target is a string
* @param {unknown} target The varible to test
* @returns {boolean} If the varible is a string type
*/
export function isString(target: unknown): boolean {
return typeof target === "string" && target !== null;
}
/**
* Convert bytes to a human readable string.
* @param {number} bytes The bytes to convert.
* @param {number} decimalPlaces The number of places to force.
* @returns {string} The bytes in human readable string.
*/
export const bytesReadable = (
bytes: number,
decimalPlaces: number = undefined,
): string => {
if (Number.isNaN(bytes)) {
return "0 Bytes";
}
if (Math.abs(bytes) < 1024) {
return bytes + " Bytes";
}
const units = ["KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
let u = -1;
const r = 10 ** 1;
let tempBytes = bytes;
while (
Math.round(Math.abs(tempBytes) * r) / r >= 1024 &&
u < units.length - 1
) {
tempBytes /= 1024;
++u;
}
if (decimalPlaces === undefined) {
return tempBytes.toFixed(2).replace(/\.?0+$/, "") + " " + units[u];
}
return tempBytes.toFixed(decimalPlaces) + " " + units[u];
};

View File

@@ -1,147 +0,0 @@
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
export const urlStripAttributes = (url: string): string => {
const urlObject = new URL(url);
urlObject.search = "";
urlObject.hash = "";
return urlObject.toString();
};
export const urlMatches = (
fullUrl: string,
testPath: string | string[],
): boolean | number => {
// Remove query string and fragment identifier from both URLs
const urlWithoutParams = fullUrl.split(/[?#]/)[0];
if (Array.isArray(testPath)) {
// Iterate over the array of test paths and return the index of the first matching path
for (let i = 0; i < testPath.length; i++) {
const pathWithoutParams = testPath[i].split(/[?#]/)[0];
// Remove trailing slashes from both URLs
const trimmedUrl = urlWithoutParams.replace(/\/$/, "");
const trimmedPath = pathWithoutParams.replace(/\/$/, "");
// Check if both URLs contain a domain and port
const hasDomainAndPort =
/^https?:\/\/[^/]+\//.test(trimmedUrl) &&
/^https?:\/\/[^/]+\//.test(trimmedPath);
if (hasDomainAndPort) {
// Do a full test with both URLs
if (trimmedUrl === trimmedPath) {
return i;
}
} else {
// Remove the domain and test the paths
const urlWithoutDomain = trimmedUrl.replace(
/^https?:\/\/[^/]+/,
"",
);
const pathWithoutDomain = trimmedPath.replace(
/^https?:\/\/[^/]+/,
"",
);
if (urlWithoutDomain === pathWithoutDomain) {
return i;
}
}
}
// If no matching path is found, return false
return false;
} else {
const pathWithoutParams = testPath.split(/[?#]/)[0];
// Remove trailing slashes from both URLs
const trimmedUrl = urlWithoutParams.replace(/\/$/, "");
const trimmedPath = pathWithoutParams.replace(/\/$/, "");
// Check if both URLs contain a domain and port
const hasDomainAndPort =
/^https?:\/\/[^/]+\//.test(trimmedUrl) &&
/^https?:\/\/[^/]+\//.test(trimmedPath);
if (hasDomainAndPort) {
// Do a full test with both URLs
return trimmedUrl === trimmedPath;
} else {
// Remove the domain and test the paths
const urlWithoutDomain = trimmedUrl.replace(
/^https?:\/\/[^/]+/,
"",
);
const pathWithoutDomain = trimmedPath.replace(
/^https?:\/\/[^/]+/,
"",
);
return urlWithoutDomain === pathWithoutDomain;
}
}
};
interface Params {
[key: string]: string;
}
export const updateRouterParams = (router: Router, params: Params): void => {
const query = { ...router.currentRoute.value.query };
Object.entries(params).forEach(([key, value]) => {
if (value === "") {
if (key in params) {
delete query[key];
}
} else {
query[key] = value;
}
});
router.push({ query });
};
export const getRouterParam = (
route: RouteLocationNormalizedLoaded,
param: string,
defaultValue: string = "",
): string => {
if (route.query[param] !== undefined) {
const val = route.query[param];
if (Array.isArray(val) == true) {
if (val.length > 0) {
return val[0];
}
return defaultValue;
}
return val.toString();
}
return defaultValue;
};
export const extractFileNameFromUrl = (url: string): string => {
const matches = url.match(/\/([^/]+\.[^/]+)$/);
if (!matches) {
return "";
}
const fileName = matches[1];
return fileName;
};
export const addQueryParam = (
url: string,
name: string,
value: string,
): string => {
const urlObject = new URL(url);
const queryParams = new URLSearchParams(urlObject.search);
if (queryParams.has(name)) {
queryParams.set(name, value);
} else {
// Add the new query parameter
queryParams.append(name, value);
}
urlObject.search = queryParams.toString();
return urlObject.toString();
};

View File

@@ -1,164 +0,0 @@
import { useUserStore } from "../store/UserStore";
import { extractFileNameFromUrl } from "./url";
/**
* Tests if an object or string is empty.
* @param {unknown} value The object or string.
* @returns {boolean} If the object or string is empty.
*/
export const isEmpty = (value: unknown): boolean => {
if (typeof value === "string") {
return value.trim().length === 0;
} else if (
value instanceof File ||
value instanceof Blob ||
value instanceof Map ||
value instanceof Set
) {
return value.size === 0;
} else if (value instanceof FormData) {
return [...value.entries()].length === 0;
} else if (typeof value === "object") {
return !value || Object.keys(value).length === 0;
}
return false;
};
/**
* Returns the file extension
* @param {string} fileName The filename with extension.
* @returns {string} The file extension.
*/
export const getFileExtension = (fileName: string): string => {
if (fileName.includes(".")) {
return fileName.split(".").pop();
}
return "";
};
/**
* Returns a url to a file type icon based on file name.
* @param {string} fileName The filename with extension.
* @returns {string} The url to the file type icon.
*/
export const getFileIconImagePath = (fileName: string): string => {
const ext = getFileExtension(fileName);
if (ext.length > 0) {
return `/assets/fileicons/${ext}.webp`;
}
return "/assets/fileicons/unknown.webp";
};
/**
* Returns a url to a file preview icon based on file url.
* @param {string} url The url of the file.
* @returns {string} The url to the file preview icon.
*/
export const getFilePreview = (url: string): string => {
const ext = getFileExtension(extractFileNameFromUrl(url));
if (ext.length > 0) {
if (/(gif|jpe?g|png)/i.test(ext)) {
return `${url}?size=thumb`;
}
return `/assets/fileicons/${ext}.webp`;
}
return "/assets/fileicons/unknown.webp";
};
/**
* Clamps a number between 2 numbers.
* @param {number} n The number to clamp.
* @param {number} min The minimum allowable number.
* @param {number} max The maximum allowable number.
* @returns {number} The clamped number.
*/
export const clamp = (n: number, min: number, max: number): number => {
if (n < min) return min;
if (n > max) return max;
return n;
};
type RandomIDVerifyCallback = (id: string) => boolean;
/**
* Generate a random ID.
* @param {string} prefix Any prefix to add to the ID.
* @param {number} length The length of the ID string (default = 6).
* @param {RandomIDVerifyCallback|null} callback Callback that if returns true generates a ID string.
* @returns {string} A random string.
*/
export const generateRandomId = (
prefix: string = "",
length: number = 6,
callback: RandomIDVerifyCallback | null = null,
): string => {
let randomId = "";
const letters =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890";
do {
randomId = prefix;
for (let i = 0; i < length; i++) {
randomId += letters.charAt(
Math.floor(Math.random() * letters.length),
);
}
} while (callback != null ? callback(randomId) : false);
return randomId;
};
/**
* Generate a random element ID.
* @param {string} prefix Any prefix to add to the ID.
* @param {number} length The length of the ID string (default = 6).
* @returns {string} A random string non-existent in the document.
*/
export const generateRandomElementId = (
prefix: string = "",
length: number = 6,
): string => {
return generateRandomId(prefix, length, (s) => {
return document.getElementById(s) != null;
});
};
/**
* Return if the current user has a permission.
* @param {string} permission The permission to check.
* @returns {boolean} If the user has the permission.
*/
export const userHasPermission = (permission: string): boolean => {
const userStore = useUserStore();
return userStore.permissions && userStore.permissions.includes(permission);
};
/**
* Convert File Name to Title
* @param {string} fileName The filename with extension.
* @returns {string} The title.
*/
export const convertFileNameToTitle = (fileName: string): string => {
// Remove file extension
fileName = fileName.replace(/\.[^/.]+$/, "");
// Replace underscores with space
fileName = fileName.replace(/_/g, " ");
// Replace dashes that are not surrounded by spaces with space
fileName = fileName.replace(/(?<! )-(?! )/g, " ");
// Remove double spaces
fileName = fileName.replace(/\s{2,}/g, " ");
// Capitalize the first letter and convert to lowercase
fileName =
fileName.charAt(0).toUpperCase() + fileName.slice(1).toLowerCase();
return fileName;
};

View File

@@ -1,24 +0,0 @@
/**
* Test if target is a UUID
*
* @param {string} uuid The variable to test
* @returns {boolean} If the varible is a UUID
*/
export const isUUID = (uuid: string): boolean => {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
uuid
);
};
/**
* Generates a random UUID.
*
* @returns {string} A random UUID.
*/
export const randomUUID = (): string => {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
};

View File

@@ -1,978 +0,0 @@
import { bytesReadable } from "../helpers/types";
import { SMDate } from "./datetime";
import { isEmpty } from "../helpers/utils";
export interface ValidationObject {
validate: (value: unknown) => Promise<ValidationResult>;
}
export interface ValidationResult {
valid: boolean;
invalidMessages: Array<string>;
}
export const defaultValidationResult: ValidationResult = {
valid: true,
invalidMessages: [],
};
export const createValidationResult = (
valid: boolean,
message: string | Array<string> = ""
) => {
if (typeof message == "string") {
message = [message];
}
return {
valid: valid,
invalidMessages: message,
};
};
/**
* Validation Min
*/
const VALIDATION_MIN_TYPE = ["String", "Number"];
type ValidationMinType = (typeof VALIDATION_MIN_TYPE)[number];
interface ValidationMinOptions {
min: number;
type?: ValidationMinType;
invalidMessage?: string | ((options: ValidationMinOptions) => string);
}
interface ValidationMinObject extends ValidationMinOptions {
validate: (value: string) => Promise<ValidationResult>;
}
const defaultValidationMinOptions: ValidationMinOptions = {
min: 1,
type: "String",
invalidMessage: (options: ValidationMinOptions) => {
return options.type == "String"
? `Required to be at least ${options.min} characters.`
: `Required to be at least ${options.min}.`;
},
};
export function Min(
minOrOptions: number | ValidationMinOptions,
options?: ValidationMinOptions
);
export function Min(options: ValidationMinOptions): ValidationMinObject;
/**
* Validate field length or number is at minimum or higher/larger
*
* @param minOrOptions minimum number or options data
* @param options options data
* @returns ValidationMinObject
*/
export function Min(
minOrOptions: number | ValidationMinOptions,
options?: ValidationMinOptions
): ValidationMinObject {
if (typeof minOrOptions === "number") {
options = { ...defaultValidationMinOptions, ...(options || {}) };
options.min = minOrOptions;
} else {
options = { ...defaultValidationMinOptions, ...(minOrOptions || {}) };
}
return {
...options,
validate: function (value: string): Promise<ValidationResult> {
return Promise.resolve({
valid:
this.type == "String"
? value.toString().length >= this.min
: parseInt(value) >= this.min,
invalidMessages: [
typeof this.invalidMessage === "string"
? this.invalidMessage
: this.invalidMessage(this),
],
});
},
};
}
/**
* Validation Max
*/
const VALIDATION_MAX_TYPE = ["String", "Number"];
type ValidationMaxType = (typeof VALIDATION_MAX_TYPE)[number];
interface ValidationMaxOptions {
max: number;
type?: ValidationMaxType;
invalidMessage?: string | ((options: ValidationMaxOptions) => string);
}
interface ValidationMaxObject extends ValidationMaxOptions {
validate: (value: string) => Promise<ValidationResult>;
}
const defaultValidationMaxOptions: ValidationMaxOptions = {
max: 1,
type: "String",
invalidMessage: (options: ValidationMaxOptions) => {
return options.type == "String"
? `Required to be less than ${options.max + 1} characters.`
: `Required to be less than ${options.max + 1}.`;
},
};
export function Max(
maxOrOptions: number | ValidationMaxOptions,
options?: ValidationMaxOptions
): ValidationMaxObject;
export function Max(options: ValidationMaxOptions): ValidationMaxObject;
/**
* Validate field length or number is at maximum or smaller
*
* @param maxOrOptions maximum number or options data
* @param options options data
* @returns ValidationMaxObject
*/
export function Max(
maxOrOptions: number | ValidationMaxOptions,
options?: ValidationMaxOptions
): ValidationMaxObject {
if (typeof maxOrOptions === "number") {
options = { ...defaultValidationMaxOptions, ...(options || {}) };
options.max = maxOrOptions;
} else {
options = { ...defaultValidationMaxOptions, ...(maxOrOptions || {}) };
}
return {
...options,
validate: function (value: string): Promise<ValidationResult> {
return Promise.resolve({
valid:
this.type == "String"
? value.toString().length <= this.max
: parseInt(value) <= this.max,
invalidMessages: [
typeof this.invalidMessage === "string"
? this.invalidMessage
: this.invalidMessage(this),
],
});
},
};
}
/**
* Validation Length
*/
interface ValidationLengthOptions {
length: number;
invalidMessage?: string | ((options: ValidationLengthOptions) => string);
}
interface ValidationLengthObject extends ValidationLengthOptions {
validate: (value: string) => Promise<ValidationResult>;
}
const defaultValidationLengthOptions: ValidationLengthOptions = {
length: 1,
invalidMessage: (options: ValidationLengthOptions) => {
return `Required to be ${options.length} characters.`;
},
};
export function Length(
lengthOrOptions: number | ValidationLengthOptions,
options?: ValidationLengthOptions
): ValidationLengthObject;
export function Length(
options: ValidationLengthOptions
): ValidationLengthObject;
/**
* Validate field length
*
* @param lengthOrOptions string length or options data
* @param options options data
* @returns ValidationLengthObject
*/
export function Length(
lengthOrOptions: number | ValidationLengthOptions,
options?: ValidationLengthOptions
): ValidationLengthObject {
if (typeof lengthOrOptions === "number") {
options = { ...defaultValidationLengthOptions, ...(options || {}) };
options.length = lengthOrOptions;
} else {
options = {
...defaultValidationLengthOptions,
...(lengthOrOptions || {}),
};
}
return {
...options,
validate: function (value: string): Promise<ValidationResult> {
return Promise.resolve({
valid: value.toString().length == this.length,
invalidMessages: [
typeof this.invalidMessage === "string"
? this.invalidMessage
: this.invalidMessage(this),
],
});
},
};
}
/**
* PASSWORD
*/
interface ValidationPasswordOptions {
invalidMessage?: string | ((options: ValidationPasswordOptions) => string);
}
interface ValidationPasswordObject extends ValidationPasswordOptions {
validate: (value: string) => Promise<ValidationResult>;
}
const defaultValidationPasswordOptions: ValidationPasswordOptions = {
invalidMessage:
"Your password needs to have at least a letter, a number and a special character.",
};
/**
* Validate field is in a valid password format
*
* @param options options data
* @returns ValidationPasswordObject
*/
export function Password(
options?: ValidationPasswordOptions
): ValidationPasswordObject {
options = { ...defaultValidationPasswordOptions, ...(options || {}) };
return {
...options,
validate: function (value: string): Promise<ValidationResult> {
return Promise.resolve({
valid: /(?=.*[A-Za-z])(?=.*\d)(?=.*[.@$!%*#?&])[A-Za-z\d.@$!%*#?&]{1,}$/.test(
value
),
invalidMessages: [
typeof this.invalidMessage === "string"
? this.invalidMessage
: this.invalidMessage(this),
],
});
},
};
}
/**
* EMAIL
*/
interface ValidationEmailOptions {
invalidMessage?: string | ((options: ValidationEmailOptions) => string);
}
interface ValidationEmailObject extends ValidationEmailOptions {
validate: (value: string) => Promise<ValidationResult>;
}
const defaultValidationEmailOptions: ValidationEmailOptions = {
invalidMessage: "Your email is not in a supported format.",
};
/**
* Validate field is in a valid Email format
*
* @param options options data
* @returns ValidationEmailObject
*/
export function Email(options?: ValidationEmailOptions): ValidationEmailObject {
options = { ...defaultValidationEmailOptions, ...(options || {}) };
return {
...options,
validate: function (value: string): Promise<ValidationResult> {
return Promise.resolve({
valid:
value.length == 0 ||
/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/.test(value),
invalidMessages: [
typeof this.invalidMessage === "string"
? this.invalidMessage
: this.invalidMessage(this),
],
});
},
};
}
/**
* PHONE
*/
interface ValidationPhoneOptions {
invalidMessage?: string | ((options: ValidationPhoneOptions) => string);
}
interface ValidationPhoneObject extends ValidationPhoneOptions {
validate: (value: string) => Promise<ValidationResult>;
}
const defaultValidationPhoneOptions: ValidationPhoneOptions = {
invalidMessage: "Your Phone number is not in a supported format.",
};
/**
* Validate field is in a valid Phone format
*
* @param options options data
* @returns ValidationPhoneObject
*/
export function Phone(options?: ValidationPhoneOptions): ValidationPhoneObject {
options = { ...defaultValidationPhoneOptions, ...(options || {}) };
return {
...options,
validate: function (value: string): Promise<ValidationResult> {
return Promise.resolve({
valid:
value.length == 0 ||
/^(\+|00)?[0-9][0-9 \-().]{7,32}$/.test(value),
invalidMessages: [
typeof this.invalidMessage === "string"
? this.invalidMessage
: this.invalidMessage(this),
],
});
},
};
}
/**
* NUMBER
*/
interface ValidationNumberOptions {
invalidMessage?: string | ((options: ValidationNumberOptions) => string);
}
interface ValidationNumberObject extends ValidationNumberOptions {
validate: (value: string) => Promise<ValidationResult>;
}
const defaultValidationNumberOptions: ValidationNumberOptions = {
invalidMessage: "Must be a number.",
};
/**
* Validate field is in a valid Whole number format
*
* @param options options data
* @returns ValidationNumberObject
*/
export function Number(
options?: ValidationNumberOptions
): ValidationNumberObject {
options = { ...defaultValidationNumberOptions, ...(options || {}) };
return {
...options,
validate: function (value: string): Promise<ValidationResult> {
return Promise.resolve({
valid: value.length == 0 || /^0?\d+$/.test(value),
invalidMessages: [
typeof this.invalidMessage === "string"
? this.invalidMessage
: this.invalidMessage(this),
],
});
},
};
}
/**
* DATE
*/
interface ValidationDateOptions {
before?: string | ((value: string) => string);
after?: string | ((value: string) => string);
invalidMessage?: string | ((options: ValidationDateOptions) => string);
invalidBeforeMessage?:
| string
| ((options: ValidationDateOptions) => string);
invalidAfterMessage?: string | ((options: ValidationDateOptions) => string);
}
interface ValidationDateObject extends ValidationDateOptions {
validate: (value: string) => Promise<ValidationResult>;
}
const defaultValidationDateOptions: ValidationDateOptions = {
before: "",
after: "",
invalidMessage: "Must be a valid date.",
invalidBeforeMessage: (options: ValidationDateOptions) => {
return `Must be a date before ${options.before}.`;
},
invalidAfterMessage: (options: ValidationDateOptions) => {
return `Must be a date after ${options.after}.`;
},
};
/**
* Validate field is in a valid Date format
*
* @param options options data
* @returns ValidationDateObject
*/
export function Date(options?: ValidationDateOptions): ValidationDateObject {
options = { ...defaultValidationDateOptions, ...(options || {}) };
return {
...options,
validate: function (value: string): Promise<ValidationResult> {
let valid = true;
let invalidMessageType = "invalidMessage";
const parsedDate = new SMDate(value);
if (parsedDate.isValid() == true) {
const beforeDate = new SMDate(
typeof (options["before"] = options?.before || "") ===
"function"
? options.before(value)
: options.before
);
const afterDate = new SMDate(
typeof (options["after"] = options?.after || "") ===
"function"
? options.after(value)
: options.after
);
if (
beforeDate.isValid() == true &&
parsedDate.isBefore(beforeDate) == false
) {
valid = false;
invalidMessageType = "invalidBeforeMessage";
}
if (
afterDate.isValid() == true &&
parsedDate.isAfter(afterDate) == false
) {
valid = false;
invalidMessageType = "invalidAfterMessage";
}
} else {
valid = false;
}
return Promise.resolve({
valid: valid,
invalidMessages: [
typeof this[invalidMessageType] === "string"
? this[invalidMessageType]
: this[invalidMessageType](this),
],
});
},
};
}
/**
* TIME
*/
interface ValidationTimeOptions {
before?: string | ((value: string) => string);
after?: string | ((value: string) => string);
invalidMessage?: string | ((options: ValidationTimeOptions) => string);
invalidBeforeMessage?:
| string
| ((options: ValidationTimeOptions) => string);
invalidAfterMessage?: string | ((options: ValidationTimeOptions) => string);
}
interface ValidationTimeObject extends ValidationTimeOptions {
validate: (value: string) => Promise<ValidationResult>;
}
const defaultValidationTimeOptions: ValidationTimeOptions = {
before: "",
after: "",
invalidMessage: "Must be a valid time.",
invalidBeforeMessage: (options: ValidationTimeOptions) => {
return `Must be a time before ${options.before}.`;
},
invalidAfterMessage: (options: ValidationTimeOptions) => {
return `Must be a time after ${options.after}.`;
},
};
/**
* Validate field is in a valid Time format
*
* @param options options data
* @returns ValidationTimeObject
*/
export function Time(options?: ValidationTimeOptions): ValidationTimeObject {
options = { ...defaultValidationTimeOptions, ...(options || {}) };
return {
...options,
validate: function (value: string): Promise<ValidationResult> {
let valid = true;
let invalidMessageType = "invalidMessage";
const parsedTime = new SMDate(value);
if (parsedTime.isValid() == true) {
const beforeTime = new SMDate(
typeof (options["before"] = options?.before || "") ===
"function"
? options.before(value)
: options.before
);
const afterTime = new SMDate(
typeof (options["after"] = options?.after || "") ===
"function"
? options.after(value)
: options.after
);
if (
beforeTime.isValid() == true &&
parsedTime.isBefore(beforeTime) == false
) {
valid = false;
invalidMessageType = "invalidBeforeMessage";
}
if (
afterTime.isValid() == true &&
parsedTime.isAfter(afterTime) == false
) {
valid = false;
invalidMessageType = "invalidAfterMessage";
}
} else {
valid = false;
}
return Promise.resolve({
valid: valid,
invalidMessages: [
typeof this[invalidMessageType] === "string"
? this[invalidMessageType]
: this[invalidMessageType](this),
],
});
},
};
}
/**
* DATETIME
*/
interface ValidationDateTimeOptions {
before?: string | ((value: string) => string);
after?: string | ((value: string) => string);
invalidMessage?: string | ((options: ValidationDateTimeOptions) => string);
invalidBeforeMessage?:
| string
| ((options: ValidationDateTimeOptions) => string);
invalidAfterMessage?:
| string
| ((options: ValidationDateTimeOptions) => string);
}
interface ValidationDateTimeObject extends ValidationDateTimeOptions {
validate: (value: string) => Promise<ValidationResult>;
}
const defaultValidationDateTimeOptions: ValidationDateTimeOptions = {
before: "",
after: "",
invalidMessage: "Must be a valid date and time.",
invalidBeforeMessage: (options: ValidationDateTimeOptions) => {
return `Must be a date/time before ${options.before}.`;
},
invalidAfterMessage: (options: ValidationDateTimeOptions) => {
return `Must be a date/time after ${options.after}.`;
},
};
/**
* Validate field is in a valid Date format
*
* @param options options data
* @returns ValidationDateObject
*/
export function DateTime(
options?: ValidationDateTimeOptions
): ValidationDateTimeObject {
options = { ...defaultValidationDateTimeOptions, ...(options || {}) };
return {
...options,
validate: function (value: string): Promise<ValidationResult> {
let valid = true;
let invalidMessageType = "invalidMessage";
const parsedDate = new SMDate(value);
if (parsedDate.isValid() == true) {
const beforeDate = new SMDate(
typeof (options["before"] = options?.before || "") ===
"function"
? options.before(value)
: options.before
);
const afterDate = new SMDate(
typeof (options["after"] = options?.after || "") ===
"function"
? options.after(value)
: options.after
);
if (
beforeDate.isValid() == true &&
parsedDate.isBefore(beforeDate) == false
) {
valid = false;
invalidMessageType = "invalidBeforeMessage";
}
if (
afterDate.isValid() == true &&
parsedDate.isAfter(afterDate) == false
) {
valid = false;
invalidMessageType = "invalidAfterMessage";
}
} else {
valid = false;
}
return Promise.resolve({
valid: valid,
invalidMessages: [
typeof this[invalidMessageType] === "string"
? this[invalidMessageType]
: this[invalidMessageType](this),
],
});
},
};
}
/**
* CUSTOM
*/
type ValidationCustomCallback = (value: string) => Promise<boolean | string>;
interface ValidationCustomOptions {
callback: ValidationCustomCallback;
invalidMessage?: string | ((options: ValidationCustomOptions) => string);
}
interface ValidationCustomObject extends ValidationCustomOptions {
validate: (value: string) => Promise<ValidationResult>;
}
const defaultValidationCustomOptions: ValidationCustomOptions = {
callback: async () => {
return true;
},
invalidMessage: "This field is invalid.",
};
export function Custom(
callbackOrOptions: ValidationCustomCallback | ValidationCustomOptions,
options?: ValidationCustomOptions
);
export function Custom(
options: ValidationCustomOptions
): ValidationCustomObject;
/**
* Validate field is in a valid Custom format
*
* @param callbackOrOptions
* @param options options data
* @returns ValidationCustomObject
*/
export function Custom(
callbackOrOptions: ValidationCustomCallback | ValidationCustomOptions,
options?: ValidationCustomOptions
): ValidationCustomObject {
if (typeof callbackOrOptions === "function") {
options = { ...defaultValidationCustomOptions, ...(options || {}) };
options.callback = callbackOrOptions;
} else {
options = {
...defaultValidationCustomOptions,
...(callbackOrOptions || {}),
};
}
return {
...options,
validate: async function (value: string): Promise<ValidationResult> {
const validateResult = {
valid: true,
invalidMessages: [
typeof this.invalidMessage === "string"
? this.invalidMessage
: this.invalidMessage(this),
],
};
const callbackResult =
typeof this.callback === "function"
? await this.callback(value)
: true;
if (typeof callbackResult === "string") {
if (callbackResult.length > 0) {
validateResult.valid = false;
validateResult.invalidMessages = [callbackResult];
}
} else if (callbackResult !== true) {
validateResult.valid = false;
}
return validateResult;
},
};
}
/**
* And
*
* @param list
*/
export const And = (list: Array<ValidationObject>) => {
return {
list: list,
validate: async function (value: string) {
const validationResult: ValidationResult = {
valid: true,
invalidMessages: [],
};
await Promise.all(
this.list.map(async (item: ValidationObject) => {
const validationItemResult = await item.validate(value);
if (validationItemResult.valid == false) {
validationResult.valid = false;
validationResult.invalidMessages =
validationResult.invalidMessages.concat(
validationItemResult.invalidMessages
);
}
})
);
return validationResult;
},
};
};
/**
* Required
*/
interface ValidationRequiredOptions {
invalidMessage?: string | ((options: ValidationRequiredOptions) => string);
}
interface ValidationRequiredObject extends ValidationRequiredOptions {
validate: (value: string) => Promise<ValidationResult>;
}
const defaultValidationRequiredOptions: ValidationRequiredOptions = {
invalidMessage: "This field is required.",
};
/**
* Validate field contains value
*
* @param options options data
* @returns ValidationRequiredObject
*/
export function Required(
options?: ValidationRequiredOptions
): ValidationRequiredObject {
options = { ...defaultValidationRequiredOptions, ...(options || {}) };
return {
...options,
validate: function (value: unknown): Promise<ValidationResult> {
return Promise.resolve({
valid: !isEmpty(value),
invalidMessages: [
typeof this.invalidMessage === "string"
? this.invalidMessage
: this.invalidMessage(this),
],
});
},
};
}
/**
* Required If
*/
type ValidationRequiredIfCheck = boolean | Array<boolean>;
interface ValidationRequiredIfOptions {
check: ValidationRequiredIfCheck;
invalidMessage?:
| string
| ((options: ValidationRequiredIfOptions) => string);
}
interface ValidationRequiredIfObject extends ValidationRequiredIfOptions {
validate: (value: string) => Promise<ValidationResult>;
}
const defaultValidationRequiredIfOptions: ValidationRequiredIfOptions = {
check: true,
invalidMessage: "This field is required.",
};
/**
* Validate field contains value
*
* @param checkOrOptions
* @param options options data
* @returns ValidationRequiredIfObject
*/
export function RequiredIf(
checkOrOptions: boolean | Array<boolean> | ValidationRequiredIfOptions,
options?: ValidationRequiredIfOptions
): ValidationRequiredIfObject {
if (
typeof checkOrOptions === "boolean" ||
Array.isArray(checkOrOptions) === true
) {
options = { ...defaultValidationRequiredIfOptions, ...(options || {}) };
options.check = checkOrOptions;
} else {
options = {
...defaultValidationRequiredIfOptions,
...(checkOrOptions || {}),
};
}
options = { ...defaultValidationRequiredIfOptions, ...(options || {}) };
return {
...options,
validate: function (value: unknown): Promise<ValidationResult> {
return Promise.resolve({
valid: Array.isArray(value)
? value.every((item) => !!item)
: value == true,
invalidMessages: [
typeof this.invalidMessage === "string"
? this.invalidMessage
: this.invalidMessage(this),
],
});
},
};
}
/**
* Url
*/
interface ValidationUrlOptions {
invalidMessage?: string | ((options: ValidationUrlOptions) => string);
}
interface ValidationUrlObject extends ValidationUrlOptions {
validate: (value: string) => Promise<ValidationResult>;
}
const defaultValidationUrlOptions: ValidationUrlOptions = {
invalidMessage: "Not a supported Url format.",
};
/**
* Validate field is in a valid Email format
*
* @param options options data
* @returns ValidationEmailObject
*/
export function Url(options?: ValidationUrlOptions): ValidationUrlObject {
options = { ...defaultValidationUrlOptions, ...(options || {}) };
return {
...options,
validate: function (value: string): Promise<ValidationResult> {
return Promise.resolve({
valid:
value.length > 0
? /^(https?|ftp):\/\/[^\s/$.?#].[^\s]*(:\d+)?([/?#][^\s]*)?$/.test(
value
)
: true,
invalidMessages: [
typeof this.invalidMessage === "string"
? this.invalidMessage
: this.invalidMessage(this),
],
});
},
};
}
/**
* FileSize
*/
interface ValidationFileSizeOptions {
size: number;
invalidMessage?: string | ((options: ValidationFileSizeOptions) => string);
}
interface ValidationFileSizeObject extends ValidationFileSizeOptions {
validate: (value: File) => Promise<ValidationResult>;
}
const defaultValidationFileSizeOptions: ValidationFileSizeOptions = {
size: 1024 * 1024 * 1024, // 1 Mb
invalidMessage: (options) => {
return `The file size must be less than ${bytesReadable(options.size)}`;
},
};
/**
* Validate file is equal or less than size.
*
* @param options options data
* @returns ValidationEmailObject
*/
export function FileSize(
options?: ValidationFileSizeOptions
): ValidationFileSizeObject {
options = { ...defaultValidationFileSizeOptions, ...(options || {}) };
return {
...options,
validate: function (value: File): Promise<ValidationResult> {
const isValid =
value instanceof File ? value.size < options.size : true;
return Promise.resolve({
valid: isValid,
invalidMessages: [
typeof this.invalidMessage === "string"
? this.invalidMessage
: this.invalidMessage(this),
],
});
},
};
}

File diff suppressed because one or more lines are too long

View File

@@ -1,12 +0,0 @@
import Router from "@/router";
import { createPinia } from "pinia";
import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
import { createApp } from "vue";
import App from "./views/App.vue";
import "uno.css";
import "../css/app.scss";
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
createApp(App).use(pinia).use(Router).mount("#app");

View File

@@ -1,619 +0,0 @@
import { useUserStore } from "@/store/UserStore";
import { createRouter, createWebHistory } from "vue-router";
import { api } from "../helpers/api";
import { useApplicationStore } from "../store/ApplicationStore";
import { updateSEOTags } from "../helpers/seo";
export const routes = [
{
path: "/",
name: "home",
meta: {
title: "Home",
description:
"STEMMechanics, a family-run company based in Cairns, Queensland, creates fantastic STEM-focused programs and activities that are both entertaining and educational.",
},
component: () => import("@/views/Home.vue"),
},
{
path: "/blog",
name: "blog",
meta: {
title: "Blog",
},
component: () => import(/* webpackPrefetch: true */ "@/views/Blog.vue"),
},
{
path: "/article",
redirect: "/blog",
children: [
{
path: ":slug",
name: "article",
component: () => import("@/views/Article.vue"),
},
],
},
{
path: "/workshops",
name: "workshops",
meta: {
title: "Workshops",
},
component: () =>
import(/* webpackPreload: true */ "@/views/Workshops.vue"),
},
{
path: "/event",
redirect: "/workshops",
children: [
{
path: ":id",
name: "event",
component: () => import("@/views/Event.vue"),
},
],
},
{
path: "/verify-email",
name: "verify-email",
meta: {
title: "Verify Email",
},
component: () => import("@/views/EmailVerify.vue"),
},
{
path: "/resend-verify-email",
name: "resend-verify-email",
meta: {
title: "Resend Verification Email",
},
component: () => import("@/views/ResendEmailVerify.vue"),
},
{
path: "/reset-password",
name: "reset-password",
meta: {
title: "Reset Password",
},
component: () => import("@/views/ResetPassword.vue"),
},
{
path: "/privacy",
name: "privacy",
meta: {
title: "Privacy Policy",
},
component: () => import("@/views/Privacy.vue"),
},
{
path: "/rules",
name: "rules",
meta: {
title: "Rules",
},
component: () => import("@/views/Rules.vue"),
},
{
path: "/community",
name: "community",
meta: {
title: "Community",
},
component: () => import("@/views/Community.vue"),
},
{
path: "/minecraft",
children: [
{
path: "",
name: "minecraft",
meta: {
title: "Minecraft",
},
component: () => import("@/views/Minecraft.vue"),
},
{
path: "curve",
name: "minecraft-curve",
meta: {
title: "Minecraft Curve",
},
component: () => import("@/views/MinecraftCurve.vue"),
},
],
},
{
path: "/login",
name: "login",
meta: {
title: "Login",
middleware: "guest",
},
component: () =>
import(/* webpackPrefetch: true */ "@/views/Login.vue"),
},
{
path: "/logout",
name: "logout",
meta: {
title: "Logout",
},
component: () => import("@/views/Logout.vue"),
},
{
path: "/contact",
name: "contact",
meta: {
title: "Contact",
},
component: () =>
import(/* webpackPrefetch: true */ "@/views/Contact.vue"),
},
{
path: "/conduct",
redirect: { name: "code-of-conduct" },
},
{
path: "/code-of-conduct",
name: "code-of-conduct",
meta: {
title: "Code of Conduct",
},
component: () => import("@/views/CodeOfConduct.vue"),
},
{
path: "/terms",
redirect: { name: "terms-and-conditions" },
},
{
path: "/terms-and-conditions",
name: "terms-and-conditions",
meta: {
title: "Terms and Conditions",
},
component: () => import("@/views/TermsAndConditions.vue"),
},
{
path: "/register",
name: "register",
meta: {
title: "Register",
},
component: () =>
import(/* webpackPrefetch: true */ "@/views/Register.vue"),
},
{
path: "/dashboard",
children: [
{
path: "",
name: "dashboard",
meta: {
title: "Dashboard",
middleware: "authenticated",
},
component: () =>
import(
/* webpackPrefetch: true */ "@/views/dashboard/Dashboard.vue"
),
},
{
path: "analytics",
children: [
{
path: "",
name: "dashboard-analytics-list",
meta: {
title: "Analytics",
middleware: "authenticated",
},
component: () =>
import("@/views/dashboard/AnalyticsList.vue"),
},
{
path: ":id",
name: "dashboard-analytics-item",
meta: {
title: "Analytics Session",
middleware: "authenticated",
},
component: () =>
import("@/views/dashboard/AnalyticsItem.vue"),
},
],
},
{
path: "articles",
children: [
{
path: "",
name: "dashboard-article-list",
meta: {
title: "Articles",
middleware: "authenticated",
},
component: () =>
import("@/views/dashboard/ArticleList.vue"),
},
{
path: "create",
name: "dashboard-article-create",
meta: {
title: "Create Article",
middleware: "authenticated",
},
component: () =>
import("@/views/dashboard/ArticleEdit.vue"),
},
{
path: ":id",
name: "dashboard-article-edit",
meta: {
title: "Edit Article",
middleware: "authenticated",
},
component: () =>
import("@/views/dashboard/ArticleEdit.vue"),
},
],
},
{
path: "events",
children: [
{
path: "",
name: "dashboard-event-list",
meta: {
title: "Events",
middleware: "authenticated",
},
component: () =>
import("@/views/dashboard/EventList.vue"),
},
{
path: "create",
name: "dashboard-event-create",
meta: {
title: "Create Event",
middleware: "authenticated",
},
component: () =>
import("@/views/dashboard/EventEdit.vue"),
},
{
path: ":id",
name: "dashboard-event-edit",
meta: {
title: "Event",
middleware: "authenticated",
},
component: () =>
import("@/views/dashboard/EventEdit.vue"),
},
],
},
{
path: "details",
name: "dashboard-account-details",
meta: {
title: "Account Details",
middleware: "authenticated",
},
component: () => import("@/views/dashboard/UserEdit.vue"),
},
{
path: "users",
children: [
{
path: "",
name: "dashboard-user-list",
meta: {
title: "Users",
middleware: "authenticated",
},
component: () =>
import("@/views/dashboard/UserList.vue"),
},
{
path: "create",
name: "dashboard-user-create",
meta: {
title: "Create User",
middleware: "authenticated",
},
component: () =>
import("@/views/dashboard/UserEdit.vue"),
},
{
path: ":id",
name: "dashboard-user-edit",
meta: {
title: "Edit User",
middleware: "authenticated",
},
component: () =>
import("@/views/dashboard/UserEdit.vue"),
},
],
},
{
path: "media",
children: [
{
path: "",
name: "dashboard-media-list",
meta: {
title: "Media",
middleware: "authenticated",
},
component: () =>
import("@/views/dashboard/MediaList.vue"),
},
{
path: "create",
name: "dashboard-media-create",
meta: {
title: "Upload Media",
middleware: "authenticated",
},
component: () =>
import("@/views/dashboard/MediaEdit.vue"),
},
{
path: ":id",
name: "dashboard-media-edit",
meta: {
title: "Edit Media",
middleware: "authenticated",
},
component: () =>
import("@/views/dashboard/MediaEdit.vue"),
},
],
},
{
path: "shortlinks",
children: [
{
path: "",
name: "dashboard-shortlink-list",
meta: {
title: "Shortlink",
middleware: "authenticated",
},
component: () =>
import("@/views/dashboard/ShortlinkList.vue"),
},
{
path: "create",
name: "dashboard-shortlink-create",
meta: {
title: "Create Shortlink",
middleware: "authenticated",
},
component: () =>
import("@/views/dashboard/ShortlinkEdit.vue"),
},
{
path: ":id",
name: "dashboard-shortlink-edit",
meta: {
title: "Edit Shortlink",
middleware: "authenticated",
},
component: () =>
import("@/views/dashboard/ShortlinkEdit.vue"),
},
],
},
{
path: "discord-bot-logs",
name: "dashboard-discord-bot-logs",
meta: {
title: "Discord Bot Logs",
middleware: "authenticated",
},
component: () => import("@/views/dashboard/DiscordBotLogs.vue"),
},
],
},
{
path: "/forgot-password",
name: "forgot-password",
meta: {
title: "Forgot Password",
},
component: () => import("@/views/ForgotPassword.vue"),
},
{
path: "/file/:id",
name: "file",
meta: {
title: "File",
},
component: () => import("@/views/File.vue"),
},
{
path: "/cart",
name: "cart",
meta: {
title: "Cart",
},
component: () => import("@/views/Cart.vue"),
},
{
path: "/:catchAll(.*)",
name: "not-found",
meta: {
title: "Page not found",
hideInEditor: true,
},
component: () => import("@/views/404.vue"),
},
];
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior() {
return { top: 0 };
},
});
// export let activeRoutes = [];
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore();
const applicationStore = useApplicationStore();
applicationStore.hydrated = false;
applicationStore.clearDynamicTitle();
if (applicationStore.pageLoaderTimeout !== 0) {
window.clearTimeout(applicationStore.pageLoaderTimeout);
applicationStore.pageLoaderTimeout = window.setTimeout(() => {
const pageLoadingElem = document.getElementById("sm-page-loading");
if (pageLoadingElem !== null) {
pageLoadingElem.style.display = "flex";
}
}, 0);
}
if (to.meta.middleware == "authenticated") {
if (userStore.id) {
api.get({
url: "/me",
})
.then((res) => {
userStore.setUserDetails(res.data.user);
})
.catch((err) => {
console.log(err);
if (err.status == 401) {
userStore.clearUser();
window.location.href = `/login?redirect=${to.fullPath}`;
}
});
}
if (!userStore.id) {
next({
name: "login",
query: { redirect: encodeURIComponent(to.fullPath) },
});
return;
}
}
api.post({
url: "/analytics",
body: {
type: "pageview",
path: to.fullPath,
},
}).catch(() => {
/* empty */
});
next();
});
router.afterEach((to, from) => {
const applicationStore = useApplicationStore();
if (from.name !== undefined) {
document.body.classList.remove(`page-${from.name}`);
}
document.body.classList.add(`page-${to.name}`);
window.setTimeout(() => {
const getMetaValue = (tag, defaultValue = "") => {
const getMeta = (obj, tag) => {
const tagHierarchy = tag.split(".");
const nearestWithMeta = obj.matched
.slice()
.reverse()
.reduce(
(acc, r) => acc || (r.meta && r.meta[tagHierarchy[0]]),
null,
);
if (nearestWithMeta) {
let result = nearestWithMeta;
for (let i = 1; i < tagHierarchy.length; i++) {
result = result[tagHierarchy[i]];
if (!result) break;
}
if (result !== undefined) return result;
}
return null;
};
const nearestMeta = getMeta(to, tag);
if (nearestMeta == null) {
const previousMeta = getMeta(from, tag);
if (previousMeta == null) {
return defaultValue;
}
return previousMeta;
}
return nearestMeta;
};
updateSEOTags({
title: getMetaValue("title"),
description: getMetaValue("description"),
keywords: getMetaValue("keywords", []),
robots: {
index: getMetaValue(
"robots.index",
!to.meta.middleware
? true
: to.meta.middleware != "authenticated",
),
follow: getMetaValue(
"robots.follow",
!to.meta.middleware
? true
: to.meta.middleware != "authenticated",
),
},
url: getMetaValue("url", to.path),
image: getMetaValue("image", ""),
});
}, 10);
window.setTimeout(() => {
const autofocusElement = document.querySelector("[autofocus]");
if (autofocusElement) {
autofocusElement.focus();
}
const hash = window.location.hash;
if (hash) {
const target = document.querySelector(hash);
if (target) {
target.scrollIntoView();
}
}
}, 10);
if (applicationStore.pageLoaderTimeout !== 0) {
window.clearTimeout(applicationStore.pageLoaderTimeout);
applicationStore.pageLoaderTimeout = 0;
}
const pageLoadingElem = document.getElementById("sm-page-loading");
if (pageLoadingElem !== null) {
pageLoadingElem.style.display = "none";
}
applicationStore.hydrated = true;
});
export default router;

View File

@@ -1,94 +0,0 @@
import { defineStore } from "pinia";
type ApplicationStoreEventKeyUpCallback = (event: KeyboardEvent) => boolean;
type ApplicationStoreEventKeyPressCallback = (event: KeyboardEvent) => boolean;
export interface ApplicationStore {
hydrated: boolean;
unavailable: boolean;
dynamicTitle: string;
eventKeyUpStack: ApplicationStoreEventKeyUpCallback[];
eventKeyPressStack: ApplicationStoreEventKeyPressCallback[];
pageLoaderTimeout: number;
_addedListener: boolean;
}
export const useApplicationStore = defineStore({
id: "application",
state: (): ApplicationStore => ({
hydrated: false,
unavailable: false,
dynamicTitle: "",
eventKeyUpStack: [],
eventKeyPressStack: [],
pageLoaderTimeout: 0,
_addedListener: false,
}),
actions: {
async setDynamicTitle(title: string) {
this.$state.dynamicTitle = title;
document.title = `STEMMechanics | ${title}`;
},
clearDynamicTitle() {
this.$state.dynamicTitle = "";
},
addKeyUpListener(callback: ApplicationStoreEventKeyUpCallback) {
this.eventKeyUpStack.push(callback);
if (!this._addedListener) {
document.addEventListener("keyup", (event: KeyboardEvent) => {
this.eventKeyUpStack.every(
(item: ApplicationStoreEventKeyUpCallback) => {
const result = item(event);
if (result) {
return false;
}
return true;
},
);
});
}
},
removeKeyUpListener(callback: ApplicationStoreEventKeyUpCallback) {
this.eventKeyUpStack = this.eventKeyUpStack.filter(
(item: ApplicationStoreEventKeyUpCallback) => item !== callback,
);
},
addKeyPressListener(callback: ApplicationStoreEventKeyPressCallback) {
this.eventKeyPressStack.push(callback);
if (!this._addedListener) {
document.addEventListener(
"keypress",
(event: KeyboardEvent) => {
this.eventKeyPressStack.every(
(item: ApplicationStoreEventKeyPressCallback) => {
const result = item(event);
if (result) {
return false;
}
return true;
},
);
},
);
}
},
removeKeyPressListener(
callback: ApplicationStoreEventKeyPressCallback,
) {
this.eventKeyPressStack = this.eventKeyPressStack.filter(
(item: ApplicationStoreEventKeyPressCallback) =>
item !== callback,
);
},
},
});

View File

@@ -1,61 +0,0 @@
import { DefineStoreOptions, defineStore } from "pinia";
interface CacheItem {
url: string;
data: unknown;
}
export const useCacheStore = defineStore({
id: "cache",
state: () => ({
cache: [] as CacheItem[],
}),
actions: {
// Method to retrieve cached JSON data based on a URL
getCacheByUrl(url: string) {
const cachedItem = this.cache.find((item) => item.url === url);
return cachedItem ? cachedItem.data : null;
},
// Method to update the cache with new data and check for modifications
updateCache(url: string, newData: unknown): boolean {
const index = this.cache.findIndex((item) => item.url === url);
if (index !== -1) {
// If the URL is already in the cache, check for modifications
const existingData = this.cache[index].data;
if (JSON.stringify(existingData) === JSON.stringify(newData)) {
// Data is not modified, return false
return false;
} else {
// Data is modified, update the cache
this.cache[index].data = newData;
return true;
}
} else {
// If the URL is not in the cache, add it
this.cache.push({ url, data: newData });
return true;
}
},
// Method to clear the cache for a specific URL
clearCacheByUrl(url: string) {
const index = this.cache.findIndex((item) => item.url === url);
if (index !== -1) {
this.cache.splice(index, 1);
}
},
// Method to clear the entire cache
clearCache() {
this.cache = [];
},
},
persist: true,
} as DefineStoreOptions<string, unknown, unknown, unknown> & {
persist?: boolean;
});

View File

@@ -1,83 +0,0 @@
import { defineStore } from "pinia";
export interface ToastOptions {
id?: number;
title?: string;
content: string;
type?: string;
loader?: boolean;
}
export interface ToastItem {
id: number;
title: string;
content: string;
type: string;
loader: boolean;
}
export interface ToastStore {
toasts: ToastItem[];
}
export const defaultToastItem: ToastItem = {
id: 0,
title: "",
content: "",
type: "primary",
loader: false,
};
export const useToastStore = defineStore({
id: "toasts",
state: (): ToastStore => ({
toasts: [],
}),
actions: {
addToast(toast: ToastOptions): number {
while (
!toast.id ||
toast.id == 0 ||
this.toasts.find((item: ToastItem) => item.id === toast.id)
) {
toast.id =
Math.floor(Math.random() * Number.MAX_SAFE_INTEGER) + 1;
}
toast.title = toast.title || defaultToastItem.title;
toast.type = toast.type || defaultToastItem.type;
this.toasts.push(toast);
return toast.id;
},
clearToast(id: number): void {
this.toasts = this.toasts.filter(
(item: ToastItem) => item.id !== id
);
},
updateToast(id: number, updatedFields: Partial<ToastOptions>): void {
const toastToUpdate = this.toasts.find(
(item: ToastItem) => item.id === id
);
if (toastToUpdate) {
toastToUpdate.title =
updatedFields.title || toastToUpdate.title;
toastToUpdate.content =
updatedFields.content || toastToUpdate.content;
toastToUpdate.type = updatedFields.type || toastToUpdate.type;
if (
Object.prototype.hasOwnProperty.call(
updatedFields,
"loader"
)
) {
toastToUpdate.loader = updatedFields.loader;
}
}
},
},
});

View File

@@ -1,72 +0,0 @@
import { defineStore, DefineStoreOptions } from "pinia";
export interface UserDetails {
id: string;
username: string;
first_name: string;
last_name: string;
display_name: string;
email: string;
phone: string;
permissions: string[];
}
export interface UserState {
id: string;
token: string;
username: string;
firstName: string;
lastName: string;
displayName: string;
email: string;
phone: string;
permissions: string[];
}
export const useUserStore = defineStore({
id: "user",
state: (): UserState => {
return {
id: "",
token: "",
username: "",
firstName: "",
lastName: "",
displayName: "",
email: "",
phone: "",
permissions: [],
};
},
actions: {
async setUserDetails(user: UserDetails) {
this.$state.id = user.id;
this.$state.username = user.username;
this.$state.firstName = user.first_name;
this.$state.lastName = user.last_name;
this.$state.displayName = user.display_name;
this.$state.email = user.email;
this.$state.phone = user.phone;
this.$state.permissions = user.permissions || [];
},
async setUserToken(token: string) {
this.$state.token = token;
},
clearUser() {
this.$state.id = null;
this.$state.token = null;
this.$state.username = null;
this.$state.firstName = null;
this.$state.lastName = null;
this.$state.displayName = null;
this.$state.email = null;
this.$state.phone = null;
this.$state.permissions = [];
},
},
persist: true,
} as DefineStoreOptions<string, unknown, unknown, unknown> & { persist?: boolean });

View File

@@ -1,33 +0,0 @@
import { expect, describe, it } from "vitest";
import { SMDate } from "../helpers/datetime";
describe("format()", () => {
it("should return an empty string when the first argument is not a Date object", () => {
const result = new SMDate("not a date").format("yyyy-MM-dd");
expect(result).toEqual("");
});
it("should format the date correctly", () => {
const date = new Date("2022-02-19T12:34:56");
const result = new SMDate(date).format("yyyy-MM-dd HH:mm:ss");
expect(result).toEqual("2022-02-19 12:34:56");
});
it("should handle single-digit month and day", () => {
const date = new Date("2022-01-01T00:00:00");
const result = new SMDate(date).format("yy-M-d");
expect(result).toEqual("22-1-1");
});
it("should handle day of week and month name abbreviations", () => {
const date = new Date("2022-03-22T00:00:00");
const result = new SMDate(date).format("EEE, MMM dd, yyyy");
expect(result).toEqual("Tue, Mar 22, 2022");
});
it("should handle 12-hour clock with am/pm", () => {
const date = new Date("2022-01-01T12:34:56");
const result = new SMDate(date).format("hh:mm:ss aa");
expect(result).toEqual("12:34:56 pm");
});
});

View File

@@ -1,14 +0,0 @@
import { expect, describe, it } from "vitest";
import { toTitleCase } from "../helpers/string";
describe("toTitleCase()", () => {
it("should return a converted title case string", () => {
const result = toTitleCase("titlecase");
expect(result).toEqual("Titlecase");
});
it("should return a converted title case string and spaces", () => {
const result = toTitleCase("titlecase_and_more");
expect(result).toEqual("Titlecase And More");
});
});

View File

@@ -1,28 +0,0 @@
import { expect, describe, it } from "vitest";
import { Email } from "../helpers/validate";
describe("Email()", () => {
it("should return valid=false when an invalid email address is passed to the validate function", async () => {
const v = Email();
const result = await v.validate("invalid email");
expect(result.valid).toBe(false);
});
it("should return valid=false when an invalid email address is passed to the validate function", async () => {
const v = Email();
const result = await v.validate("fake@outlook");
expect(result.valid).toBe(false);
});
it("should return valid=true when an valid email address is passed to the validate function", async () => {
const v = Email();
const result = await v.validate("fake@outlook.com");
expect(result.valid).toBe(true);
});
it("should return valid=true when an valid email address is passed to the validate function", async () => {
const v = Email();
const result = await v.validate("fake@outlook.com.au");
expect(result.valid).toBe(true);
});
});

View File

@@ -1,7 +0,0 @@
<template>
<SMPageStatus :status="404" />
</template>
<script setup lang="ts">
import SMPageStatus from "../components/SMPageStatus.vue";
</script>

View File

@@ -1,44 +0,0 @@
<template>
<SMNavbar />
<main class="flex-1">
<SMLoading v-if="loading" class="h-95" />
<router-view v-else v-slot="{ Component }">
<component :is="Component" />
</router-view>
</main>
<SMPageFooter />
<SMToastList />
<SMDialogList />
</template>
<script setup lang="ts">
import SMNavbar from "../components/SMNavbar.vue";
import SMPageFooter from "../components/SMPageFooter.vue";
import SMToastList from "../components/SMToastList.vue";
import SMDialogList from "../components/SMDialog";
import SMLoading from "../components/SMLoading.vue";
import { useApplicationStore } from "../store/ApplicationStore";
import { ref, watch } from "vue";
const loading = ref(true);
let loadingTimeout = null;
watch(
() => useApplicationStore().hydrated,
(newValue) => {
if (newValue == true) {
if (loadingTimeout != null) {
clearTimeout(loadingTimeout);
loadingTimeout = null;
}
loading.value = false;
} else {
if (loadingTimeout == null) {
loadingTimeout = setTimeout(() => {
loading.value = true;
}, 2000);
}
}
}
);
</script>

View File

@@ -1,163 +0,0 @@
<template>
<SMLoading class="pt-24 pb-48" v-if="pageLoading" />
<SMPageStatus
v-else-if="!pageLoading && pageStatus != 200"
:status="pageStatus" />
<template v-else>
<div
class="max-w-4xl mx-auto h-96 text-center mb-8 relative rounded-4 overflow-hidden">
<div
class="blur bg-cover bg-center absolute top-0 left-0 w-full h-full -z-1 opacity-50"
:style="{
backgroundImage: `url('${backgroundImageUrl}')`,
}"></div>
<img :src="backgroundImageUrl" class="h-full" />
</div>
<div class="max-w-4xl mx-auto flex flex-col px-4">
<h1 class="pb-2 text-gray-6">
{{ article.title }}
</h1>
<div
class="flex flex-1 flex-justify-between flex-items-center pb-4">
<div>
<div class="font-bold text-gray-4">
{{ formattedDate(article.publish_at) }}
</div>
</div>
<router-link
v-if="userHasPermission('admin/articles') && article.id"
role="button"
:to="{
name: 'dashboard-article-edit',
params: { id: article.id },
}"
class="font-medium px-6 py-1.5 rounded-md hover:shadow-md transition text-sm border-1 bg-white border-sky-6 text-sky-600 text-center"
>Edit Article</router-link
>
</div>
<SMHTML :html="article.content" />
<SMImageGallery
v-if="article.gallery.length > 0"
:model-value="article.gallery" />
<SMAttachments
v-if="article.attachments.length > 0"
:model-value="article.attachments || []" />
</div>
</template>
</template>
<script setup lang="ts">
import { ref, Ref } from "vue";
import { useRoute } from "vue-router";
import SMAttachments from "../components/SMAttachments.vue";
import { api } from "../helpers/api";
import { Article, ArticleCollection, User } from "../helpers/api.types";
import { SMDate } from "../helpers/datetime";
import { useApplicationStore } from "../store/ApplicationStore";
import { mediaGetVariantUrl } from "../helpers/media";
import { userHasPermission } from "../helpers/utils";
import SMLoading from "../components/SMLoading.vue";
import SMPageStatus from "../components/SMPageStatus.vue";
import SMHTML from "../components/SMHTML.vue";
import SMImageGallery from "../components/SMImageGallery.vue";
const applicationStore = useApplicationStore();
/**
* The article data.
*/
let article: Ref<Article> = ref({
id: "",
created_at: "",
updated_at: "",
title: "",
slug: "",
user_id: "",
user: { display_name: "" },
content: "",
publish_at: "",
hero: {},
gallery: [],
attachments: [],
});
/**
* The current page error.
*/
let pageStatus = ref(200);
/**
* Is the page loading.
*/
let pageLoading = ref(false);
/**
* Article user.
*/
let articleUser: User | null = null;
/**
* Thumbnail image URL.
*/
let backgroundImageUrl = ref("");
/**
* Load the page data.
*/
const handleLoad = async () => {
let slug = useRoute().params.slug || "";
pageLoading.value = true;
if (slug.length > 0) {
let result = await api.get({
url: "/articles",
params: {
slug: `=${slug}`,
limit: 1,
},
callback: (result) => {
if (result.status < 300) {
const data = result.data as ArticleCollection;
if (data && data.articles && data.total && data.total > 0) {
article.value = data.articles[0];
article.value.publish_at = new SMDate(
article.value.publish_at,
{
format: "ymd",
utc: true,
},
).format("yyyy/MM/dd HH:mm:ss");
backgroundImageUrl.value = mediaGetVariantUrl(
article.value.hero,
"large",
);
applicationStore.setDynamicTitle(article.value.title);
} else {
pageStatus.value = 404;
}
} else {
pageStatus.value = result.status;
}
pageLoading.value = false;
},
});
} else {
pageStatus.value = 404;
}
};
/**
* Format Date
* @param dateStr Date string.
* @returns Formatted date.
*/
const formattedDate = (dateStr) => {
return new SMDate(dateStr, { format: "yMd" }).format("MMMM d, yyyy");
};
handleLoad();
</script>

View File

@@ -1,167 +0,0 @@
<template>
<SMMastHead title="Blog" />
<div class="max-w-7xl mx-auto px-4">
<div class="flex space-between gap-4 py-8">
<SMInput
type="text"
label="Search articles"
v-model="searchInput"
@keyup.enter="handleSearch"
@blur="handleSearch">
<template #append
><button
type="button"
class="font-medium px-4 py-3.1 rounded-r-2 hover:shadow-md transition bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
@click="handleSearch">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-6">
<path
d="M796-121 533-384q-30 26-69.959 40.5T378-329q-108.162 0-183.081-75Q120-479 120-585t75-181q75-75 181.5-75t181 75Q632-691 632-584.85 632-542 618-502q-14 40-42 75l264 262-44 44ZM377-389q81.25 0 138.125-57.5T572-585q0-81-56.875-138.5T377-781q-82.083 0-139.542 57.5Q180-666 180-585t57.458 138.5Q294.917-389 377-389Z"
fill="currentColor" />
</svg></button
></template>
</SMInput>
</div>
<SMPagination
v-if="articlesTotal > articlesPerPage"
class="mb-4"
v-model="articlesPage"
:total="articlesTotal"
:per-page="articlesPerPage" />
<SMLoading v-if="pageLoading" />
<div
v-else-if="articles.length > 0"
class="grid md:grid-cols-2 lg:grid-cols-3 gap-8 w-full">
<SMArticleCard
v-for="(article, index) in articles"
:key="index"
:article="article" />
</div>
<div v-else class="py-12 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-24 text-gray-5">
<path
d="M453-280h60v-240h-60v240Zm26.982-314q14.018 0 23.518-9.2T513-626q0-14.45-9.482-24.225-9.483-9.775-23.5-9.775-14.018 0-23.518 9.775T447-626q0 13.6 9.482 22.8 9.483 9.2 23.5 9.2Zm.284 514q-82.734 0-155.5-31.5t-127.266-86q-54.5-54.5-86-127.341Q80-397.681 80-480.5q0-82.819 31.5-155.659Q143-709 197.5-763t127.341-85.5Q397.681-880 480.5-880q82.819 0 155.659 31.5Q709-817 763-763t85.5 127Q880-563 880-480.266q0 82.734-31.5 155.5T763-197.684q-54 54.316-127 86Q563-80 480.266-80Zm.234-60Q622-140 721-239.5t99-241Q820-622 721.188-721 622.375-820 480-820q-141 0-240.5 98.812Q140-622.375 140-480q0 141 99.5 240.5t241 99.5Zm-.5-340Z"
fill="currentColor" />
</svg>
<p class="text-lg text-gray-5">
{{ articlesError || "No posts where found" }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { Ref, ref, watch } from "vue";
import SMPagination from "../components/SMPagination.vue";
import { api } from "../helpers/api";
import { Article, ArticleCollection } from "../helpers/api.types";
import { SMDate } from "../helpers/datetime";
import SMMastHead from "../components/SMMastHead.vue";
import SMInput from "../components/SMInput.vue";
import SMLoading from "../components/SMLoading.vue";
import SMArticleCard from "../components/SMArticleCard.vue";
const message = ref("");
const pageLoading = ref(true);
const articles: Ref<Article[]> = ref([]);
const articlesPerPage = 24;
let articlesPage = ref(1);
let articlesTotal = ref(0);
const articlesError = ref("");
let searchInput = ref("");
let oldSearchInput = "";
const handleSearch = () => {
if (oldSearchInput != searchInput.value) {
oldSearchInput = searchInput.value;
articlesPage.value = 1;
handleLoad();
}
};
/**
* Load the page data.
*/
const handleLoad = () => {
message.value = "";
pageLoading.value = true;
articles.value = [];
let params = {
limit: articlesPerPage,
page: articlesPage.value,
};
if (searchInput.value.length > 0) {
params[
"filter"
] = `(title:${searchInput.value},OR,content:${searchInput.value})`;
}
api.get({
url: "/articles",
params: params,
})
.then((result) => {
const data = result.data as ArticleCollection;
articles.value = data.articles;
articlesTotal.value = data.total;
articles.value.forEach((article) => {
article.publish_at = new SMDate(article.publish_at, {
format: "ymd",
utc: true,
}).format("yyyy/MM/dd HH:mm:ss");
});
})
.catch((error) => {
if (error.status != 404) {
message.value =
error.data?.message ||
"The server is currently not available";
}
})
.finally(() => {
pageLoading.value = false;
});
};
watch(
() => articlesPage.value,
() => {
handleLoad();
},
);
handleLoad();
</script>
<style lang="scss">
.page-blog {
.articles {
display: grid;
grid-template-columns: 1fr;
gap: 30px;
}
}
@media (min-width: 768px) {
.page-blog .articles {
grid-template-columns: 1fr 1fr;
}
}
@media (min-width: 1024px) {
.page-blog .articles {
grid-template-columns: 1fr 1fr 1fr;
}
}
</style>

View File

@@ -1,3 +0,0 @@
<template>
<div>CART</div>
</template>

View File

@@ -1,150 +0,0 @@
<template>
<SMMastHead title="Code of Conduct" />
<div class="pb-12">
<div class="max-w-4xl mx-auto px-4">
<p class="pt-16 pb-2">
STEMMechanics supports the international community open to
everyone without discrimination. We want this community to be a
safe and welcoming place for both newcomers and current members.
Everyone should feel comfortable and accepted regardless of
their personal background and affiliation our projects and
workshops.
</p>
<SMHeader text="Philosophy" class="pt-16 pb-2" />
<p>
In the STEMMechanics community, participants from all over the
world come together to create and work on STEM projects. This is
made possible by the support, hard work, and enthusiasm of
people who collaborate towards the common goal of creating great
ideas. Cooperation at such a scale requires common guidelines to
ensure a positive and inspiring atmosphere in the community.
</p>
<p>
This is why we have this Code of Conduct: it explains the type
of community we want to have. The rules below are not applied to
all interactions with a simple matching algorithm. Human
interactions happen in context and are complex. Perceived
violations are evaluated by real humans who will try to
interpret the interactions and the rules with kindness.
Accordingly, there is no need to hypothesize on how these rules
would affect normal interactions. Be reasonable, the
<a href="#coc-team">Code of Conduct team</a> surely will be as
well.
</p>
<SMHeader text="Application" class="pt-16 pb-2" />
<p>
This Code of Conduct applies to all users, contributors and
participants who engage with the STEMMechanics workshops,
projects and its community platforms.
</p>
<SMHeader text="Expectations" class="pt-16 pb-2" />
<ul class="list-disc">
<li>
Politeness is expected at all times. Be kind and courteous.
</li>
<li>
Always assume positive intent from others. Be aware that
differences in culture and English proficiency make written
communication more difficult than face-to-face communication
and that your interpretation of messages may not be the one
the author intended. Conversely, if someone asks you to
rephrase something you said, be ready to do so without
feeling judged.
</li>
<li>
Feedback is always welcome but keep your criticism
constructive. We encourage you to open discussions,
proposals, issues, and bug reports. Use the community
platforms to discuss improvements, not to vent out
frustration. Similarly, when other users offer you feedback
please accept it gracefully.
</li>
</ul>
<SMHeader text="Restricted conduct" class="pt-16 pb-2" />
<p>
Participating in restricted conduct will lead to a warning from
community moderators and/or the Code of Conduct team and may
lead to exclusion from the community in the form of a ban from
one or all platforms.
</p>
<ul class="list-disc">
<li>
STEMMechanics is committed to providing a friendly and safe
environment for everyone, regardless of level of experience,
gender identity and expression, sexual orientation,
disability, physical appearance, body size, race, ethnicity,
language proficiency, age, political orientation,
nationality, religion, or other similar characteristics. We
do not tolerate harassment or discrimination of participants
in any form.
</li>
<li>
In particular, we strive to be welcoming to all and to
ensure that anyone can take a more active role in the
community and a project. Targeted harassment of minorities
or individuals is unacceptable.
</li>
<li>Aggressive or offensive behavior is not acceptable.</li>
<li>
You will be excluded from participating in the community if
you insult, demean, harass, intentionally make others
uncomfortable by any means, or participate in any other
hateful conduct, either publicly or privately.
</li>
<li>
Likewise, any spamming, trolling, flaming, baiting, or other
attention-stealing behavior is not welcome and will result
in exclusion from the community.
</li>
<li>
Any form of retaliation against a participant who contacts
the Code of Conduct team is completely unacceptable,
regardless of the outcome of the complaint. Any such
behavior will result in exclusion from the community.
</li>
<li>
For certainty, any conduct which could reasonably be
considered inappropriate in a professional setting is not
acceptable.
</li>
</ul>
<SMHeader text="Reporting a breach" class="pt-16 pb-2" />
<p>
If you witness or are involved in an interaction with another
community member that you think may violate this Code of
Conduct, please contact STEMMechanics
<a href="#coc-team">Code of Conduct team</a>.
</p>
<p>
STEMMechanics recognizes that it can be difficult to come
forward in cases of a violation of the Code of Conduct. To make
it easier to report violations, we provide a single point of
contact via email at:
<a href="conduct@stemmechanics.com.au"
>conduct@stemmechanics.com.au</a
>. If you are more comfortable reaching out to a single person,
you are also welcome to contact one or more members of the team
using their personal emails listed below, or via direct
messaging on community platforms where they are present.
</p>
<SMHeader
id="coc-item"
text="Code of Conduct team"
class="pt-16 pb-2" />
<ul class="list-disc">
<li>James Collins, james@stemmechanics.com.au</li>
<ul class="list-circle">
<li>
GitHub / Discord / Reddit / Twitter:
<span class="italic">nomadjimbob</span>
</li>
</ul>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import SMMastHead from "../components/SMMastHead.vue";
import SMHeader from "../components/SMHeader.vue";
</script>

View File

@@ -1,130 +0,0 @@
<template>
<SMMastHead title="Community"
>STEMMechanics has an active community across multiple channels. By
joining our communities, you agree to follow the
<router-link :to="{ name: 'code-of-conduct' }"
>Code of Conduct</router-link
>.</SMMastHead
>
<div class="max-w-7xl mx-auto px-4 pt-8">
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-8 w-full">
<a
:href="community.url"
class="min-w-84 decoration-none bg-white border-1 border-gray-3 rounded-xl transition hover:shadow-md"
v-for="(community, index) in communities"
:key="index">
<div
class="h-36 bg-cover bg-no-repeat bg-center rounded-t-xl"
:style="{
backgroundImage: `url(${community.thumbnail})`,
}"></div>
<h2 class="p-4">{{ community.title }}</h2>
<p class="text-sm text-gray-5 px-4 pb-8">
{{ community.content }}
</p>
</a>
</div>
</div>
</template>
<script setup lang="ts">
import SMMastHead from "../components/SMMastHead.vue";
const communities = [
{
thumbnail: "/assets/community-discord.webp",
url: "https://discord.gg/yNzk4x7mpD",
title: "Discord",
content:
"A vibrant community for discussion, user support, showcases... and custom emoji!",
},
{
thumbnail: "/assets/community-minecraft.webp",
url: "/minecraft",
title: "Minecraft",
content:
"Our usual hang-out to kill zombies and build redstone contraptions.",
},
{
thumbnail: "/assets/community-github.webp",
url: "https://github.com/stemmechanics",
title: "GitHub",
content: "All our open-source projects. Send bug reports here.",
},
{
thumbnail: "/assets/community-youtube.webp",
url: "https://youtube.com/stemmechanics",
title: "YouTube",
content: "Channel for official STEMMechanics videos.",
},
{
thumbnail: "/assets/community-facebook.webp",
url: "https://facebook.com/stemmechanics",
title: "Facebook",
content: "Community for discussions and showcasing workshops.",
},
{
thumbnail: "/assets/community-mastodon.webp",
url: "https://mastodon.au/@stemmechanics",
title: "Mastodon",
content: "Connect with us in the Fediverse.",
},
];
</script>
<style lang="scss">
.page-community {
.communities {
display: grid;
grid-template-columns: 1fr;
gap: 30px;
.community-card {
text-decoration: none;
color: var(--base-color-text);
background-color: var(--base-color-light);
box-shadow: var(--base-shadow);
&:hover {
filter: none;
.thumbnail {
filter: brightness(115%);
}
}
.thumbnail {
aspect-ratio: 16 / 9;
background-position: center;
background-size: cover;
background-color: var(--card-background-color);
margin-bottom: 24px;
}
.title {
margin: 0;
padding: 0 16px;
word-break: break-word;
color: var(--primary-color);
}
.content {
font-size: 90%;
padding: 0 16px 16px 16px;
}
}
}
}
@media (min-width: 640px) {
.page-community .communities {
grid-template-columns: 1fr 1fr;
}
}
@media (min-width: 768px) {
.page-community .communities {
grid-template-columns: 1fr 1fr 1fr;
}
}
</style>

View File

@@ -1,50 +0,0 @@
<template>
<SMMastHead title="Contact us" />
<div class="max-w-4xl mx-auto px-4">
<SMHeader text="Questions & Support" class="pt-16 pb-2" />
<p>
If you have a question or would like help with a project, you can
send it our way using the form on this page or be emailing
<a href="mailto:hello@stemmechanics.com.au"
>hello@stemmechanics.com.au</a
>.
</p>
<p>
You can find us on various social media platforms, and if you join
our
<a href="https://discord.gg/yNzk4x7mpD">Discord</a>
server, you'll have the opportunity to connect with our team,
participants, and other individuals who share similar interests.
</p>
<SMSocialIcons />
<SMHeader text="Wanting a workshop?" class="pt-16 pb-2" />
<p>
We provide both public and private workshops as well as run events
on behalf of your organisation. If you would like to discuss a
potential opportunity, send us an email at
<a href="mailto:hello@stemmechanics.com.au"
>hello@stemmechanics.com.au</a
>.
</p>
<SMHeader text="Where are you located?" class="pt-16 pb-2" />
<p>
We do not have a physical address as our workshops are delivered
across Queensland. Visit the
<router-link :to="{ name: 'workshops' }">workshops</router-link>
page for each specific location.
</p>
<p>Official mail can be sent to the following postal address:</p>
<div class="mt-8 text-center">
<p class="mt-4">
STEMMechanics<br />PO Box 36<br />Edmonton, QLD, 4869<br />Australia
</p>
<p class=""><strong>ABN: </strong>15 772 281 735</p>
</div>
</div>
</template>
<script setup lang="ts">
import SMMastHead from "../components/SMMastHead.vue";
import SMSocialIcons from "../components/SMSocialIcons.vue";
import SMHeader from "../components/SMHeader.vue";
</script>

View File

@@ -1,101 +0,0 @@
<template>
<div
class="max-w-2xl mx-auto border-1 bg-white rounded-xl mt-7xl text-gray-5 px-12 py-8">
<template v-if="!formDone">
<SMForm ref="formObject" v-model="form" @submit="handleSubmit">
<h1 class="mb-4">Email Verify</h1>
<p class="mb-4">
Enter your verification code below. If you have not yet
received one,
<router-link to="/resend-verify-email"
>request a new code</router-link
>.
</p>
<SMInput class="mb-4" autofocus control="code" />
<div class="flex flex-justify-end items-center pt-4">
<input
v-if="!form.loading()"
type="submit"
class="font-medium px-6 py-1.5 rounded-md hover:shadow-md transition text-sm bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
value="Verify Code" />
<SMLoading v-else small />
</div>
</SMForm>
</template>
<template v-else>
<h1 class="mb-4">Email Verified!</h1>
<p class="mb-4">Hurrah, Your email has been verified!</p>
<div class="flex flex-justify-center items-center pt-4">
<router-link
role="button"
class="font-medium px-6 py-1.5 rounded-md hover:shadow-md transition text-sm bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
:to="{ name: 'login' }"
>Login</router-link
>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { useRoute } from "vue-router";
import SMForm from "../components/SMForm.vue";
import SMInput from "../components/SMInput.vue";
import { api } from "../helpers/api";
import { Form, FormControl } from "../helpers/form";
import { And, Max, Min, Required } from "../helpers/validate";
import { useToastStore } from "../store/ToastStore";
import SMLoading from "../components/SMLoading.vue";
// const { executeRecaptcha, recaptchaLoaded } = useReCaptcha();
const formDone = ref(false);
const formObject = ref(null);
let form = reactive(
Form({
code: FormControl("", And([Required(), Min(6), Max(6)])),
}),
);
const handleSubmit = async () => {
try {
form.loading(true);
await api.post({
url: "/users/verifyEmail",
body: {
code: form.controls.code.value,
// captcha_token: captcha,
},
});
formDone.value = true;
} catch (error) {
form.apiErrors(error, (message) => {
useToastStore().addToast({
title: "An error occurred",
content: message,
type: "danger",
});
});
} finally {
form.loading(false);
}
};
onMounted(() => {
if (useRoute().query.code !== undefined) {
const code = useRoute().query.code;
if (Array.isArray(code)) {
if (code.length > 0) {
form.controls.code.value = code[0];
}
} else {
form.controls.code.value = code;
}
formObject.value.submit();
}
});
</script>

View File

@@ -1,357 +0,0 @@
<template>
<SMLoading class="pt-24 pb-48" v-if="pageLoading" />
<SMPageStatus
v-else-if="!pageLoading && pageStatus != 200"
:status="pageStatus" />
<div v-else>
<div
class="max-w-4xl mx-auto h-96 text-center mb-8 relative rounded-4 overflow-hidden">
<div
class="blur bg-cover bg-center absolute top-0 left-0 w-full h-full -z-1 opacity-50"
:style="{
backgroundImage: `url('${mediaGetVariantUrl(
event.hero,
'large',
)}')`,
}"></div>
<img
:src="mediaGetVariantUrl(event.hero, 'large')"
class="h-full" />
</div>
<div>
<div
class="max-w-4xl mx-auto px-4 flex flex-col-reverse sm:flex-row">
<div class="sm:pr-8 mt-4 sm:mt-0">
<h1 class="pb-6">{{ event.title }}</h1>
<SMHTML class="mb-8" :html="event.content" />
<SMAttachments :model-value="event.attachments" />
</div>
<div class="sm:min-w-68">
<div
v-if="
event.status == 'closed' ||
((event.status == 'open' ||
event.status == 'full') &&
expired)
"
class="text-xs px-4 py-2 b-1 border-red-400 bg-red-100 text-red-900 text-center rounded">
Registration for this event has closed.
</div>
<div
v-if="event.status == 'full' && expired == false"
class="text-xs px-4 py-2 b-1 border-red-400 bg-red-100 text-red-900 text-center rounded">
This event is at capacity.
</div>
<div
v-if="event.status == 'soon'"
class="text-xs px-4 py-2 b-1 border-yellow-400 bg-yellow-100 text-yellow-900 text-center rounded">
Registration for this event will open soon.
</div>
<div
v-if="event.status == 'cancelled'"
class="text-xs px-4 py-2 b-1 border-red-400 bg-red-100 text-red-900 text-center rounded">
This event has been cancelled.
</div>
<div
v-if="
event.status == 'open' &&
expired == false &&
event.registration_type == 'none'
"
class="text-xs px-4 py-2 b-1 border-yellow-400 bg-yellow-100 text-yellow-900 text-center rounded">
Registration not required for this event.<br />Arrive
early to avoid disappointment as seating maybe limited.
</div>
<div
v-if="
event.status == 'open' &&
expired == false &&
event.registration_type == 'link'
"
class="workshop-registration workshop-registration-url">
<a
role="button"
:href="registerUrl"
class="font-medium px-6 py-1.5 rounded-md hover:shadow-md transition text-sm bg-green-600 hover:bg-green-500 text-white block text-center"
>Register for Event</a
>
</div>
<div
v-if="
event.status == 'open' &&
expired == false &&
event.registration_type == 'message'
"
class="text-xs px-4 py-2 b-1 border-yellow-400 bg-yellow-100 text-yellow-900 text-center rounded">
{{ event.registration_data }}
</div>
<router-link
v-if="userHasPermission('admin/events') && event.id"
role="button"
:to="{
name: 'dashboard-event-edit',
params: { id: event.id },
}"
class="font-medium mt-4 px-6 py-1.5 rounded-md hover:shadow-md transition text-sm border-1 bg-white border-sky-6 text-sky-600 block text-center"
>Edit Event</router-link
>
<div class="text-gray-6">
<h3 class="flex flex-items-center pb-2 pt-6">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 pr-1"
viewBox="0 -960 960 960">
<path
d="M180-80q-24 0-42-18t-18-42v-620q0-24 18-42t42-18h65v-60h65v60h340v-60h65v60h65q24 0 42 18t18 42v620q0 24-18 42t-42 18H180Zm0-60h600v-430H180v430Zm0-490h600v-130H180v130Zm0 0v-130 130Zm300 230q-17 0-28.5-11.5T440-440q0-17 11.5-28.5T480-480q17 0 28.5 11.5T520-440q0 17-11.5 28.5T480-400Zm-160 0q-17 0-28.5-11.5T280-440q0-17 11.5-28.5T320-480q17 0 28.5 11.5T360-440q0 17-11.5 28.5T320-400Zm320 0q-17 0-28.5-11.5T600-440q0-17 11.5-28.5T640-480q17 0 28.5 11.5T680-440q0 17-11.5 28.5T640-400ZM480-240q-17 0-28.5-11.5T440-280q0-17 11.5-28.5T480-320q17 0 28.5 11.5T520-280q0 17-11.5 28.5T480-240Zm-160 0q-17 0-28.5-11.5T280-280q0-17 11.5-28.5T320-320q17 0 28.5 11.5T360-280q0 17-11.5 28.5T320-240Zm320 0q-17 0-28.5-11.5T600-280q0-17 11.5-28.5T640-320q17 0 28.5 11.5T680-280q0 17-11.5 28.5T640-240Z"
fill="currentColor" />
</svg>
Date / Time
</h3>
<p
v-for="(line, index) in workshopDate"
:key="index"
class="pl-6 text-sm mt-0">
{{ line }}
</p>
</div>
<div class="text-gray-6">
<h3 class="flex flex-items-center pb-2 pt-6">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 pr-2"
viewBox="0 -960 960 960">
<path
d="M480.089-490Q509-490 529.5-510.589q20.5-20.588 20.5-49.5Q550-589 529.411-609.5q-20.588-20.5-49.5-20.5Q451-630 430.5-609.411q-20.5 20.588-20.5 49.5Q410-531 430.589-510.5q20.588 20.5 49.5 20.5ZM480-159q133-121 196.5-219.5T740-552q0-117.79-75.292-192.895Q589.417-820 480-820t-184.708 75.105Q220-669.79 220-552q0 75 65 173.5T480-159Zm0 79Q319-217 239.5-334.5T160-552q0-150 96.5-239T480-880q127 0 223.5 89T800-552q0 100-79.5 217.5T480-80Zm0-472Z"
fill="currentColor" />
</svg>
Location
</h3>
<p class="pl-6 text-sm mt-0">
<template v-if="event.location == 'online'"
>Online event</template
>
<template
v-else-if="event.location_url.length == 0"
>{{ event.address }}</template
>
<template v-else
><a
:href="event.location_url"
no-follow
target="_blank"
>{{ event.address }}</a
></template
>
</p>
</div>
<div v-if="event.ages" class="text-gray-6">
<h3 class="flex flex-items-center pb-2 pt-6">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 pr-2"
viewBox="0 -960 960 960">
<path
d="M626-533q22.5 0 38.25-15.75T680-587q0-22.5-15.75-38.25T626-641q-22.5 0-38.25 15.75T572-587q0 22.5 15.75 38.25T626-533Zm-292 0q22.5 0 38.25-15.75T388-587q0-22.5-15.75-38.25T334-641q-22.5 0-38.25 15.75T280-587q0 22.5 15.75 38.25T334-533Zm146 272q66 0 121.5-35.5T682-393H278q26 61 81 96.5T480-261Zm0 181q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-400Zm0 340q142.375 0 241.188-98.812Q820-337.625 820-480t-98.812-241.188Q622.375-820 480-820t-241.188 98.812Q140-622.375 140-480t98.812 241.188Q337.625-140 480-140Z"
fill="currentColor" />
</svg>
{{ computedAges }}
</h3>
<p
class="text-sm border-l-4 pl-2 ml-2 border-yellow-400">
{{ computedAgeNotice }}
</p>
</div>
<div v-if="event.price" class="text-gray-6">
<h3 class="flex flex-items-center pb-2 pt-6">
<div class="w-6 text-center font-normal">$</div>
{{ computedPrice }}
</h3>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, Ref, ref } from "vue";
import { useRoute } from "vue-router";
import SMAttachments from "../components/SMAttachments.vue";
import { api } from "../helpers/api";
import { Event, EventResponse } from "../helpers/api.types";
import { SMDate } from "../helpers/datetime";
import { stringToNumber } from "../helpers/string";
import { useApplicationStore } from "../store/ApplicationStore";
import { mediaGetVariantUrl } from "../helpers/media";
import { userHasPermission } from "../helpers/utils";
import SMLoading from "../components/SMLoading.vue";
import SMPageStatus from "../components/SMPageStatus.vue";
import SMHTML from "../components/SMHTML.vue";
const applicationStore = useApplicationStore();
/**
* Event data
*/
const event: Ref<Event | null> = ref(null);
const route = useRoute();
const pageLoading = ref(true);
/**
* Page error.
*/
let pageStatus = ref(200);
const workshopDate = computed(() => {
let str: string[] = [];
if (Object.keys(event.value).length > 0) {
if (
event.value.end_at.length > 0 &&
event.value.start_at.substring(
0,
event.value.start_at.indexOf(" "),
) !=
event.value.end_at.substring(0, event.value.end_at.indexOf(" "))
) {
str = [
new SMDate(event.value.start_at, { format: "ymd" }).format(
"dd/MM/yyyy",
),
];
if (event.value.end_at.length > 0) {
str[0] =
str[0] +
" - " +
new SMDate(event.value.end_at, { format: "ymd" }).format(
"dd/MM/yyyy",
);
}
} else {
str = [
new SMDate(event.value.start_at, { format: "ymd" }).format(
"EEEE dd MMM yyyy",
),
new SMDate(event.value.start_at, { format: "ymd" }).format(
"h:mm aa",
) +
" - " +
new SMDate(event.value.end_at, { format: "ymd" }).format(
"h:mm aa",
),
];
}
}
return str;
});
/**
* Return a computed price amount, if a form of 0, return "Free"
*/
const computedPrice = computed(() => {
if (
event.value.price.toLowerCase() == "tbc" ||
event.value.price.toLowerCase() == "tbd"
) {
return event.value.price.toUpperCase();
}
const parsedPrice = stringToNumber(event.value.price || "0");
if (parsedPrice == 0) {
return "Free";
}
return event.value.price;
});
const registerUrl = computed(() => {
let href = "";
if (event.value?.registration_type == "link") {
return event.value?.registration_data;
} else if (event.value?.registration_type == "email") {
return "mailto:" + event.value?.registration_data;
}
return href;
});
const expired = computed(() => {
return new SMDate(event.value.end_at, {
format: "ymd",
}).isBefore();
});
/**
* Return a human readable Ages string.
*/
const computedAges = computed(() => {
const trimmed = event.value.ages.trim();
const regex = /^(\d+)(\s*\+?\s*|\s*-\s*\d+\s*)?$/;
if (regex.test(trimmed)) {
return `Ages ${trimmed}`;
}
return event.value.ages;
});
/**
* Display a age notice if required.
*/
const computedAgeNotice = computed(() => {
const trimmed = event.value.ages.trim();
const regex = /^(\d+)(\s*\+?\s*|\s*-\s*\d+\s*)?$/;
if (regex.test(trimmed)) {
const age = parseInt(trimmed, 10);
if (age <= 8) {
return "Parental supervision may be required for children 8 years of age and under.";
}
}
return "";
});
/**
* Load the page data.
*/
const handleLoad = async () => {
pageLoading.value = true;
try {
let result = await api.get({
url: "/events/{event}",
params: {
event: route.params.id,
},
});
const eventData = result.data as EventResponse;
if (eventData && eventData.event) {
event.value = eventData.event;
event.value.start_at = new SMDate(event.value.start_at, {
format: "ymd",
utc: true,
}).format("yyyy/MM/dd HH:mm:ss");
event.value.end_at = new SMDate(event.value.end_at, {
format: "ymd",
utc: true,
}).format("yyyy/MM/dd HH:mm:ss");
applicationStore.setDynamicTitle(event.value.title);
} else {
pageStatus.value = 404;
}
} catch (error) {
pageStatus.value = error.status;
} finally {
pageLoading.value = false;
}
};
handleLoad();
</script>

View File

@@ -1,193 +0,0 @@
<template>
<SMPageStatus
v-if="pageLoading == false && pageStatus != 200"
:status="pageStatus" />
<SMLoading v-else-if="pageLoading == true"></SMLoading>
<SMForm
v-else-if="showForm == 'password'"
:model-value="form"
@submit="handleSubmit">
<div
class="max-w-2xl mx-auto border-1 bg-white rounded-xl mt-7xl text-gray-5 px-12 py-8">
<h3 class="mb-4">Password Required</h3>
<p class="mb-2 text-sm">
The file <strong>{{ fileName }}</strong> requires a password
before you can view it:
</p>
<SMInput
class="mb-4"
control="password"
type="password"
label="File Password"
autofocus />
<div class="flex flex-justify-end">
<input
type="submit"
class="font-medium block w-full md:inline-block md:w-auto px-6 py-1.5 rounded-md hover:shadow-md transition text-sm bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
value="Submit" />
</div>
</div>
</SMForm>
<div v-else-if="showForm == 'complete'">
<div
class="max-w-2xl mx-auto border-1 bg-white rounded-xl mt-7xl text-gray-5 px-12 py-8">
<h3 class="mb-4">Download Requested</h3>
<p class="mb-2">
If you have permission to view this document, your download
should now begin.
</p>
<div class="flex flex-justify-between">
<button
role="button"
class="font-medium block w-full md:inline-block md:w-auto px-6 py-1.5 rounded-md hover:shadow-md transition text-sm bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
@click="handleReload()">
Retry
</button>
<router-link
:to="{ name: 'home' }"
role="button"
class="font-medium block w-full md:inline-block md:w-auto px-6 py-1.5 rounded-md hover:shadow-md transition text-sm bg-sky-600 hover:bg-sky-500 text-white cursor-pointer">
Home
</router-link>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from "vue";
import { api } from "../helpers/api";
import { useRoute } from "vue-router";
import { Media, MediaResponse } from "../helpers/api.types";
import SMForm from "../components/SMForm.vue";
import SMInput from "../components/SMInput.vue";
import SMLoading from "../components/SMLoading.vue";
import SMPageStatus from "../components/SMPageStatus.vue";
import { strCaseCmp } from "../helpers/string";
import { useUserStore } from "../store/UserStore";
import { Form, FormControl, FormObject } from "../helpers/form";
import { Required } from "../helpers/validate";
const pageStatus = ref(200);
const pageLoading = ref(true);
const showForm = ref("");
const fileUrl = ref("");
const fileName = ref("");
const userStore = useUserStore();
const form: FormObject = reactive(
Form({
password: FormControl("", Required()),
}),
);
/*
* Download file from URL
*/
const downloadFile = (params = {}) => {
let url = fileUrl.value;
// Check if the URL already contains query parameters
const hasQueryParameters = url.includes("?");
if (Object.keys(params).length > 0) {
url += hasQueryParameters ? "&" : "?";
url += Object.keys(params)
.map(
(key) =>
encodeURIComponent(key) +
"=" +
encodeURIComponent(params[key]),
)
.join("&");
}
window.location.href = url;
window.setTimeout(() => {
showForm.value = "complete";
}, 1500);
};
/*
* Handle password form submit
*/
const handleSubmit = () => {
const params = {
password: form.controls.password.value,
};
downloadFile(params);
};
const handleClose = () => {
window.close();
};
/**
* Handle page loading
*/
const handleLoad = async () => {
const route = useRoute();
if (
route === undefined ||
route.params === undefined ||
route.params.id === undefined
) {
pageStatus.value = 404;
pageLoading.value = false;
} else {
const params = {
id: route.params.id,
};
try {
let result = await api.get({
url: "/media/{id}",
params: params,
});
if (result.status === 200) {
const data = result.data as MediaResponse;
const medium = data.medium as Media;
fileName.value = medium.name;
fileUrl.value = medium.url;
if (medium.security_type === "") {
downloadFile();
} else if (
strCaseCmp("permission", medium.security_type) === true &&
userStore.id
) {
const params = {
token: userStore.token,
};
downloadFile(params);
} else if (
strCaseCmp("password", medium.security_type) === true
) {
showForm.value = "password";
} else {
/* unknown security type */
pageStatus.value = 403;
}
pageLoading.value = false;
} else {
pageStatus.value = result.status;
pageLoading.value = false;
}
} catch (error) {
pageStatus.value = error.status;
pageLoading.value = false;
}
}
};
const handleReload = () => {
window.location.reload();
};
handleLoad();
</script>

View File

@@ -1,91 +0,0 @@
<template>
<div
class="max-w-2xl mx-auto border-1 bg-white rounded-xl mt-7xl text-gray-5 px-12 py-8">
<template v-if="formDone">
<h1 class="mb-4">Forgot Password</h1>
<p class="mb-4">
Enter your email below to receive a password reset link.
</p>
<SMForm v-model="form" @submit="handleSubmit">
<SMInput control="email" type="email" autofocus />
<div
class="flex flex-justify-between items-center pt-4 flex-col sm:flex-row">
<div class="text-xs mb-4 sm:mb-0">
<span class="pr-1">Remember?</span
><router-link :to="{ name: 'login' }"
>Log in</router-link
>
</div>
<input
v-if="!form.loading()"
type="submit"
class="font-medium px-6 py-1.5 rounded-md hover:shadow-md transition text-sm bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
value="Send Email" />
<SMLoading v-else small />
</div>
</SMForm>
</template>
<template v-else>
<h1 class="mb-4">Email Sent!</h1>
<p class="mb-4">
If that email address has been registered, you will receive an
email with a reset password link in the next few minutes.
</p>
<div class="flex flex-justify-center items-center pt-4">
<router-link
role="button"
class="font-medium px-6 py-1.5 rounded-md hover:shadow-md transition text-sm bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
:to="{ name: 'home' }"
>Home</router-link
>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from "vue";
import SMForm from "../components/SMForm.vue";
import SMInput from "../components/SMInput.vue";
import { api } from "../helpers/api";
import { Form, FormControl } from "../helpers/form";
import { And, Email, Required } from "../helpers/validate";
import { useToastStore } from "../store/ToastStore";
import SMLoading from "../components/SMLoading.vue";
const formDone = ref(false);
let form = reactive(
Form({
email: FormControl("", And([Required(), Email()])),
}),
);
const handleSubmit = async () => {
try {
form.loading(true);
await api.post({
url: "/users/forgotPassword",
body: {
email: form.controls.email.value,
},
});
formDone.value = true;
} catch (error) {
if (error.status == 422) {
formDone.value = true;
} else {
form.apiErrors(error, (message) => {
useToastStore().addToast({
title: "An error occurred",
content: message,
type: "danger",
});
});
}
} finally {
form.loading(false);
}
};
</script>

View File

@@ -1,385 +0,0 @@
<template>
<header class="bg-hero">
<div
class="max-w-7xl flex flex-row mx-auto px-4 pt-32 pb-32 lg:pt-36 gap-16 text-white">
<div class="flex-1 max-w-2xl">
<h1 class="leading-normal text-4xl lg:leading-normal">
Join the fun!
</h1>
<p class="mt-4">
To keep up with our ever-changing world, it's important to
encourage and support a new generation of curious minds who
love science, engineering, art, and leadership.
</p>
<p class="mt-4">
Our fun and exciting workshops can unlock countless
opportunities for new ideas and improvements, giving kids
the skills and tools they need to solve any problem that
comes their way.
</p>
</div>
</div>
<div class="flex justify-end">
<p class="text-white text-xs bg-black px-4 py-1 mb-5 mr-10">
Steady Hand Game in Ravenshoe
</p>
</div>
</header>
<section id="news" class="w-full pt-12 pb-8 bg-sky-100 dark:bg-dark-8">
<div class="max-w-7xl mx-auto">
<h2 class="font-semibold text-xl md:text-2xl px-6 lg:px-4 mb-4">
Latest News
</h2>
<SMLoading v-if="articlesLoading" />
<div
v-else-if="
!articlesLoading &&
articlesError.length == 0 &&
articles.length > 0
"
class="grid md:grid-cols-2 lg:grid-cols-3 gap-5 px-4">
<SMArticleCard
v-for="article in articles"
:article="article"
:key="article.id" />
</div>
<div v-else class="py-12 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-24 text-gray-5">
<path
d="M453-280h60v-240h-60v240Zm26.982-314q14.018 0 23.518-9.2T513-626q0-14.45-9.482-24.225-9.483-9.775-23.5-9.775-14.018 0-23.518 9.775T447-626q0 13.6 9.482 22.8 9.483 9.2 23.5 9.2Zm.284 514q-82.734 0-155.5-31.5t-127.266-86q-54.5-54.5-86-127.341Q80-397.681 80-480.5q0-82.819 31.5-155.659Q143-709 197.5-763t127.341-85.5Q397.681-880 480.5-880q82.819 0 155.659 31.5Q709-817 763-763t85.5 127Q880-563 880-480.266q0 82.734-31.5 155.5T763-197.684q-54 54.316-127 86Q563-80 480.266-80Zm.234-60Q622-140 721-239.5t99-241Q820-622 721.188-721 622.375-820 480-820q-141 0-240.5 98.812Q140-622.375 140-480q0 141 99.5 240.5t241 99.5Zm-.5-340Z"
fill="currentColor" />
</svg>
<p class="text-lg text-gray-5">
{{ articlesError || "No articles where found" }}
</p>
</div>
</div>
</section>
<section class="max-w-7xl flex flex-row mx-auto px-4 py-24 lg:py-36 gap-16">
<div
class="flex-1 lg:flex hidden justify-end flex-self-center rounded-lg bg-gray-900 aspect-video relative overflow-clip max-h-82 w-120 h-283">
<img
class="w-full h-full object-cover"
src="/assets/home-green-screen.webp" />
</div>
<div class="flex-1">
<h2
class="font-medium leading-normal lg:text-4xl lg:leading-normal text-4xl">
Build skills while having a great time
</h2>
<p class="text-xl mt-4">
To keep up with our ever-changing world, it's important to
encourage and support a new generation of curious minds who love
science, engineering, art, and leadership.
</p>
<div class="flex flex-row gap-4 mt-8 flex-justify-center">
<router-link
:to="{ name: 'workshops' }"
role="button"
class="font-medium px-6 py-1.5 rounded-md hover:shadow-md transition bg-green-600 hover:bg-green-500 text-white">
Explore Workshops
</router-link>
</div>
</div>
</section>
<section
id="workshops"
class="w-full py-12 lg:py-16 bg-fuchsia-50 dark:bg-dark-8">
<div class="max-w-7xl mx-auto">
<h2 class="font-semibold text-3xl md:text-4xl px-6 lg:px-4 mb-14">
Upcoming workshops
</h2>
<SMLoading v-if="eventsLoading" />
<div
v-else-if="
!eventsLoading &&
eventsError.length == 0 &&
events.length > 0
"
class="grid md:grid-cols-2 lg:grid-cols-3 gap-5 px-4">
<SMEventCard
v-for="event in events"
:event="event"
:key="event.id" />
</div>
<div v-else class="py-12 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-24 text-gray-5">
<path
d="M453-280h60v-240h-60v240Zm26.982-314q14.018 0 23.518-9.2T513-626q0-14.45-9.482-24.225-9.483-9.775-23.5-9.775-14.018 0-23.518 9.775T447-626q0 13.6 9.482 22.8 9.483 9.2 23.5 9.2Zm.284 514q-82.734 0-155.5-31.5t-127.266-86q-54.5-54.5-86-127.341Q80-397.681 80-480.5q0-82.819 31.5-155.659Q143-709 197.5-763t127.341-85.5Q397.681-880 480.5-880q82.819 0 155.659 31.5Q709-817 763-763t85.5 127Q880-563 880-480.266q0 82.734-31.5 155.5T763-197.684q-54 54.316-127 86Q563-80 480.266-80Zm.234-60Q622-140 721-239.5t99-241Q820-622 721.188-721 622.375-820 480-820q-141 0-240.5 98.812Q140-622.375 140-480q0 141 99.5 240.5t241 99.5Zm-.5-340Z"
fill="currentColor" />
</svg>
<p class="text-lg text-gray-5">
{{ eventsError || "No workshops scheduled at this time" }}
</p>
</div>
</div>
</section>
<div class="bg-minecraft">
<section
class="max-w-7xl flex flex-col mx-auto px-4 pt-32 pb-26 lg:pt-36 lg:pb-46 text-white">
<h2
class="font-medium leading-normal lg:text-4xl lg:leading-normal text-4xl">
Play Minecraft with us
</h2>
<p class="text-xl mt-4">
We invite you to join us on our
<router-link :to="{ name: 'minecraft' }">
Minecraft server</router-link
>
where you can participate in weekly challenges and mini-games.
</p>
<div
class="flex flex-row gap-4 mt-8 items-center flex-justify-center">
<img
src="/assets/home-minecraft-edu.webp"
loading="lazy"
class="h-24" />
<p class="text-xl mt-4">
We also offer workshops for
<a
href="https://education.minecraft.net/en-us/discover/what-is-minecraft"
target="_blank">
Minecraft Education</a
>
, where you can learn to make it rain rabbits or grow
flowers wherever you walk, all without the need for a school
account.
</p>
</div>
<div class="flex flex-row gap-4 mt-8 flex-justify-center">
<img
src="/assets/home-minecraft-address.webp"
loading="lazy"
class="max-w-140 w-full" />
</div>
</section>
</div>
<section
class="max-w-7xl flex flex-row mx-auto px-4 pt-24 pb-8 lg:pt-36 lg:pb-8 gap-16">
<div
class="flex-1 lg:flex hidden justify-end flex-self-center rounded-lg bg-gray-900 aspect-video relative overflow-clip max-h-82 w-120 h-283">
<img
class="w-full h-full object-cover"
src="/assets/home-discord.webp" />
</div>
<div class="flex-1">
<h2
class="font-medium leading-normal lg:text-4xl lg:leading-normal text-4xl">
And the support doesn't stop!
</h2>
<p class="text-xl mt-4">
Though the workshop has come to a close, we remain available to
assist you via email and Discord with any projects you undertake
at home. We are always happy to help.
</p>
<div class="flex flex-row gap-4 mt-8 flex-justify-center">
<a
role="button"
href="https://discord.gg/yNzk4x7mpD"
class="font-medium px-6 py-1.5 rounded-md hover:shadow-md transition bg-sky-600 hover:bg-sky-500 text-white">
Join Discord
</a>
<router-link
:to="{ name: 'contact' }"
role="button"
class="font-medium px-6 py-1.5 rounded-md hover:shadow-md text-black transition border-1 border-gray-400 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800">
Contact Us
</router-link>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { api, getApiResultData } from "../helpers/api";
import { ArticleCollection, EventCollection } from "../helpers/api.types";
import { SMDate } from "../helpers/datetime";
import SMArticleCard from "../components/SMArticleCard.vue";
import SMEventCard from "../components/SMEventCard.vue";
import SMLoading from "../components/SMLoading.vue";
const events = ref([]);
const articles = ref([]);
let eventsLoading = ref(true);
let eventsError = ref("");
let articlesLoading = ref(true);
let articlesError = ref("");
const viewLoad = async () => {
eventsLoading.value = true;
eventsError.value = "";
articlesLoading.value = true;
articlesError.value = "";
api.get({
url: "/events",
params: {
limit: 10,
sort: "start_at",
start_at: `>${new SMDate("now").format("yyyy-MM-dd hh:mm:ss")}`,
},
callback: (eventsResult) => {
if (eventsResult.status < 300) {
const eventsData =
getApiResultData<EventCollection>(eventsResult);
if (eventsData && eventsData.events) {
events.value = [];
for (const event of eventsData.events) {
if (
event.status === "open" ||
event.status === "soon"
) {
events.value.push(event);
if (events.value.length === 4) break;
}
}
}
} else {
if (eventsResult.status != 404) {
eventsError.value =
"An error occured retrieving the events";
}
}
eventsLoading.value = false;
},
});
api.get({
url: "/articles",
params: {
limit: 4,
},
callback: (articlesResult) => {
if (articlesResult.status < 300) {
const articlesData =
getApiResultData<ArticleCollection>(articlesResult);
if (articlesData && articlesData.articles) {
articles.value = articlesData.articles;
}
} else {
if (articlesResult.status != 404) {
articlesError.value =
"An error occured retrieving the posts";
}
}
articlesLoading.value = false;
},
});
// try {
// await Promise.all([
// api
// .get({
// url: "/events",
// params: {
// limit: 10,
// sort: "start_at",
// start_at: `>${new SMDate("now").format(
// "yyyy-MM-dd hh:mm:ss",
// )}`,
// },
// })
// .then((eventsResult) => {
// const eventsData =
// getApiResultData<EventCollection>(eventsResult);
// if (eventsData && eventsData.events) {
// events.value = [];
// for (const event of eventsData.events) {
// if (
// event.status === "open" ||
// event.status === "soon"
// ) {
// events.value.push(event);
// if (events.value.length === 4) break;
// }
// }
// }
// })
// .catch((error) => {
// if (error.status != 404) {
// eventsError.value =
// "An error occured retrieving the events";
// }
// })
// .finally(() => {
// eventsLoading.value = false;
// }),
// api
// .get({
// url: "/articles",
// params: {
// limit: 4,
// },
// })
// .then((articlesResult) => {
// const articlesData =
// getApiResultData<ArticleCollection>(articlesResult);
// if (articlesData && articlesData.articles) {
// articles.value = articlesData.articles;
// }
// })
// .catch((error) => {
// if (error.status != 404) {
// articlesError.value =
// "An error occured retrieving the posts";
// }
// })
// .finally(() => {
// articlesLoading.value = false;
// }),
// ]);
// } catch {
// /* empty */
// }
};
viewLoad();
</script>
<style lang="scss">
.bg-hero {
margin-top: -70px;
background-image: linear-gradient(
to right,
rgba(0, 0, 0, 0.7),
rgba(0, 0, 0, 0.2)
),
url("https://www.stemmechanics.com.au/assets/home-hero.webp");
background-repeat: no-repeat;
background-position: center;
background-size: cover;
}
.bg-minecraft {
background-image: url("https://www.stemmechanics.com.au/assets/home-minecraft.webp");
background-repeat: no-repeat;
background-position: center;
background-size: cover;
}
@media (min-width: 1024px) {
#news .article-card:nth-child(4),
#workshops .event-card:nth-child(4) {
display: none;
}
}
</style>

View File

@@ -1,104 +0,0 @@
<template>
<div
class="max-w-2xl mx-auto border-1 bg-white rounded-xl mt-7xl text-gray-5 px-12 py-8">
<SMForm v-model="form" @submit="handleSubmit">
<h1 class="mb-4">Log in</h1>
<p class="mb-4">
Enter your website login details to view your account.
</p>
<SMInput class="mb-4" control="email" autofocus type="email">
</SMInput>
<SMInput class="mb-4" control="password" type="password">
<router-link to="/forgot-password"
>Forgot password?</router-link
>
</SMInput>
<div
class="flex flex-justify-between items-center pt-4 flex-col sm:flex-row">
<div class="text-xs mb-4 sm:mb-0">
<span class="pr-1">Need an account?</span
><router-link to="/register">Register</router-link>
</div>
<input
v-if="!form.loading()"
type="submit"
class="font-medium px-6 py-1.5 rounded-md hover:shadow-md transition text-sm bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
value="Log in" />
<SMLoading v-else small />
</div>
</SMForm>
</div>
</template>
<script setup lang="ts">
import { reactive } from "vue";
import { useRoute, useRouter } from "vue-router";
import SMForm from "../components/SMForm.vue";
import SMInput from "../components/SMInput.vue";
import { api } from "../helpers/api";
import { LoginResponse } from "../helpers/api.types";
import { Form, FormControl } from "../helpers/form";
import { And, Email, Required } from "../helpers/validate";
import { useUserStore } from "../store/UserStore";
import { useToastStore } from "../store/ToastStore";
import SMLoading from "../components/SMLoading.vue";
const userStore = useUserStore();
const router = useRouter();
let form = reactive(
Form({
email: FormControl("", And([Required(), Email()])),
password: FormControl("", Required()),
})
);
const redirectQuery = useRoute().query.redirect;
/**
* Handle the user submitting the login form.
*/
const handleSubmit = async () => {
form.message();
form.loading(true);
try {
let result = await api.post({
url: "/login",
body: {
email: form.controls.email.value,
password: form.controls.password.value,
},
});
const login = result.data as LoginResponse;
userStore.setUserDetails(login.user);
userStore.setUserToken(login.token);
if (redirectQuery !== undefined) {
const redirect = Array.isArray(redirectQuery)
? redirectQuery[0]
: redirectQuery;
router.push(decodeURIComponent(redirect));
} else {
router.push({ name: "dashboard" });
}
} catch (error) {
form.controls.password.value = "";
form.apiErrors(error, (message) => {
useToastStore().addToast({
title: "An error occurred",
content: message,
type: "danger",
});
});
} finally {
form.loading(false);
}
};
if (userStore.token) {
userStore.clearUser();
}
</script>

View File

@@ -1,37 +0,0 @@
<template>
<SMLoading />
</template>
<script setup lang="ts">
import { useRouter } from "vue-router";
import SMLoading from "../components/SMLoading.vue";
import { api } from "../helpers/api";
import { useToastStore } from "../store/ToastStore";
import { useUserStore } from "../store/UserStore";
const router = useRouter();
const userStore = useUserStore();
const toastStore = useToastStore();
/**
* Logout the current user and redirect to home page.
*/
const logout = async () => {
api.post({
url: "/logout",
}).finally(() => {
userStore.clearUser();
toastStore.addToast({
title: "Logged Out",
content: "You have been logged out.",
type: "success",
});
router.push({ name: "home" });
});
};
logout();
</script>
<style lang="scss"></style>

View File

@@ -1,99 +0,0 @@
<template>
<SMMastHead title="STEMCraft" />
<div class="max-w-4xl mx-auto px-4">
<SMHeader id="connect" text="Join us on STEMCraft" class="pt-16 pb-2" />
<div class="flex flex-col md:flex-row gap-4 flex-items-center">
<div
class="h-75 w-full md:w-75 bg-norepeat bg-center bg-cover rounded-2 border-1"
style="
background-image: url(&quot;/assets/vareal.webp&quot;);
"></div>
<div class="flex-1">
<p>
Howdy Minecraft fans, we invite you to join us on our own
Minecraft server,
<a href="https://www.stemcraft.com.au/">STEMCraft</a>.
</p>
<p>
STEMCraft offers a unique blend of survival gameplay,
captivating mini-games, and an array of custom items and
mobs that will keep you immersed in a thrilling gaming
experience.
</p>
<p>
And best of all, the server can be customized by YOU!
STEMCraft goes beyond mere entertainmentit is designed with
an educational focus. We have incorporated special features
to support our workshops, from coding new in commands in
Java, customized mobs using 3D editing tools such as Blender
and designing unique items for players to interact with.
</p>
<p>
Jump over to the
<a href="https://www.stemcraft.com.au/">STEMCraft</a>
website for more information.
</p>
</div>
</div>
<SMHeader text="Goodbye Drustcraft" class="pt-16 pb-2" />
<p>
STEMMechanics launched the Drustcraft server three years ago and
since then, players have had countless enjoyable experiences. Cities
were built, bosses defeated, and most importantly, a tight-knit
community formed.
</p>
<p>
Maintaining the server design became overwhelming and took away the
fun of playing Minecraft. Hence, in January, the decision was made
to shut down Drustcraft and offer a more straightforward Minecraft
server, retaining the beloved elements of Drustcraft like
mini-games, bosses, and survival. Join us on the new STEMMechanics
Minecraft server, where the Drustcraft community awaits.
</p>
<SMHeader text="So long Cairns Minecraft" class="pt-16 pb-2" />
<p>
After seven incredible years of operation, the Cairns Minecraft
server officially closed its virtual doors in May 2022. This
close-knit online community, which brought together gamers from
around the region, renowned for its fantastic builds, lively
competitions, and unique events. Throughout its existence, players
forged genuine friendships, collaborated on awe-inspiring projects,
and pushed the boundaries of creativity in the world of Minecraft.
Although the server's closure marked the end of an era, the
cherished memories and invaluable experiences shared by its members
will forever remain etched in the hearts of the Cairns Minecraft
community.
</p>
<SMAttachments class="mt-8" :attachments="downloads" />
</div>
</template>
<script setup lang="ts">
import SMAttachments from "../components/SMAttachments.vue";
import SMMastHead from "../components/SMMastHead.vue";
import SMHeader from "../components/SMHeader.vue";
const downloads = [
{
id: "1",
title: "Cairns Minecraft - Survival",
name: "2103-cm-survival.zip",
url: "https://cdn.stemmechanics.com.au/2103-cm-survival.zip",
size: 6098565968,
},
{
id: "2",
title: "Cairns Minecraft - Creative",
name: "2205-cm-creative-complete.zip",
url: "https://cdn.stemmechanics.com.au/2205-cm-creative-complete.zip",
size: 6712439230,
},
{
id: "3",
title: "Cairns Minecraft - Creative (Worlds Only)",
name: "2205-cm-creative.zip",
url: "https://cdn.stemmechanics.com.au/2205-cm-creative.zip",
size: 3585899092,
},
];
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -1,359 +0,0 @@
<template>
<SMMastHead title="Privacy Policy" />
<div class="max-w-4xl mx-auto px-4">
<SMHeader
id="serious"
text="We take our customers' privacy & security seriously."
class="pt-16 pb-2" />
<p>
At STEMMechanics, we take our customers' privacy and security
seriously. We are committed to protecting your privacy and security
and to complying with the Australian Privacy Principles in the
Australian Privacy Act.
</p>
<p>
The purpose of the Privacy Policy ("Privacy Policy") is to outline
the personal information collected by us and the use of such
information. The Privacy Policy applies to (stemmechanics.com.au)
and online services (collectively, the "Sites") of STEMMechanics.
("STEMMechanics"). In this Privacy Policy, "Personal Information"
means information or opinion about an identified individual, or an
individual who is reasonably identifiable in accordance with section
6 of the Privacy Act 1988 (Cth).
</p>
<p>
By using the Website and our online services, you agree to accept
the Privacy Policy and the Site's Terms and Conditions
<router-link :to="{ name: 'terms-and-conditions' }"
>https://www.stemmechanics.com.au/terms-and-conditions</router-link
>
(Terms and Conditions). Where the Privacy Policy uses a word
starting with a capital letter, that term will be defined in the
Terms and Conditions or elsewhere in this Privacy Policy. If you do
not agree to the terms of this Privacy Policy or the Terms and
Conditions, you should not use the Site.
</p>
<SMHeader
id="1"
text="1. The kinds of information we collect and hold"
class="pt-16 pb-2" />
<p>
Depending on the particular circumstances, we may collect and hold a
range of different information about you.
</p>
<SMHeader
:size="4"
id="1.1"
text="1.1. Individually identifiable information"
class="pt-16 pb-2" />
<p>
The types of individually identifiable information we collect will
depend on the purposes(s) for which we are collecting it. For
example, we may ask for:
</p>
<ul class="list-disc">
<li>your name;</li>
<li>your gamer tag or gamer username;</li>
<li>contact details;</li>
<li>identification information;</li>
<li>
historical records of your communication and interaction with
us;
</li>
<li>
details or history of preference, interests and behaviour
relation to transactions, products, services and activities on
our Site;
</li>
</ul>
<p>
As well as other similar Personal Information that is needed to
register or subscribe you to our services or offers. If we ever ask
for significantly different information, we will inform you. If you
do not provide certain requested Personal Information to us, we may
not be able to provide you with access to and use of the Site,
provide you with access to our other products and services, or to
fulfil one or more of our functions and activities applicable to
you.
</p>
<SMHeader
:size="4"
id="1.2"
text="1.2. Non-identifiable information"
class="pt-16 pb-2" />
<p>
Non-identifiable information is data that has never been labelled
with individual identifiers or from which identifiers have been
permanently removed, and by means of which no specific individual
can be identified. When you visit the Site, our Company servers may
automatically record non-identifiable information that your browser
sends. This data may include:
</p>
<ul class="list-disc">
<li>your computer's IP address;</li>
<li>browser type;</li>
<li>
webpage you were visiting before you came to our Site; the pages
within
<router-link :to="{ name: 'home' }"
>www.stemmechanics.com.au</router-link
>
you visit;
</li>
<li>
the time spent on those pages, items, and information searched
on our Site, access times, dates and other statistics.
</li>
</ul>
<p>
Non-identifiable information is collected for analysis and
evaluation in order to help us improve our Site and the services and
products we provide. This data will not be used in association with
any other Personal Information
</p>
<SMHeader
id="2"
text="2. How we collect your information"
class="pt-16 pb-2" />
<p>We may collect your information in a number of ways, including:</p>
<p>(a) Directly from you, including but not limited when you:</p>
<ul class="list-disc">
<li>browse our Site;</li>
<li>save an item;</li>
<li>register on our Site;</li>
<li>log into the Site once registered;</li>
<li>make a purchase from us;</li>
<li>
contact our Customer Support, either about an order or for any
other reason;
</li>
<li>click on STEMMechanics banners, hyperlinks or plugins;</li>
<li>interact with us on our social media;</li>
<li>or have a conversation with our team members</li>
</ul>
<p>
(b) From third parties such as our related entities, business or
commercial partners, and
</p>
<p>(c) From publicly available sources of information.</p>
<p>
We may also collect information from you online. See more
information in clause 6.
</p>
<SMHeader
id="3"
text="3. How we hold your Personal Information"
class="pt-16 pb-2" />
<p>
We may store your Personal Information in hard copy or electronic
format, in facilities that we own and operate ourselves, or that are
owned and operated by our service providers. We take the privacy and
security of your Personal Information seriously and we use a number
of procedures and processes to ensure, where possible, the security
and integrity of your Personal Information.
</p>
<p>We protect your Personal Information by:</p>
<ul class="list-disc">
<li>Restricting access to Personal Information;</li>
<li>
Maintaining technology products to prevent unauthorised computer
access;
</li>
<li>
Securely destroying your Personal Information when it is no
longer needed for our record retention purposes.
</li>
</ul>
<p>
To further secure your credit card, we also don't keep details of
your credit card information, including the security code (or CCV
number) that you need to input in order to complete an order using
your credit card.
</p>
<p>
However, no data transmission over the internet can be guaranteed as
completely secure. Once any information is in our possession, we
will take reasonable steps to protect that information from misuse,
loss, unauthorized access, and modification or disclosure. While we
strive to protect such information, we cannot guarantee 100% the
security of any information (personal or other) you transmit to us.
Therefore, we will not be liable for any breach of security or
unintended loss or disclosure of information due to the Site being
linked to the Internet.
</p>
<SMHeader
id="4"
text="4. How we use your information"
class="pt-16 pb-2" />
<p>
We may use your information for a range of different purposes,
including:
</p>
<p>(a) to provide products and services to you;</p>
<p>
(b) to communicate with you, including about products and services,
competition results, special offers, and events which might interest
you;
</p>
<p>
(c) to answer your questions and provide you with information or
advice;
</p>
<p>
(d) to create orders, transaction records, agreements for the sale
of products or services, accounts, tax invoices or receipts;
</p>
<p>
(e) to gain an understanding your information to improve or develop
our products and services;
</p>
<p>(f) to provide you with better customer services;</p>
<p>(g) to perform research and analysis;</p>
<p>
(h) to carry out administration, marketing, planning, fraud and loss
prevention activities, procurement, product and service development,
quality control and research to improve the way STEMMechanics and
our related bodies corporate and service providers provide products
and services to you; and;
</p>
<p>
(i) to comply with laws or regulations or to comply with any
directions given by regulators or authorities.
</p>
<p>
We may also use your information so that we, our related entities,
other business or commercial partners can promote and market
products, services and special offers that will be of interest to
you (which may include products, services and offers provided by a
third party).
</p>
<p>
We collect aggregated information about you which informs us about
our users. The browser information we collect is used in an
aggregated, anonymous manner in our internal analysis of traffic
patterns within our Site. This information is used by us to
administer and improve our education and training products and
services.
</p>
<SMHeader
id="5"
text="5. When we disclose your information"
class="pt-16 pb-2" />
<p>
We engage a range of third parties to provide services and perform
business support functions for us. Some of those third parties need
access to Personal Information in order to provide the services or
perform the functions we require. Therefore, we may disclose your
information to these third parties to:
</p>
<p>
(a) assist us in providing products and services you have requested,
such as delivery service providers and fulfilment managers;
</p>
<p>(b) conduct market research and marketing strategy analysis; and</p>
<p>
(c) manage or develop our business and corporate strategies and
functions.
</p>
<p>
Where we share your Personal Information with third party service
providers, they will be contractually bound to use the information
only for the purposes of providing the services or performing the
functions required by us and to store the information securely, for
example, storing in non-human readable form to ensure the security
of your information.
</p>
<SMHeader id="6" text="6. Cookies" class="pt-16 pb-2" />
<p>
We use "cookies" when you visit our Site. It is a technology that
enables us to operate an efficient service and track the patterns of
behaviour of visitors to the Site. There are four main types of
cookies - here's how and why we use them.
</p>
<p>
(a) Site functionality cookies - these cookies allow you to navigate
the Site and use our features, such as "Add to Bag" and "Add to
Wishlist".
</p>
<p>
(b) Site analytics cookies - these cookies allow us to measure and
analyse how our customers use the Site, to improve both its
functionality and your shopping experience.
</p>
<p>
(c) Customer preference cookies - when you are browsing, these
cookies will remember your preferences (like your language or
location), so we can make your shopping experience as seamless as
possible, and more personal to you.
</p>
<p>
(d) Targeting or advertising cookies - these cookies are used to
deliver marketing and advertising materials that are relevant to
you. They also limit the number of times that you see an ad and help
us measure the effectiveness of our marketing campaigns.
</p>
<p>
By using our Site, you agree to us placing these sorts of cookies on
your device and accessing them when you visit the Site in the
future. You can modify the settings on your device to prevent cookie
use. Please note by disabling cookies, you user experience may be
affected and you might not be able to take advantage of certain
functions of our Site.
</p>
<SMHeader
id="7"
text="7. How to access or correct your Personal Information"
class="pt-16 pb-2" />
<p>
Under the Privacy Act, individuals have a right to complete access
to their records. You may ask us in writing to provide you with
details of the Personal Information we hold about you. We will
endeavour to process your request as soon as practicable.
</p>
<p>
If you wish for your Personal Information to be removed from our
database, please contact us at
<router-link :to="{ name: 'contact' }"
>https://www.stemmechanics.com.au/contact</router-link
>.
</p>
<SMHeader
id="8"
text="8. How to make a complaint about a breach of privacy"
class="pt-16 pb-2" />
<p>
If you wish to exercise any of your rights under this Privacy
Policy, have any questions, comments or complaints regarding our
practices, or if you are of the view that we have not adhered to
this Privacy Policy, you can contact us by email to
<a href="mailto:hello@stemmechanics.com.au"
>hello@stemmechanics.com.au</a
>. You can find more information about privacy and the protection of
your Personal Information on the website of the OAIC at
<a href="https://www.oaic.gov.au">https://www.oaic.gov.au</a>.
</p>
<SMHeader id="9" text="9. Changes to this Policy" class="pt-16 pb-2" />
<p>
Please note that this Privacy Policy forms part of the Terms and
Conditions for use of the Site and forms part of the Agreement
between you and us. We may, from time to time, amend this Privacy
Policy, in whole or part, in our sole discretion. Any changes to
this Privacy Policy will be effective immediately upon the posting
of the revised Privacy policy on the Site. Depending on the nature
of the change, we may announce the change on the Site or by email if
we have your email address. However, in any event, by continuing to
use the Site following any changes, you will be deemed to have
agreed to such changes. If you do not agree with the terms of this
Privacy Policy, as it may be amended from time to time, in whole or
partly, you must terminate your use of the Site.
</p>
</div>
</template>
<script setup lang="ts">
import SMMastHead from "../components/SMMastHead.vue";
import SMHeader from "../components/SMHeader.vue";
</script>

View File

@@ -1,127 +0,0 @@
<template>
<div
class="max-w-2xl mx-auto border-1 bg-white rounded-xl mt-7xl text-gray-5 px-12 py-8">
<SMForm v-if="!userRegistered" v-model="form" @submit="handleSubmit">
<h1 class="mb-4">Register</h1>
<p class="mb-4">
Create an account to access STEMMechanics courses and features.
</p>
<SMFormError v-model="form" />
<SMInput class="mb-4" control="email" autofocus type="email" />
<SMInput class="mb-4" control="password" type="password" />
<SMInput class="mb-4" control="display_name" label="Display Name" />
<div
class="flex flex-justify-between items-center pt-4 flex-col sm:flex-row">
<div class="text-xs mb-4 sm:mb-0">
<span class="pr-1">Already have an account?</span
><router-link to="/login">Log in</router-link>
</div>
<input
type="submit"
class="font-medium px-6 py-1.5 rounded-md hover:shadow-md transition text-sm bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
value="Register" />
</div>
</SMForm>
<div v-else>
<h1 class="mb-4">Email Sent!</h1>
<p class="mb-4">
An email has been sent to you to confirm your details and to
finish registering your account.
</p>
<div class="text-center">
<router-link
role="button"
class="font-medium px-6 py-1.5 rounded-md hover:shadow-md transition text-sm bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
:to="{ name: 'home' }"
>Home</router-link
>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from "vue";
import SMForm from "../components/SMForm.vue";
import SMInput from "../components/SMInput.vue";
import { api } from "../helpers/api";
import { Form, FormControl } from "../helpers/form";
import {
And,
Custom,
Email,
Min,
Password,
Required,
} from "../helpers/validate";
import SMFormError from "../components/SMFormError.vue";
import { useToastStore } from "../store/ToastStore";
let abortController: AbortController | null = null;
const checkUsername = async (value: string): Promise<boolean | string> => {
try {
if (lastUsernameCheck.value != value) {
lastUsernameCheck.value = value;
if (abortController != null) {
abortController.abort();
abortController = null;
}
abortController = new AbortController();
await api.get({
url: "/users",
params: {
username: `=${value}`,
},
signal: abortController.signal,
});
return "The username has already been taken.";
}
return true;
} catch (error) {
return true;
}
};
const userRegistered = ref(false);
const lastUsernameCheck = ref("");
let form = reactive(
Form({
email: FormControl("", And([Required(), Email()])),
password: FormControl("", And([Required(), Password()])),
display_name: FormControl("", And([Min(4)])),
})
);
const handleSubmit = async () => {
form.loading(true);
try {
await api.post({
url: "/register",
body: {
email: form.controls.email.value,
password: form.controls.password.value,
display_name: form.controls.display_name.value,
},
});
userRegistered.value = true;
} catch (error) {
form.apiErrors(error, (message) => {
useToastStore().addToast({
title: "An error occurred",
content: message,
type: "danger",
});
});
} finally {
form.loading(false);
}
};
</script>

View File

@@ -1,91 +0,0 @@
<template>
<div
class="max-w-2xl mx-auto border-1 bg-white rounded-xl mt-7xl text-gray-5 px-12 py-8">
<template v-if="!formDone">
<SMForm v-model="form" @submit="handleSubmit">
<h1 class="mb-4">Resend Email</h1>
<p class="mb-4">
If you have not received your verification email yet, we can
send you another one.
</p>
<SMInput control="email" type="email" />
<div
class="flex flex-justify-between items-center pt-4 flex-col sm:flex-rowpo">
<div class="text-xs mb-4 sm:mb-0">
<span>Stuck?</span
><router-link to="/contact">Contact Us</router-link>
</div>
<input
v-if="!form.loading()"
type="submit"
class="font-medium px-6 py-1.5 rounded-md hover:shadow-md transition text-sm bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
value="Send" />
<SMLoading v-else small />
</div>
</SMForm>
</template>
<template v-else>
<h1 class="mb-4">Email Sent!</h1>
<p class="mb-4">
If that email address has been registered, and you still need to
verify your email, you will receive an email with a new verify
code.
</p>
<div class="flex flex-justify-center items-center pt-4">
<router-link
role="button"
class="font-medium px-6 py-1.5 rounded-md hover:shadow-md transition text-sm bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
:to="{ name: 'home' }"
>Home</router-link
>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from "vue";
import SMForm from "../components/SMForm.vue";
import SMInput from "../components/SMInput.vue";
import { api } from "../helpers/api";
import { Form, FormControl } from "../helpers/form";
import { And, Email, Required } from "../helpers/validate";
import { useToastStore } from "../store/ToastStore";
import SMLoading from "../components/SMLoading.vue";
const formDone = ref(false);
let form = reactive(
Form({
email: FormControl("", And([Required(), Email()])),
}),
);
const handleSubmit = async () => {
try {
form.loading(true);
await api.post({
url: "/users/resendVerifyEmailCode",
body: {
email: form.controls.email.value,
},
});
formDone.value = true;
} catch (error) {
if (error.status == 422) {
formDone.value = true;
} else {
form.apiErrors(error, (message) => {
useToastStore().addToast({
title: "An error occurred",
content: message,
type: "danger",
});
});
}
} finally {
form.loading(false);
}
};
</script>

View File

@@ -1,92 +0,0 @@
<template>
<div
class="max-w-2xl mx-auto border-1 bg-white rounded-xl mt-7xl text-gray-5 px-12 py-8">
<template v-if="!formDone">
<h1 class="mb-4">Reset Password</h1>
<SMForm v-model="form" @submit="handleSubmit">
<SMInput class="mb-4" control="code" />
<SMInput class="mb-4" control="password" type="password" />
<div
class="flex flex-justify-between items-center pt-4 flex-col sm:flex-row">
<div class="text-xs mb-4 sm:mb-0">
<router-link :to="{ name: 'forgot-password' }"
>Resend Code</router-link
>
</div>
<input
v-if="!form.loading()"
type="submit"
class="font-medium px-6 py-1.5 rounded-md hover:shadow-md transition text-sm bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
value="Reset Password" />
<SMLoading v-else small />
</div>
</SMForm>
</template>
<template v-else>
<h1 class="mb-4">Password Reset!</h1>
<p class="mb-4">Hurrah, Your password has been changed!</p>
<div class="flex flex-justify-center items-center pt-4">
<router-link
role="button"
class="font-medium px-6 py-1.5 rounded-md hover:shadow-md transition text-sm bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
:to="{ name: 'login' }"
>Log in</router-link
>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from "vue";
import { useRoute } from "vue-router";
import SMForm from "../components/SMForm.vue";
import SMInput from "../components/SMInput.vue";
import { api } from "../helpers/api";
import { Form, FormControl } from "../helpers/form";
import { And, Max, Min, Password, Required } from "../helpers/validate";
import { useToastStore } from "../store/ToastStore";
import SMLoading from "../components/SMLoading.vue";
const formDone = ref(false);
let form = reactive(
Form({
code: FormControl("", And([Required(), Min(6), Max(6)])),
password: FormControl("", And([Required(), Password()])),
}),
);
if (useRoute().query.code !== undefined) {
let queryCode = useRoute().query.code;
if (Array.isArray(queryCode)) {
queryCode = queryCode[0];
}
form.controls.code.value = queryCode;
}
const handleSubmit = async () => {
try {
form.loading(true);
await api.post({
url: "/users/resetPassword",
body: {
code: form.controls.code.value,
password: form.controls.password.value,
},
});
formDone.value = true;
} catch (error) {
form.apiErrors(error, (message) => {
useToastStore().addToast({
title: "An error occurred",
content: message,
type: "danger",
});
});
} finally {
form.loading(false);
}
};
</script>

View File

@@ -1,55 +0,0 @@
<template>
<SMMastHead title="Rules" />
<div class="max-w-4xl mx-auto px-4">
<p class="pt-16">
We want to make sure everyone has fun and stays safe while using our
platforms and services. That's why we have some rules to follow.
</p>
<p>
These are some of the things we <strong>DO NOT</strong> allow
anywhere:
</p>
<ul class="list-disc">
<li>Don't spam or use bad words too much.</li>
<li>Don't be mean to others or say hateful things.</li>
<li>Don't share inappropriate, adult or violent stuff.</li>
<li>
Don't tell people where you live or give them your real name.
</li>
<li>Don't advertise things, but it's okay to talk about them.</li>
<li>
Don't send anything that could harm someone's computer or
property.
</li>
<li>Don't try to get out of trouble by doing something sneaky.</li>
<li>Don't bully or be mean to others.</li>
</ul>
<SMHeader text="Discord Server" class="pt-16 pb-2" />
<ul class="list-disc">
<li>
Please follow Discord's
<a href="https://discord.com/terms">Terms of Service</a>.
</li>
</ul>
<SMHeader text="Minecraft" class="pt-16 pb-2" />
<ul class="list-disc">
<li>Don't beg for things from others.</li>
<li>Use must use a Microsoft account to play on the server.</li>
<li>
Don't use hacks to cheat like Jesus, Camera, and Dupes (but
mini-maps are okay).
</li>
<li>
Don't destroy other people's buildings, except in the Survival
game mode.
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import SMMastHead from "../components/SMMastHead.vue";
import SMHeader from "../components/SMHeader.vue";
</script>

View File

@@ -1,589 +0,0 @@
<template>
<SMMastHead title="Terms and Conditions" />
<div class="max-w-4xl mx-auto px-4">
<p class="pt-16">
Please read these terms carefully. By accessing or using our website
and online servers, you agree to be bound by these terms and
conditions. Do not use this site or our other online services if you
if you do not agree to all of these terms. If you have any questions
regarding the use of our site, please contact us. These Terms and
Conditions ("Terms") apply to your access to, and use of the website
(stemmechanics.com.au) and online services (collectively, the
"Sites") of STEMMechanics. ("STEMMechanics"). The Terms do not alter
in any way the terms or conditions of any other agreement you may
have with STEMMechanics, or our subsidiaries or affiliates, for
products, services or otherwise. If you are using the Sites on
behalf of any entity, you represent and declare that you are
authorized to accept these Terms on such entity's behalf and that
such entity agrees to indemnify you and STEMMechanics for its
violations of these Terms.
</p>
<SMHeader
id="1"
text="1. Eligibility, registration &amp; account"
class="pt-16 pb-2" />
<p>
You must be 18 years of age to use the Website. If you are under 18
years of age you must have the permission of your parent or guardian
to use the Site.
</p>
<p>
Until you are 18 years old, by using our online services you certify
that your parents or legal guardian has consented to your use and
agreed to these Terms and Conditions on your behalf, and you
acknowledge and agree that your use of our online services is at
their discretion. We may require your parents or legal guardian to
provide a written acknowledgement of these Terms and Conditions on
your behalf before we provide you with part of all of our online
services.
</p>
<p>
You also represent and warrant that you (a) have not previously been
suspended or removed from the Sites; (b) do not have more than one
Site account.
</p>
<p>
In consideration of your use of the Sites, you agree to (a) provide
accurate, current and complete information; (b) maintain and
promptly update your account information; (c) maintain the security
of your account credentials; (d) not share your account credentials
with others; and (e) promptly notify STEMMechanics if you discover
or otherwise suspect any security breaches related to the Sites.
</p>
<SMHeader
id="2"
text="2. Ownership of site content"
class="pt-16 pb-2" />
<p>
Unless otherwise indicated on our Sites, the Sites and all content
and materials therein, including but not limited to the
STEMMechanics logo and all designs, text, graphics, pictures,
information, data, software, sound files, other files and the
selection and arrangement thereof (collectively, "Site Content") are
the proprietary property of STEMMechanics or our affiliates,
licensors, suppliers or users and are protected by international
copyright laws.
</p>
<p>
You are granted a limited, nonexclusive, non-sublicensable license
to access and use the Sites and electronically copy (except where
prohibited without a license) and print hard copy portions of the
Site Content for your informational, non-commercial and personal
use. Such license is subject to these Terms and excludes: (a) any
resale of the Sites or Site Content; (b) the collection and use of
any product listings, pictures or descriptions; (c) the
distribution, public performance or public display of any Site
Content; (d) modifying or otherwise making any derivative uses of
the Sites and the Site Content, or any portion thereof; (e) use of
any data mining, robots or similar data gathering or extraction
methods; (f) downloading (other than page caching) of any portion of
the Sites, the Site Content or any information contained therein,
except as expressly permitted on the Sites or pursuant to separate
terms; or (g) any use of the Sites or the Site Content other than
for its intended purpose. Any other use of the Sites or the Site
Content, without the prior written permission of STEMMechanics, is
strictly prohibited and will terminate the license granted herein.
Unless explicitly stated herein, nothing in these Terms shall be
construed as conferring any license to intellectual property rights,
whether by estoppel, implication or otherwise. This license is
revocable at any time.
</p>
<SMHeader id="3" text="3. Hyperlinks" class="pt-16 pb-2" />
<p>
You are granted a limited, non-exclusive right to create a text
hyperlink to the Sites for non-commercial purposes, provided such
link does not portray STEMMechanics or any of our products and
services in a false, misleading, derogatory or otherwise defamatory
manner and provided further that the linking site does not contain
any adult or illegal material or any material that is offensive,
harassing or otherwise objectionable. This limited right may be
revoked at any time. You may not use a STEMMechanics logo or other
proprietary graphic of STEMMechanics to link to the Sites without
the express written permission of STEMMechanics. Further, you may
not use, frame or utilize framing techniques to enclose any
STEMMechanics logo or other proprietary information, including the
images found at the Sites, the content of any text or the
layout/design of any page or form contained on a page on the Sites
without STEMMechanics' express written consent.
</p>
<p>
STEMMechanics makes no claim or representation regarding the
quality, content, nature or reliability of third-party websites
accessible by hyperlink from the Sites, or websites linking to the
Sites. Such sites are not under the control of STEMMechanics and
STEMMechanics provides these links to you only as a convenience. The
inclusion of any link does not imply affiliation, endorsement or
adoption by STEMMechanics of any site or any information contained
therein. When you leave our Sites, you should be aware that our
terms and policies no longer govern. You should review the
applicable terms and policies, including privacy and data gathering
practices, of any site to which you navigate from the Sites.
</p>
<SMHeader id="4" text="4. User content" class="pt-16 pb-2" />
<p>
The Sites may include discussion blogs, profiles, product reviews or
other interactive features or areas (collectively, "Interactive
Areas"), in which you or other users create, post, transmit or store
any content, such as text, photos, video, graphics or code on the
Sites ("User Content"). User Content is publicly-viewable and
includes your profile information and any content you post pursuant
to your profile, but it does not include your stemmechanics.com.au
account information (also known as "Your STEMMechanics Account" or
"Your Account") or information you submit in order to make a
purchase. You agree that you are solely responsible for your User
Content and for your use of such Interactive Areas, and that you use
the Interactive Areas at your own risk.
</p>
<p>
By using any Interactive Areas, you agree not to post, upload to,
transmit, distribute, store, create or otherwise publish or send
through the Sites any of the following:
</p>
<ul class="list-disc">
<li>
User Content that is unlawful, defamatory, obscene,
pornographic, indecent, lewd, suggestive, harassing,
threatening, abusive, inflammatory, fraudulent or otherwise
objectionable;
</li>
<li>
User Content that would constitute, encourage or provide
instructions for a criminal offense, violate the rights of any
party or that would otherwise create liability or violate any
local, state, national or international law;
</li>
<li>
User Content that displays, describes or encourages usage of any
product we sell in a manner that could be offensive,
inappropriate or harmful to STEMMechanics or any user or
consumer or that is contrary to any instructions or warnings
relating to the product (safety concerns can be reported here);
</li>
<li>
User Content that may impinge upon or violate the publicity,
privacy or data protection rights of others, including pictures,
videos, images or information about another individual where you
have not obtained such individual's consent;
</li>
<li>
User Content that makes false or misleading statements, claims
or depictions about a person, company, product or service;
</li>
<li>
User Content that does not clearly and prominently disclose any
material connections you may have to STEMMechanics or
third-party brands or sellers (for example, if you receive free
products or services or are a paid blogger or employee);
</li>
<li>
User Content that may infringe any patent, trademark, trade
secret, copyright or other intellectual or proprietary right of
any party;
</li>
<li>
User Content that impersonates any person or entity or otherwise
misrepresents your affiliation with a person or entity;
</li>
<li>
Viruses, malware of any kind, corrupted data or other harmful,
disruptive or destructive files or code; and
</li>
<li>
User Content that, in the sole judgment of STEMMechanics,
restricts or inhibits any other person from using or enjoying
the Sites or which may expose STEMMechanics or our users to any
harm or liability of any type.
</li>
</ul>
<p>
Enforcement of the Terms, however, is solely in our discretion and
the absence of enforcement of these Terms in some instances does not
constitute a waiver of our right to enforce the Terms in other
instances. In addition, these Terms do not create any private right
of action on the part of any third party or any reasonable
expectation or promise that the Sites will not contain any content
that is prohibited by such Terms. Although STEMMechanics has no
obligation to screen, edit or monitor any of the User Content posted
on the Sites, STEMMechanics reserves the right, and has absolute
discretion, to remove, screen or edit any User Content posted or
stored on the Sites at any time and for any reason without notice,
and you are solely responsible for creating backup copies and
replacing any User Content you post or store on the Sites at your
sole cost and expense.
</p>
<p>
Any use of the Sites in violation of these Terms may result in,
among other things, termination or suspension of your rights to use
the Sites.
</p>
<SMHeader id="5" text="5. Rights in user content" class="pt-16 pb-2" />
<p>
Except as otherwise provided herein, on the Sites or in a separate
agreement with us (such as the rules of a STEMMechanics photo
sharing contest), STEMMechanics claims no ownership or control over
any User Content. However, by submitting or posting User Content on
the Sites, you grant STEMMechanics and our subsidiaries and
affiliates a nonexclusive, royalty-free, world-wide, perpetual,
irrevocable, transferable, and fully sublicensable right to use,
reproduce, modify, adapt, publish, translate, create derivative
works from, distribute, perform and display such User Content on the
Sites and on third-party sites and in all other media or formats,
whether currently known or hereafter developed, for any purpose and
without any compensation to you. You also grant users of the Sites
the right to access your User Content in connection with their use
of the Sites. If you choose to remove your User Content, the license
granted above will automatically expire; however, you acknowledge
that there may be exceptions (for example, you cannot delete a vote
you submitted that has already been counted or your purchase
history). In addition, we may retain archived copies of your User
Content and cached copies of your User Content may still be
available for some period of time.
</p>
<p>
By posting User Content to the Sites, you represent and warrant that
(a) such User Content is non-confidential; (b) you own and control
all of the rights, title and interest in and to the User Content or
you otherwise have all necessary rights to post and use such User
Content to the Sites and to grant the rights to STEMMechanics that
you grant in these Terms; (c) the User Content is accurate and not
misleading or harmful in any manner; and (d) the User Content, and
your use and posting thereof in connection with the Sites, do not
and will not violate these Terms, our Site Rules, any other
applicable STEMMechanics terms, guidelines or policies or any
applicable law, rule or regulation.
</p>
<SMHeader id="6" text="6. Feedback" class="pt-16 pb-2" />
<p>
Separate and apart from User Content, you have the ability to submit
questions, comments suggestions, reviews, ideas, plans, designs,
notes, proposals, drawings, original or creative materials and other
information regarding the Sites, STEMMechanics and our products or
services (collectively "Feedback"). You agree that Feedback is
non-confidential and shall become the sole property of
STEMMechanics. STEMMechanics shall own exclusive rights, including
all intellectual property rights, in and to such Feedback and shall
be entitled to the unrestricted use and dissemination of the
Feedback for any purpose, commercial or otherwise, without
acknowledgment or compensation to you. Do not send us Feedback if
you expect to be paid or want to continue to own or claim rights in
them; your idea might be great, but we may have already had the same
or a similar idea and we do not want disputes.
</p>
<SMHeader id="7" text="7. User conduct" class="pt-16 pb-2" />
<p>
You agree that you will not violate any law, contract or
intellectual property or other third party right or commit a tort
and that you are solely responsible for your conduct while accessing
or using the Sites. You also agree to abide by our Site Rules and
that you will not:
</p>
<ul class="list-disc">
<li>
Use the Sites in any unlawful manner or in any manner that could
damage, disable, overburden or impair the Sites;
</li>
<li>
Send unsolicited or unauthorized advertising, solicitations,
promotional materials, spam, junk mail, chain letters and
pyramid schemes, or harvest or collect email addresses or other
contact information of other users from the Sites for the
purposes of sending spam;
</li>
<li>
Use any robot, spider, crawler, scraper or other automated means
or interface not provided by us to access the Sites or to
extract data;
</li>
<li>
Reverse engineer any aspect of the Sites or do anything that
might discover source code or bypass or circumvent measures
employed to prevent or limit access to any area, content or code
of the Sites (except as otherwise expressly permitted by law);
</li>
<li>
Solicit personal information from anyone under 18 or solicit
passwords or personally identifying information for commercial
or unlawful purposes;
</li>
<li>
Use or attempt to use another's account without authorization
from STEMMechanics;
</li>
<li>
Attempt to circumvent any content filtering techniques we employ
or access any service or area of the Sites that you are not
authorized to access;
</li>
<li>
Engage in any harassing, intimidating, predatory or stalking
conduct;
</li>
<li>
Develop any third-party applications that interact with User
Content and our Sites;
</li>
<li>
Interfere with or damage the operation of the Sites or introduce
to the Sites or its users any viruses, malware, corrupted data
or other harmful, disruptive or destructive files or code; or
</li>
<li>
"Frame" our Sites or otherwise make it look like you have a
relationship to us or that we have endorsed you for any purpose.
</li>
</ul>
<p>
In addition to the above rules, you also agree to abide by any
additional STEMMechanics rules in realtion to any organised Forum,
Chat, Game and/or Realm Servers. You can view the collection of
additional rules at
<router-link :to="{ name: 'rules' }"
>stemmechanics.com.au/rules</router-link
>
</p>
<p>
STEMMechanics has no obligation to monitor any user conduct on the
Sites, and STEMMechanics reserves the right and has absolute
discretion to monitor any user conduct on the Sites at any time and
for any reason without notice. STEMMechanics does not approve or
endorse any user-posted meetings or events referenced on the Sites
and STEMMechanics recommends exercising caution before contacting or
meeting anyone (online or offline) that is unfamiliar to you.
</p>
<SMHeader
id="8"
text="8. No third-party beneficiaries"
class="pt-16 pb-2" />
<p>
These Terms are for the benefit of, and will be enforceable by, the
parties only. These Terms are not intended to confer any right or
benefit on any third party or to create any obligations or liability
of a party to any such third party.
</p>
<SMHeader id="9" text="9. Indemnification" class="pt-16 pb-2" />
<p>
To the fullest extent permitted by applicable law, you agree to
defend, indemnify and hold harmless STEMMechanics and our
subsidiaries and affiliates, and our respective, directors,
employees, independent contractors, service providers and
consultants, from and against any claims, damages, costs,
liabilities and expenses (collectively, "Claims") arising out of or
related to (a) your access to and use or misuse of the Sites; (b)
any User Content you post, upload, use, distribute, store or
otherwise transmit on or through the Sites; (c) any Feedback you
provide; (d) your violation of these Terms; and (e) your violation
of any rights of another.
</p>
<SMHeader id="10" text=">10. Disclaimers" class="pt-16 pb-2" />
<p>
Except as expressly provided, the Sites, Site Content, User Content
and services provided on or in connection with the Sites
(collectively, "Complete Site") are provided on an "AS IS" and "WITH
ALL FAULTS" basis without representations, warranties or conditions
of any kind, either express or implied. STEMMECHANICS DISCLAIMS ALL
OTHER REPRESENTATIONS, WARRANTIES, CONDITIONS AND DUTIES, EXPRESS,
IMPLIED OR STATUTORY, INCLUDING BUT NOT LIMITED TO IMPLIED
WARRANTIES, DUTIES OR CONDITIONS: (A) OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE OR USE, RESULTS, TITLE, AND
NON-INFRINGEMENT; AND (B) CREATED BY TRADE USAGE, COURSE OF DEALING
OR COURSE OF PERFORMANCE. STEMMechanics does not represent or
warrant that the Complete Site is accurate, complete, reliable,
current or error-free. STEMMechanics does not represent or warrant
that the Sites or our servers are free of viruses or other harmful
components.
</p>
<p>
The Site Content, including, but not limited to, STEMMechanics
videos and "Expert Advice" articles, is general in nature and must
be used with an appreciation for the differing capabilities among
individual users and the differing demands placed on equipment and
techniques by the wide variety of circumstances that can be
encountered in outdoor recreation. The information is not a
substitute for in-person guidance by a qualified instructor.
</p>
<SMHeader id="11" text="11. Liability" class="pt-16 pb-2" />
<p>
To the fullest extent permitted by applicable law, in no event shall
the STEMMechanics parties be liable for any special, indirect,
incidental or consequential damages, including, but not limited to,
loss of use, loss of profits or loss of data, whether in an action
in contract, tort (including, but not limited to, negligence) or
otherwise, arising out of or in any way connected to the access or
use of the complete site, your online or offline interactions with
other site users, or otherwise related to these terms, including but
not limited to any damages that result from events beyond our
reasonable control, such as interruptions to all or portions of the
complete site, deletion of files or email, errors or omissions,
defects, viruses, delays in operation or transmission or failure of
performance, whether or not resulting from acts of god,
communications failure, theft, destruction or unauthorized access to
an STEMMechanics party's records, programs or services.
</p>
<SMHeader id="12" text="12. Modifications to site" class="pt-16 pb-2" />
<p>
STEMMechanics reserves the right to modify or discontinue,
temporarily or permanently, the Sites or any features or portions
thereof without prior notice.
</p>
<SMHeader id="13" text="13. Termination" class="pt-16 pb-2" />
<p>
You may terminate the Terms at any time by closing your account,
discontinuing your use of the Sites and providing STEMMechanics with
a notice of termination. STEMMechanics reserves the right, without
notice and in our sole discretion, to terminate your right to use
the Sites, or any portion of the Sites, and to block or prevent your
future access to and use of the Sites or any portion of the Sites.
</p>
<SMHeader id="14" text="14. Severability" class="pt-16 pb-2" />
<p>
If any provision of these Terms shall be deemed unlawful, void or
for any reason unenforceable, then that provision shall be deemed
severable from these Terms and shall not affect the validity and
enforceability of any remaining provisions.
</p>
<SMHeader id="15" text="15. Ordering online" class="pt-16 pb-2" />
<p>
Upon completing your order and submitting it through the checkout
system, an order reference number will be issued to you via a
confirmation email. We will not process your order until it has
passed our internal validation procedures, for the purpose of
preventing credit card or payment fraud. Upon processing your order
and receiving payment we will send you a confirmation email which is
your Tax Invoice. We reserve the right to refuse service or supply
of the products or to terminate the contract and/or your account at
our sole discretion. If we cannot process your order after receiving
payment, we will contact you using the details entered at the
checkout. By placing an order with stemmechanics.com.au, you declare
that will not be on-selling the product(s) to another person(s) for
financial gain.
</p>
<p>
In the instance that you need to cancel or edit your order of a
physical item; if you fail to notify us before your order has been
dispatched, you can incur a return to sender delivery fee of $25. To
avoid any fees associated when cancelling/editing your order, you'll
need to notify receive confirmation from a STEMMechanics customer
support team member before the order has been dispatched from our
premises.
</p>
<p>
Digital items cannot be cancelled or edited after receiving payment.
</p>
<SMHeader
id="16"
text="16. Pricing &amp; availability"
class="pt-16 pb-2" />
<p>
All prices are shown in Australia dollars (AUD). All items are
subject to availability and we reserve the right to impose quantity
limits on any order, to reject all or part of an order and to
discontinue products or services without notice, even if you have
already placed your order. All prices are subject to change without
notice. Prices displayed on the Sites may vary from those in the
store or from store-advertised prices. All purchases on applicable
products include GST at the rate of 10%.
</p>
<SMHeader id="17" text="17. Errors" />
<p>
We attempt to be as accurate as possible and eliminate errors on the
Sites; however, we do not warrant that any product, service,
description, photograph, pricing or other information is accurate,
complete, reliable, current or error-free. In the event of an error,
whether on the Sites, in an order confirmation, in processing an
order, delivering a product or service or otherwise, we reserve the
right to correct such error and revise your order accordingly if
necessary (including charging the correct price) or to cancel the
order and refund any amount charged. Your sole remedy in the event
of such error is to cancel your order and obtain a refund.
</p>
<SMHeader
id="18"
text="18. Out of stock / pre-order items"
class="pt-16 pb-2" />
<p>
If the colour or size you want is not listed in the "Choose Your
Colour/Size" drop-down box on the Product Information page, it is
not then available for ordering. Please check back later. If the
colour or size you want has an Out of Stock label, it is on
backorder and available for pre-order. Sometimes we will not know in
advance that product is unavailable, so when you place items in your
Cart you will be asked if you would like to pre-order them. If you
indicate yes, the item will be sent to you once it becomes
available. Note that some items may be backordered or unavailable
even if the Sites indicate that they are in-stock, and adding an
item to your Cart does not guarantee the availability of that item.
If you have items on pre-order that you would like to cancel, please
contact us.
</p>
<SMHeader
id="19"
text="19. Agreement to Conduct Transactions Electronically; Recording; Copies"
class="pt-16 pb-2"
s />
<p>
You agree that all of your transactions with or through the Sites
may, at our option, be conducted electronically from start to
finish, and that any oral conversations may be recorded. If we
decide to proceed non-electronically, those transactions will still
be governed by the remainder of these Terms unless you enter into
different terms provided by us. You are responsible to print or make
an electronic a copy of these Terms and any other contract or
disclosure that we are required to provide to you.
</p>
<SMHeader id="20" text="20. Payment" class="pt-16 pb-2" />
<p>
We currently accept Visa and Mastercard online. Only valid credit
cards or other payment method acceptable to us may be used and all
refunds will be credited to the same card or, in our discretion,
other method. By submitting your order, you represent and declare
that you are authorized to use the designated card or method and
authorize us to charge your order (including taxes, delivery costs,
handling and any other amounts described on the Sites) to that card
or other method. If the card (or other method) cannot be verified,
is invalid, or is not otherwise acceptable, your order may be
suspended or cancelled automatically.
</p>
<SMHeader
id="21"
text="21. Third-party sellers / on-sellers (buying &amp; selling)"
class="pt-16 pb-2" />
<p>
You may not place orders with the intention to immediately
on-forward the products to another person in a business transaction
(via marketplaces such as eBay etc), without the express written
permission of STEMMechanics.
</p>
<p>
In the instance that you, a customer who has received a
STEMMechanics order via an on-forwarder, then you forfeit your right
to make; a) a warranty claim or b) refund or exchange with
STEMMechanics. As per Australian Consumer Law or Consumer Guarantee,
the reseller who has conducted the transaction must address the
warranty and refunds directly with the manufacturer at their own
cost and loss.
</p>
<p>
If a reseller who has conducted the transaction is requesting that
STEMMechanics send the product directly to the customer then they
are engaging in Misleading Breach of the Law. The two laws are:
Section 18 of the act which relates to Passing Off, and Trading on
the Goodwill of Another Business - and, section 29 1 G which relates
to Affiliation (misleading the customer that the seller works for
the Retailer, in this case STEMMechanics). This can be made obvious
by the reseller's customer receiving a Tax Invoice for the products
with STEMMechanics letterhead and other documentation supplied by
STEMMechanics Outdoors, misleading the customer to think they are
buying from a reputable store instead of a reseller, often just a
consumer. Exceptions to this would be if the reseller has an ABN or
ACN that relates solely and specifically to reselling products
purchased from a Retailer, to their customer base. They will still
be responsible for warranty and after sales service but this is
allowed under the Law.
</p>
</div>
</template>
<script setup lang="ts">
import SMMastHead from "../components/SMMastHead.vue";
import SMHeader from "../components/SMHeader.vue";
</script>

View File

@@ -1,256 +0,0 @@
<template>
<SMMastHead title="Workshops" />
<div class="max-w-7xl mx-auto px-4">
<div class="flex flex-col sm:flex-row space-between gap-4 py-8">
<SMInput
v-model="filterKeywords"
label="Keywords"
@blur="handleFilter"
@keyup.enter="handleFilter" />
<SMInput
v-model="filterLocation"
label="Location"
@blur="handleFilter"
@keyup.enter="handleFilter" />
<SMInput
v-model="filterDateRange"
type="daterange"
label="Date Range"
:feedback-invalid="dateRangeError"
@blur="handleFilter"
@keyup.enter="handleFilter" />
</div>
<SMPagination
v-if="postsTotal > postsPerPage"
class="mb-4"
v-model="postsPage"
:total="postsTotal"
:per-page="postsPerPage" />
<SMLoading v-if="pageLoading" />
<div
v-else-if="events.length > 0"
class="grid md:grid-cols-2 lg:grid-cols-3 gap-8 w-full">
<SMEventCard
v-for="event in events"
:event="event"
:key="event.id" />
</div>
<div v-else class="py-12 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-24 text-gray-5">
<path
d="M453-280h60v-240h-60v240Zm26.982-314q14.018 0 23.518-9.2T513-626q0-14.45-9.482-24.225-9.483-9.775-23.5-9.775-14.018 0-23.518 9.775T447-626q0 13.6 9.482 22.8 9.483 9.2 23.5 9.2Zm.284 514q-82.734 0-155.5-31.5t-127.266-86q-54.5-54.5-86-127.341Q80-397.681 80-480.5q0-82.819 31.5-155.659Q143-709 197.5-763t127.341-85.5Q397.681-880 480.5-880q82.819 0 155.659 31.5Q709-817 763-763t85.5 127Q880-563 880-480.266q0 82.734-31.5 155.5T763-197.684q-54 54.316-127 86Q563-80 480.266-80Zm.234-60Q622-140 721-239.5t99-241Q820-622 721.188-721 622.375-820 480-820q-141 0-240.5 98.812Q140-622.375 140-480q0 141 99.5 240.5t241 99.5Zm-.5-340Z"
fill="currentColor" />
</svg>
<p class="text-lg text-gray-5">
{{ eventsError || "No workshops where found" }}
</p>
</div>
<SMPagination
v-if="postsTotal > postsPerPage"
class="mt-4"
v-model="postsPage"
:total="postsTotal"
:per-page="postsPerPage" />
</div>
</template>
<script setup lang="ts">
import { reactive, ref, watch } from "vue";
import SMInput from "../components/SMInput.vue";
import SMPagination from "../components/SMPagination.vue";
import { api } from "../helpers/api";
import { Event, EventCollection } from "../helpers/api.types";
import { SMDate } from "../helpers/datetime";
import SMMastHead from "../components/SMMastHead.vue";
import SMLoading from "../components/SMLoading.vue";
import SMEventCard from "../components/SMEventCard.vue";
import { useRoute, useRouter } from "vue-router";
import { getRouterParam, updateRouterParams } from "../helpers/url";
const pageLoading = ref(true);
let events: Event[] = reactive([]);
const dateRangeError = ref("");
const router = useRouter();
const filterKeywords = ref("");
const filterLocation = ref("");
const filterDateRange = ref("");
let oldFilterValues = {
keywords: "",
location: "",
dateRange: "",
};
const postsPerPage = 18;
let postsPage = ref(1);
let postsTotal = ref(0);
const pageStatus = ref(0);
const eventsError = ref("");
/**
* Load page data.
*/
const handleLoad = async () => {
try {
let query = {};
/*
cats, dogs
(title:"cats, dogs",OR,content:"cats, dogs")
"cats, dogs", mice
(title:""cats, dogs", mice",OR,content:"\"cats, dogs\", mice")
*/
query["filter"] = [];
if (filterKeywords.value && filterKeywords.value.length > 0) {
let value = filterKeywords.value.replace(/"/g, '\\"');
query["filter"].push(`(title:"${value}",OR,content:"${value}")`);
}
if (filterLocation.value && filterLocation.value.length > 0) {
let value = filterLocation.value.replace(/"/g, '\\"');
query["filter"].push(`(location:"${value}",OR,address:"${value}")`);
}
if (filterDateRange.value && filterDateRange.value.length > 0) {
let error = false;
const filterDates = filterDateRange.value
.split(/ *- */)
.map((dateString) => {
const date = new SMDate(dateString).format("yyyy/MM/dd");
if (date.length == 0) {
error = true;
}
return date;
});
if (!error) {
if (filterDates.length == 1) {
query["start_at"] = `>=${filterDates[0]}`;
} else if (filterDates.length >= 2) {
query["start_at"] = `<>${filterDates[0]}|${filterDates[1]}`;
}
dateRangeError.value = "";
} else {
dateRangeError.value = "Invalid date range";
return;
}
} else {
dateRangeError.value = "";
}
pageLoading.value = true;
events = [];
if (query["filter"].length > 0) {
query["filter"] = query["filter"].join(",AND,");
} else {
delete query["filter"];
}
if (
(!filterDateRange.value || filterDateRange.value.length === 0) &&
(!query["filter"] || query["filter"].length === 0)
) {
const now = new Date();
const startingDate = new Date(now.setDate(now.getDate() - 8));
query["end_at"] =
">" +
new SMDate(startingDate).format("yyyy/MM/dd HH:mm:ss", {
utc: true,
});
}
query["limit"] = postsPerPage;
query["page"] = postsPage.value;
query["sort"] = "start_at";
updateRouterParams(router, {
keywords: filterKeywords.value,
location: filterLocation.value,
"date-range": filterDateRange.value,
});
let result = await api.get({
url: "/events",
params: query,
});
const data = result.data as EventCollection;
postsTotal.value = data.total;
if (data && data.events) {
events = data.events;
}
} catch (error) {
pageStatus.value = error.status;
} finally {
pageLoading.value = false;
}
};
const handleFilter = async () => {
if (
filterKeywords.value != oldFilterValues.keywords ||
filterLocation.value != oldFilterValues.location ||
filterDateRange.value != oldFilterValues.dateRange
) {
oldFilterValues.keywords = filterKeywords.value;
oldFilterValues.location = filterLocation.value;
oldFilterValues.dateRange = filterDateRange.value;
postsPage.value = 1;
handleLoad();
}
};
watch(
() => postsPage.value,
() => {
handleLoad();
},
);
filterKeywords.value = getRouterParam(useRoute(), "keywords");
filterLocation.value = getRouterParam(useRoute(), "location");
filterDateRange.value = getRouterParam(useRoute(), "date-range");
handleLoad();
</script>
<style lang="scss">
.page-workshops {
.events {
display: grid;
grid-template-columns: 1fr;
gap: 30px;
width: 100%;
}
}
@media (min-width: 768px) {
.page-workshops {
.events {
grid-template-columns: 1fr 1fr;
}
}
}
@media (min-width: 1024px) {
.page-workshops {
.events {
grid-template-columns: 1fr 1fr 1fr;
}
}
}
</style>

View File

@@ -1,71 +0,0 @@
<template>
<SMPage :page-error="pageError" permission="admin/analytics">
<SMMastHead
:title="pageHeading"
:back-link="{ name: 'dashboard-analytics-list' }"
back-title="Back to Analytics" />
<SMContainer class="flex-grow-1">
<div>{{ sessionData.ip }}</div>
<div>{{ sessionData.useragent }}</div>
<div>{{ sessionData.created_at }}</div>
<div>{{ sessionData.ended_at }}</div>
<div v-for="request of sessionData.requests" :key="request.id">
<div>{{ request.type }}</div>
<div>{{ request.path }}</div>
<div>{{ request.created_at }}</div>
</div>
</SMContainer>
</SMPage>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { useRoute } from "vue-router";
import { api } from "../../helpers/api";
import {
EmptyObject,
Session,
SessionRequestCollection,
} from "../../helpers/api.types";
import SMMastHead from "../../components/SMMastHead.vue";
type SessionOrEmpty = Session | EmptyObject;
const route = useRoute();
let pageError = ref(200);
const pageHeading = `Session ${route.params.id}`;
const sessionData = ref<SessionOrEmpty>({});
/**
* Load the page data.
*/
const loadData = async () => {
try {
if (route.params.id) {
// form.loading(true);
let result = await api.get({
url: "/analytics/{id}",
params: {
id: route.params.id,
},
});
const data = result.data as SessionRequestCollection;
if (data && data.session) {
sessionData.value = data.session;
} else {
pageError.value = 404;
}
}
} catch (error) {
pageError.value = error.status;
} finally {
// form.loading(false);
}
};
loadData();
</script>
<style lang="scss"></style>

View File

@@ -1,199 +0,0 @@
<template>
<SMPageStatus v-if="!userHasPermission('admin/analytics')" :status="403" />
<template v-else>
<SMMastHead
title="Analytics"
:back-link="{ name: 'dashboard' }"
back-title="Return to Dashboard" />
<div class="max-w-7xl mx-auto mt-8 px-4">
<div
class="flex flex-col md:flex-row gap-4 items-center flex-justify-between mb-4">
<SMInput
v-model="itemSearch"
label="Search"
class="max-w-xl"
@keyup.enter="handleSearch">
<template #append>
<button
type="button"
class="font-medium px-4 py-3.1 rounded-r-2 hover:shadow-md transition bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
@click="handleSearch">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-6">
<path
d="M796-121 533-384q-30 26-69.959 40.5T378-329q-108.162 0-183.081-75Q120-479 120-585t75-181q75-75 181.5-75t181 75Q632-691 632-584.85 632-542 618-502q-14 40-42 75l264 262-44 44ZM377-389q81.25 0 138.125-57.5T572-585q0-81-56.875-138.5T377-781q-82.083 0-139.542 57.5Q180-666 180-585t57.458 138.5Q294.917-389 377-389Z"
fill="currentColor" />
</svg>
</button>
</template>
</SMInput>
</div>
<SMLoading large v-if="itemsLoading" />
<div
v-else-if="!itemsLoading && items.length == 0"
class="py-12 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-24 text-gray-5">
<path
d="M453-280h60v-240h-60v240Zm26.982-314q14.018 0 23.518-9.2T513-626q0-14.45-9.482-24.225-9.483-9.775-23.5-9.775-14.018 0-23.518 9.775T447-626q0 13.6 9.482 22.8 9.483 9.2 23.5 9.2Zm.284 514q-82.734 0-155.5-31.5t-127.266-86q-54.5-54.5-86-127.341Q80-397.681 80-480.5q0-82.819 31.5-155.659Q143-709 197.5-763t127.341-85.5Q397.681-880 480.5-880q82.819 0 155.659 31.5Q709-817 763-763t85.5 127Q880-563 880-480.266q0 82.734-31.5 155.5T763-197.684q-54 54.316-127 86Q563-80 480.266-80Zm.234-60Q622-140 721-239.5t99-241Q820-622 721.188-721 622.375-820 480-820q-141 0-240.5 98.812Q140-622.375 140-480q0 141 99.5 240.5t241 99.5Zm-.5-340Z"
fill="currentColor" />
</svg>
<p class="text-lg text-gray-5">
{{ "No sessions where found" }}
</p>
</div>
<template v-else>
<SMPagination
v-if="items.length < itemsTotal"
class="mb-4"
v-model="itemsPage"
:total="itemsTotal"
:per-page="itemsPerPage" />
<SMTable
:headers="headers"
:items="items"
@row-click="handleView">
</SMTable>
</template>
</div>
</template>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { api } from "../../helpers/api";
import { SessionCollection, Session } from "../../helpers/api.types";
import { SMDate } from "../../helpers/datetime";
import { updateRouterParams } from "../../helpers/url";
import { useToastStore } from "../../store/ToastStore";
import SMInput from "../../components/SMInput.vue";
import SMLoading from "../../components/SMLoading.vue";
import SMMastHead from "../../components/SMMastHead.vue";
import SMPagination from "../../components/SMPagination.vue";
import SMTable from "../../components/SMTable.vue";
import { userHasPermission } from "../../helpers/utils";
import SMPageStatus from "../../components/SMPageStatus.vue";
const route = useRoute();
const router = useRouter();
const toastStore = useToastStore();
const items = ref([]);
const itemsLoading = ref(true);
const itemSearch = ref((route.query.search as string) || "");
const itemsTotal = ref(0);
const itemsPerPage = 25;
const itemsPage = ref(parseInt((route.query.page as string) || "1"));
const headers = [
{ text: "Session", value: "id", sortable: true },
{ text: "IP", value: "ip", sortable: true },
{ text: "Started", value: "created_at", sortable: true },
{ text: "Ended", value: "ended_at", sortable: true },
];
/**
* Watch if page number changes.
*/
watch(itemsPage, () => {
handleLoad();
});
/**
* Handle searching for item.
*/
const handleSearch = () => {
itemsPage.value = 1;
handleLoad();
};
/**
* Handle loading the page and list
*/
const handleLoad = async () => {
itemsLoading.value = true;
items.value = [];
itemsTotal.value = 0;
updateRouterParams(router, {
search: itemSearch.value,
page: itemsPage.value == 1 ? "" : itemsPage.value.toString(),
});
try {
let params = {
page: itemsPage.value,
limit: itemsPerPage,
sort: "-id",
};
if (itemSearch.value.length > 0) {
params[
"filter"
] = `id:${itemSearch.value},OR,ip:${itemSearch.value},OR,path:${itemSearch.value}`;
}
let result = await api.get({
url: "/analytics",
params: params,
});
const data = result.data as SessionCollection;
data.sessions.forEach(async (row) => {
if (row.created_at !== "undefined") {
row.created_at = new SMDate(row.created_at, {
format: "ymd",
utc: true,
}).format("dd MMM yyyy h:mm AA");
}
if (row.ended_at !== "undefined") {
row.ended_at = new SMDate(row.ended_at, {
format: "ymd",
utc: true,
}).format("dd MMM yyyy h:mm AA");
}
items.value.push(row);
});
itemsTotal.value = data.total;
} catch (error) {
if (error.status != 404) {
toastStore.addToast({
title: "Server Error",
content:
"An error occurred retrieving the list from the server.",
type: "danger",
});
}
} finally {
itemsLoading.value = false;
}
};
/**
* User requests to edit the item
* @param {Session} item The event item.
*/
const handleView = (item: Session) => {
router.push({
name: "dashboard-analytics-item",
params: { id: item.id },
query: {
return: encodeURIComponent(
window.location.pathname + window.location.search,
),
},
});
};
if (userHasPermission("admin/analytics")) {
handleLoad();
}
</script>