cleanup
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
32
resources/js/bootstrap.js
vendored
32
resources/js/bootstrap.js
vendored
@@ -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'],
|
||||
// });
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 & 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 ❤️ © 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 ], ... ]
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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(/ /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;
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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];
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
@@ -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
@@ -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");
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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 });
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<SMPageStatus :status="404" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SMPageStatus from "../components/SMPageStatus.vue";
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,3 +0,0 @@
|
||||
<template>
|
||||
<div>CART</div>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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("/assets/vareal.webp");
|
||||
"></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 entertainment—it 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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 & 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 & 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 & 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user