bug fixes and updates

This commit is contained in:
2023-04-17 07:16:31 +10:00
parent d1c09ce74e
commit 7d9c982cf5
53 changed files with 3040 additions and 1701 deletions

View File

@@ -36,11 +36,16 @@ h4,
h5,
h6 {
font-family: var(--header-font-family);
font-weight: 800;
margin-bottom: 0;
}
h1 {
font-size: 42px;
}
h2 {
font-size: 36px;
margin-bottom: 0;
}
a,
@@ -70,37 +75,6 @@ small,
padding: 0 16px;
}
.card {
background-color: var(--card-color);
border-radius: 10px;
box-shadow: 0 11px 10px -10px rgba(0, 0, 0, 0.2);
margin: 48px auto 48px auto;
.card-header {
text-align: center;
padding: 24px 48px 0;
p {
opacity: 0.6;
}
}
.card-body {
padding: 0 48px;
}
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 48px 24px 48px;
}
.btn {
background-color: var(--base-color-dark);
}
}
.btn {
cursor: pointer;
position: relative;

View File

@@ -5,6 +5,7 @@
--base-color-text: rgba(0, 0, 0, 0.8);
--base-color-light: #fff;
--base-color-dark: #ddd;
--base-color-darker: #999;
--base-shadow: 0 0 4px rgba(0, 0, 0, 0.2);
--default-font-size: 18px;
@@ -19,10 +20,16 @@
--primary-color-dark: #0e80ce;
--primary-color-darker: #095589;
--danger-color-lighter: #f3c8c8;
--danger-color-light: #db5c5a;
--danger-color: #c82e2b;
--danger-color-dark: #952220;
--danger-color-darker: #641715;
--header-font-family: "Montserrat", "Montserrat override", "Arial",
"Helvetica", sans-serif;
--navbar-color: rgba(255, 255, 255, 0.1);
--navbar-color: var(--base-color-light);
--navbar-color-dropdown: var(--base-color-light);
--card-color: var(--base-color-light);
@@ -38,7 +45,7 @@
--base-color-text: rgba(255, 255, 255, 0.9);
--base-color-light: #333;
--base-color-dark: #444;
--base-color-darker: #555;
--base-color-darker: #999;
--footer-color-text: #999;
--footer-color-border: rgba(255, 255, 255, 0.1);
@@ -48,6 +55,12 @@
--primary-color: #35a5f1;
--primary-color-dark: #67bbf4;
--primary-color-darker: #cce8fb;
--danger-color-lighter: #f3c8c8;
--danger-color-light: #db5c5a;
--danger-color: #db5c5a;
--danger-color-dark: #952220;
--danger-color-darker: #641715;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,59 @@
<template>
<router-link :to="props.to" :class="['sm-article-card', `${props.type}`]">
<div
class="thumbnail"
:style="[{ backgroundImage: `url('${props.image}')` }]"></div>
<div class="content">
<h3>{{ props.title }}</h3>
<p class="excerpt">{{ props.excerpt }}</p>
</div>
</router-link>
</template>
<script setup lang="ts">
const props = defineProps({
to: {
type: Object,
required: true,
},
image: {
type: String,
required: true,
},
title: {
type: String,
required: true,
},
type: {
type: String,
default: "",
required: false,
},
excerpt: {
type: String,
required: true,
},
});
</script>
<style lang="scss">
.sm-article-card {
display: flex;
height: 100%;
flex-direction: column;
gap: 20px;
.thumbnail {
aspect-ratio: 16 / 9;
border-radius: 7px;
background-position: center;
background-size: cover;
background-color: var(--card-background-color);
box-shadow: var(--box-shadow);
}
&.row {
flex-direction: row;
}
}
</style>

View File

@@ -0,0 +1,369 @@
<template>
<button
v-if="isEmpty(to)"
:disabled="disabled"
:class="[
'sm-button',
classType,
{ 'sm-button-small': small },
{ 'sm-button-block': block },
{ 'sm-dropdown-button': dropdown },
]"
:type="buttonType"
@click="handleClick">
<ion-icon
v-if="icon && dropdown == null && iconLocation == 'before'"
:icon="icon"
class="sm-button-icon-before" />
<span class="sm-button-label">{{ label }}</span>
<ion-icon
v-if="icon && dropdown == null && iconLocation == 'after'"
:icon="icon"
class="sm-button-icon-after" />
<ion-icon
v-if="dropdown != null"
name="caret-down-outline"
class="sm-button-icon-dropdown"
@click.stop="handleClickToggleDropdown" />
<ul
v-if="dropdown != null"
ref="dropdownMenu"
@mouseleave="handleMouseLeave">
<li
v-for="(dropdownLabel, dropdownItem) in dropdown"
:key="dropdownItem"
@click.stop="handleClickItem(dropdownItem)">
{{ dropdownLabel }}
</li>
</ul>
</button>
<a
v-else-if="!isEmpty(to) && typeof to == 'string'"
:href="to"
:disabled="disabled"
:class="[
'sm-button',
classType,
{ 'sm-button-small': small },
{ 'sm-button-block': block },
]"
:type="buttonType">
<span class="sm-button-label">{{ label }}</span>
<ion-icon v-if="icon" :icon="icon" />
</a>
<router-link
v-else-if="!isEmpty(to) && typeof to == 'object'"
:to="to"
:disabled="disabled"
:class="[
'sm-button',
classType,
{ 'sm-button-small': small },
{ 'sm-button-block': block },
]">
<ion-icon v-if="icon && iconLocation == 'before'" :icon="icon" />
{{ label }}
<ion-icon v-if="icon && iconLocation == 'after'" :icon="icon" />
</router-link>
</template>
<script setup lang="ts">
import { Ref, ref } from "vue";
import { isEmpty } from "../helpers/utils";
const props = defineProps({
label: { type: String, default: "Button", required: false },
type: { type: String, default: "primary", required: false },
icon: {
type: String,
default: "",
required: false,
},
iconLocation: {
type: String,
default: "after",
required: false,
validator: (value: string) => {
return ["before", "after"].includes(value);
},
},
to: {
type: [String, Object],
default: null,
required: false,
validator: (prop) => typeof prop === "object" || prop === null,
},
disabled: {
type: Boolean,
default: false,
required: false,
},
block: {
type: Boolean,
default: false,
required: false,
},
small: {
type: Boolean,
default: false,
required: false,
},
dropdown: {
type: Object,
default: null,
required: false,
validator: (prop) => typeof prop === "object" || prop === null,
},
});
const buttonType: "submit" | "button" =
props.type == "submit" ? "submit" : "button";
const classType = props.type == "submit" ? "primary" : props.type;
const dropdownMenu: Ref<HTMLElement | null> = ref(null);
const emits = defineEmits(["click"]);
const handleClick = () => {
emits("click", "");
};
const handleClickToggleDropdown = () => {
if (dropdownMenu.value) {
dropdownMenu.value.style.display = "block";
}
};
const handleMouseLeave = () => {
if (dropdownMenu.value) {
dropdownMenu.value.style.display = "none";
}
};
const handleClickItem = (item: string) => {
emits("click", item);
};
</script>
<style lang="scss">
a.sm-button,
a:visited.sm-button,
.sm-button {
cursor: pointer;
position: relative;
font-family: var(--header-font-family);
font-weight: 800;
margin: 16px 32px 16px 32px;
padding: 16px 32px 16px 32px;
border: 0;
background-color: var(--base-color-light);
-webkit-backdrop-filter: blur(4px);
backdrop-filter: blur(4px);
color: var(--link-color);
// position: relative;
// padding: map-get($spacer, 2) map-get($spacer, 4);
// color: white;
// font-weight: 800;
// border-width: 2px;
// border-style: solid;
// min-width: 7rem;
// text-align: center;
// display: inline-flex;
// align-items: center;
// justify-content: center;
// background-color: rgba(255, 255, 255, 0.2);
// border-color: transparent;
// box-shadow: 0 0 8px 8px rgba(0, 0, 0, 0.1);
// transition: all 0.1s ease-in-out;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
&.sm-button-block {
display: flex;
width: 100%;
}
&.sm-button-small {
font-size: 85%;
font-weight: normal;
padding: map-get($spacer, 1) map-get($spacer, 3);
}
&.sm-dropdown-button {
padding: 0;
white-space: nowrap;
display: flex;
align-items: center;
font-weight: normal;
background: #fff !important;
color: $primary-color !important;
border-radius: 12px;
border-width: 1px;
font-size: 0.8rem;
min-width: auto;
span {
flex: 1;
border-right: 1px solid $primary-color-lighter;
padding-top: calc(#{map-get($spacer, 1)} / 1.5);
padding-bottom: calc(#{map-get($spacer, 1)} / 1.5);
padding-left: map-get($spacer, 3);
padding-right: map-get($spacer, 3);
}
.sm-button-icon-dropdown {
height: 1rem;
width: 1rem;
padding: 0 0.3rem 0 0.2rem;
}
&:hover {
background-color: $primary-color !important;
color: #fff !important;
span {
border-right: 1px solid $primary-color-light;
}
}
}
&:disabled {
cursor: not-allowed;
background-color: $secondary-color !important;
border-color: $secondary-color !important;
opacity: 0.5;
}
&:hover:not(:disabled) {
text-decoration: none;
background-color: rgba(255, 255, 255, 0.25);
box-shadow: 0 0 8px 8px rgba(0, 0, 0, 0.2);
}
&.primary {
// background-color: $primary-color;
// border-color: $primary-color;
&:hover:not(:disabled) {
// color: $primary-color;
}
}
&.primary-outline {
background-color: transparent;
border-color: $primary-color;
color: $primary-color;
&:hover:not(:disabled) {
color: $primary-color;
}
}
&.secondary {
background-color: $secondary-color;
border-color: $secondary-color;
&:hover:not(:disabled) {
color: $secondary-color;
}
}
&.secondary-outline {
background-color: transparent;
border-color: $secondary-color;
color: $secondary-color;
&:hover:not(:disabled) {
color: $secondary-color;
}
}
&.danger {
background-color: $danger-color;
border-color: $danger-color;
&:hover:not(:disabled) {
color: $danger-color;
}
}
&.danger-outline {
background-color: transparent;
border-color: $danger-color;
color: $danger-color;
&:hover:not(:disabled) {
color: $danger-color;
}
}
&.outline {
background-color: transparent;
border-color: $outline-color;
color: $outline-color;
&:hover:not(:disabled) {
background-color: $outline-color;
border-color: $outline-color;
color: $outline-hover-color;
}
}
ion-icon {
height: 1.2rem;
width: 1.2rem;
vertical-align: middle;
cursor: pointer;
}
ul {
position: absolute;
display: none;
z-index: 100;
top: 20%;
right: 0;
min-width: 100%;
list-style: none;
padding: 0;
margin: 0;
background-color: #f8f8f8;
border: 1px solid $border-color;
border-radius: 8px;
color: $primary-color;
box-shadow: 0 0 14px rgba(0, 0, 0, 0.5);
}
li {
padding: map-get($spacer, 1);
font-size: 100%;
cursor: pointer;
transition: background-color 0.1s ease-in-out;
&:first-child {
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
&:last-child {
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
}
li:hover {
background-color: $primary-color;
color: #f8f8f8;
}
.sm-button-icon-before {
margin-right: map-get($spacer, 1);
}
.sm-button-icon-after {
margin-left: map-get($spacer, 1);
}
}
</style>

View File

@@ -3,27 +3,20 @@
v-if="isEmpty(to)"
:disabled="disabled"
:class="[
'sm-button',
'button',
classType,
{ 'sm-button-small': small },
{ 'sm-button-block': block },
{ 'sm-dropdown-button': dropdown },
{ 'button-small': small },
{ 'button-block': block },
{ 'dropdown-button': dropdown },
]"
ref="buttonRef"
:style="{ minWidth: minWidth }"
:type="buttonType"
@click="handleClick">
<ion-icon
v-if="icon && dropdown == null && iconLocation == 'before'"
:icon="icon"
class="sm-button-icon-before" />
<span class="sm-button-label">{{ label }}</span>
<ion-icon
v-if="icon && dropdown == null && iconLocation == 'after'"
:icon="icon"
class="sm-button-icon-after" />
<ion-icon
v-if="dropdown != null"
name="caret-down-outline"
class="sm-button-icon-dropdown"
class="button-icon-dropdown"
@click.stop="handleClickToggleDropdown" />
<ul
v-if="dropdown != null"
@@ -36,30 +29,31 @@
{{ dropdownLabel }}
</li>
</ul>
<span v-if="!loading">{{ props.label }}</span>
<SMLoadingIcon v-else class="button-icon-loading" />
</button>
<a
v-else-if="!isEmpty(to) && typeof to == 'string'"
:href="to"
:disabled="disabled"
:class="[
'sm-button',
'button',
classType,
{ 'sm-button-small': small },
{ 'sm-button-block': block },
{ 'button-small': small },
{ 'button-block': block },
]"
:type="buttonType">
<span class="sm-button-label">{{ label }}</span>
<ion-icon v-if="icon" :icon="icon" />
<span class="button-label">{{ label }}</span>
</a>
<router-link
v-else-if="!isEmpty(to) && typeof to == 'object'"
:to="to"
:disabled="disabled"
:class="[
'sm-button',
'button',
classType,
{ 'sm-button-small': small },
{ 'sm-button-block': block },
{ 'button-small': small },
{ 'button-block': block },
]">
<ion-icon v-if="icon && iconLocation == 'before'" :icon="icon" />
{{ label }}
@@ -68,12 +62,13 @@
</template>
<script setup lang="ts">
import { Ref, ref } from "vue";
import { Ref, onMounted, ref, watch } from "vue";
import { isEmpty } from "../helpers/utils";
import SMLoadingIcon from "./SMLoadingIcon.vue";
const props = defineProps({
label: { type: String, default: "Button", required: false },
type: { type: String, default: "primary", required: false },
type: { type: String, default: "secondary", required: false },
icon: {
type: String,
default: "",
@@ -114,14 +109,40 @@ const props = defineProps({
required: false,
validator: (prop) => typeof prop === "object" || prop === null,
},
form: {
type: Object,
default: undefined,
required: false,
},
});
const buttonType: "submit" | "button" =
props.type == "submit" ? "submit" : "button";
const classType = props.type == "submit" ? "primary" : props.type;
const disabled = ref(props.disabled);
const dropdownMenu: Ref<HTMLElement | null> = ref(null);
const loading = ref(false);
const minWidth = ref("");
const buttonRef = ref(null);
const emits = defineEmits(["click"]);
if (props.form !== undefined) {
watch(
() => props.form.loading(),
(newValue) => {
loading.value = newValue;
disabled.value = newValue;
}
);
}
onMounted(() => {
if (buttonRef.value) {
minWidth.value = `${buttonRef.value.clientWidth}px`;
}
});
const handleClick = () => {
emits("click", "");
};
@@ -144,226 +165,35 @@ const handleClickItem = (item: string) => {
</script>
<style lang="scss">
a.sm-button,
a:visited.sm-button,
.sm-button {
cursor: pointer;
.button {
position: relative;
display: inline-block;
font-family: var(--header-font-family);
font-weight: 800;
margin: 16px 32px 16px 32px;
padding: 16px 32px 16px 32px;
border: 0;
background-color: var(--base-color-light);
-webkit-backdrop-filter: blur(4px);
backdrop-filter: blur(4px);
color: var(--link-color);
// position: relative;
// padding: map-get($spacer, 2) map-get($spacer, 4);
// color: white;
// font-weight: 800;
// border-width: 2px;
// border-style: solid;
// min-width: 7rem;
// text-align: center;
// display: inline-flex;
// align-items: center;
// justify-content: center;
// background-color: rgba(255, 255, 255, 0.2);
// border-color: transparent;
// box-shadow: 0 0 8px 8px rgba(0, 0, 0, 0.1);
// transition: all 0.1s ease-in-out;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
&.sm-button-block {
display: flex;
width: 100%;
}
&.sm-button-small {
font-size: 85%;
font-weight: normal;
padding: map-get($spacer, 1) map-get($spacer, 3);
}
&.sm-dropdown-button {
padding: 0;
white-space: nowrap;
display: flex;
align-items: center;
font-weight: normal;
background: #fff !important;
color: $primary-color !important;
border-radius: 12px;
border-width: 1px;
font-size: 0.8rem;
min-width: auto;
span {
flex: 1;
border-right: 1px solid $primary-color-lighter;
padding-top: calc(#{map-get($spacer, 1)} / 1.5);
padding-bottom: calc(#{map-get($spacer, 1)} / 1.5);
padding-left: map-get($spacer, 3);
padding-right: map-get($spacer, 3);
}
.sm-button-icon-dropdown {
height: 1rem;
width: 1rem;
padding: 0 0.3rem 0 0.2rem;
}
&:hover {
background-color: $primary-color !important;
color: #fff !important;
span {
border-right: 1px solid $primary-color-light;
}
}
}
&:disabled {
cursor: not-allowed;
background-color: $secondary-color !important;
border-color: $secondary-color !important;
opacity: 0.5;
}
text-decoration: none;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.2);
&:hover:not(:disabled) {
text-decoration: none;
background-color: rgba(255, 255, 255, 0.25);
box-shadow: 0 0 8px 8px rgba(0, 0, 0, 0.2);
box-shadow: 0 0 4px rgba(0, 0, 0, 0.5);
filter: brightness(115%);
cursor: pointer;
}
&:hover:disabled {
cursor: not-allowed;
}
&.light {
background-color: #eee;
color: #095589;
}
&.primary {
// background-color: $primary-color;
// border-color: $primary-color;
&:hover:not(:disabled) {
// color: $primary-color;
}
}
&.primary-outline {
background-color: transparent;
border-color: $primary-color;
color: $primary-color;
&:hover:not(:disabled) {
color: $primary-color;
}
}
&.secondary {
background-color: $secondary-color;
border-color: $secondary-color;
&:hover:not(:disabled) {
color: $secondary-color;
}
}
&.secondary-outline {
background-color: transparent;
border-color: $secondary-color;
color: $secondary-color;
&:hover:not(:disabled) {
color: $secondary-color;
}
}
&.danger {
background-color: $danger-color;
border-color: $danger-color;
&:hover:not(:disabled) {
color: $danger-color;
}
}
&.danger-outline {
background-color: transparent;
border-color: $danger-color;
color: $danger-color;
&:hover:not(:disabled) {
color: $danger-color;
}
}
&.outline {
background-color: transparent;
border-color: $outline-color;
color: $outline-color;
&:hover:not(:disabled) {
background-color: $outline-color;
border-color: $outline-color;
color: $outline-hover-color;
}
}
ion-icon {
height: 1.2rem;
width: 1.2rem;
vertical-align: middle;
cursor: pointer;
}
ul {
position: absolute;
display: none;
z-index: 100;
top: 20%;
right: 0;
min-width: 100%;
list-style: none;
padding: 0;
margin: 0;
background-color: #f8f8f8;
border: 1px solid $border-color;
border-radius: 8px;
color: $primary-color;
box-shadow: 0 0 14px rgba(0, 0, 0, 0.5);
}
li {
padding: map-get($spacer, 1);
font-size: 100%;
cursor: pointer;
transition: background-color 0.1s ease-in-out;
&:first-child {
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
&:last-child {
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
}
li:hover {
background-color: $primary-color;
color: #f8f8f8;
}
.sm-button-icon-before {
margin-right: map-get($spacer, 1);
}
.sm-button-icon-after {
margin-left: map-get($spacer, 1);
background-color: var(--primary-color);
color: var(--base-color);
}
}
</style>

View File

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

View File

@@ -10,13 +10,14 @@
<script setup lang="ts">
import { useSlots } from "vue";
defineProps({
const props = defineProps({
full: {
type: Boolean,
default: false,
required: false,
},
});
const slots = useSlots();
</script>
@@ -24,7 +25,7 @@ const slots = useSlots();
.sm-container {
display: flex;
width: 100%;
align-items: center;
// align-items: center;
flex-direction: column;
padding: 0 16px 0 16px;
margin: auto;

View File

@@ -1,5 +1,5 @@
<template>
<form @submit.prevent="handleSubmit">
<form class="SMForm" @submit.prevent="handleSubmit">
<SMLoader :loading="props.modelValue._loading"></SMLoader>
<SMMessage
v-if="props.modelValue._message.length > 0"
@@ -37,3 +37,9 @@ const handleSubmit = async function () {
provide("form", props.modelValue);
</script>
<style lang="scss">
.SMForm {
width: 100%;
}
</style>

View File

@@ -1,35 +1,22 @@
<template>
<div
:class="[
'sm-form-card',
{ 'sm-form-card-narrow': narrow },
{ 'sm-form-card-full': full },
{ 'sm-form-card-noshadow': noShadow },
]">
<transition name="fade">
<div v-if="loading" class="sm-form-card-loading-cover">
<div class="sm-form-card-loading">
<SMLoadingIcon />
<span>{{ loadingMessage }}</span>
</div>
</div>
</transition>
<slot></slot>
<div :class="['form-card', { 'form-card-full': full }]">
<div v-if="slots.header" class="form-card-header">
<slot name="header"></slot>
</div>
<div v-if="slots.body || slots.default``" class="form-card-body">
<slot name="body"></slot>
<slot></slot>
</div>
<div v-if="slots.footer" class="form-card-footer">
<slot name="footer"></slot>
</div>
</div>
</template>
<script setup lang="ts">
import SMLoadingIcon from "./SMLoadingIcon.vue";
import { useSlots } from "vue";
defineProps({
loading: {
type: Boolean,
default: false,
},
loadingMessage: {
type: String,
default: "",
},
narrow: {
type: Boolean,
default: false,
@@ -38,108 +25,33 @@ defineProps({
type: Boolean,
default: false,
},
noShadow: {
type: Boolean,
default: false,
},
});
const slots = useSlots();
</script>
<style lang="scss">
.sm-form-card {
flex-direction: column;
margin: 0 auto;
background-color: #eee;
padding: map-get($spacer, 5) map-get($spacer, 5)
calc(map-get($spacer, 5) / 1.5) map-get($spacer, 5);
border: 1px solid #eee;
border-radius: 24px;
overflow: hidden;
min-width: map-get($spacer, 5) * 12;
box-shadow: 4px 4px 20px rgba(0, 0, 0, 0.5);
.form-card {
max-width: 640px;
margin: 64px auto;
padding: 32px 48px;
background-color: var(--base-color-light);
border-radius: 16px;
box-shadow: var(--base-shadow);
&.sm-form-card-noshadow {
box-shadow: none !important;
}
& > h1 {
margin-top: 0;
}
& > p {
font-size: 90%;
}
&.sm-form-card-narrow {
min-width: auto;
max-width: map-get($spacer, 5) * 10;
}
&.sm-form-card-full {
width: 100%;
}
.sm-form-card-loading-cover {
position: fixed;
.form-card-footer {
display: flex;
justify-content: center;
align-items: center;
top: 0;
left: 0;
bottom: 0;
right: 0;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(4px);
background-color: rgba(255, 255, 255, 0.5);
z-index: 19000;
.sm-form-card-loading {
display: flex;
flex-direction: column;
padding: map-get($spacer, 5) calc(map-get($spacer, 5) * 2);
align-items: center;
border: 1px solid transparent;
border-radius: 24px;
ion-icon {
font-size: calc(map-get($spacer, 5) * 1.5);
}
span {
font-size: map-get($spacer, 4);
padding-top: map-get($spacer, 3);
}
}
}
}
@media only screen and (max-width: 640px) {
.sm-container .sm-form-card {
margin: 0 -1rem;
&.sm-form-card-full {
width: auto;
}
justify-content: space-between;
}
.sm-form-card {
padding: map-get($spacer, 5) map-get($spacer, 4) map-get($spacer, 4)
map-get($spacer, 4);
min-width: auto;
box-shadow: none;
border-radius: 0;
.sm-button {
display: block;
width: 100%;
text-align: center;
margin-top: map-get($spacer, 1);
margin-bottom: map-get($spacer, 1);
margin-left: 0 !important;
margin-right: 0 !important;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin-top: 0;
}
}
</style>

View File

@@ -1,18 +1,22 @@
<template>
<section class="sm-hero" v-if="loaded">
<section class="sm-hero">
<div class="sm-hero-background" :style="heroStyles"></div>
<div class="sm-hero-content">
<h1>{{ heroTitle }}</h1>
<p>{{ heroExcerpt }}</p>
<div class="sm-hero-buttons">
<SMButton
:to="{ name: 'post-view', params: { slug: heroSlug } }"
label="Read More" />
<SMContainer>
<div class="sm-hero-content">
<h1>{{ heroTitle }}</h1>
<p>{{ heroExcerpt }}</p>
<div class="sm-hero-buttons">
<SMButton
v-if="loaded"
:to="{ name: 'article', params: { slug: heroSlug } }"
label="Read More" />
</div>
</div>
</div>
</SMContainer>
<div class="sm-hero-caption">
<router-link
:to="{ name: 'post-view', params: { slug: heroSlug } }"
v-if="loaded"
:to="{ name: 'article', params: { slug: heroSlug } }"
>{{ heroImageTitle }}</router-link
>
</div>
@@ -28,11 +32,11 @@ import { excerpt } from "../helpers/string";
import SMButton from "./SMButton.vue";
const loaded = ref(false);
let heroTitle = "";
let heroExcerpt = "";
let heroImageUrl = "";
let heroTitle = ref("");
let heroExcerpt = ref("");
let heroImageUrl = ref("");
let heroImageTitle = "";
let heroSlug = "";
let heroSlug = ref("");
const translateY = ref(0);
const heroStyles = ref({
backgroundImage: "none",
@@ -71,15 +75,18 @@ const handleLoad = async () => {
const randomIndex = Math.floor(
Math.random() * postsData.posts.length
);
heroTitle = postsData.posts[randomIndex].title;
heroExcerpt = excerpt(postsData.posts[randomIndex].content, 200);
heroImageUrl = mediaGetVariantUrl(
heroTitle.value = postsData.posts[randomIndex].title;
heroExcerpt.value = excerpt(
postsData.posts[randomIndex].content,
200
);
heroImageUrl.value = mediaGetVariantUrl(
postsData.posts[randomIndex].hero
);
heroImageTitle = postsData.posts[randomIndex].hero.title;
heroSlug = postsData.posts[randomIndex].slug;
heroSlug.value = postsData.posts[randomIndex].slug;
heroStyles.value.backgroundImage = `linear-gradient(rgba(0, 0, 0, 0.5),rgba(0, 0, 0, 0.5)),url('${heroImageUrl}')`;
heroStyles.value.backgroundImage = `linear-gradient(to right, rgba(0, 0, 0, 0.7),rgba(0, 0, 0, 0.2)),url('${heroImageUrl.value}')`;
loaded.value = true;
}
@@ -109,9 +116,8 @@ handleLoad();
.sm-hero-content {
position: relative;
padding: 150px 32px 120px;
max-width: 1200px;
margin: 0 auto;
margin: 150px 32px 120px;
max-width: 640px;
h1 {
font-size: 300%;
@@ -134,18 +140,19 @@ handleLoad();
position: absolute;
bottom: 14px;
right: 30px;
color: #999;
color: #ccc;
font-size: 80%;
padding: 2px 10px;
padding: 6px 12px;
background-color: rgba(0, 0, 0, 0.5);
a {
color: inherit;
transition: color 0.1s ease-in-out;
text-decoration: none;
&:hover {
text-decoration: none;
color: #ccc;
color: #eee;
}
}
}

View File

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

View File

@@ -0,0 +1,11 @@
<template>
<div class="input-group">
<slot></slot>
</div>
</template>
<style lang="scss">
.input-group {
margin-bottom: 12px;
}
</style>

View File

@@ -11,33 +11,32 @@
.sm-loading-icon-balls {
display: inline-block;
position: relative;
width: 80px;
height: 80px;
width: 3em;
height: 0.5em;
div {
position: absolute;
top: 33px;
width: 13px;
height: 13px;
top: 0em;
width: 0.5em;
height: 0.5em;
border-radius: 50%;
background: #000;
background: var(--base-color-light);
animation-timing-function: cubic-bezier(0, 1, 1, 0);
box-shadow: 0 0 1px rgba(0, 0, 0, 1);
}
div:nth-child(1) {
left: 8px;
left: 0.3em;
animation: sm-loading-icon1 0.6s infinite;
}
div:nth-child(2) {
left: 8px;
left: 0.3em;
animation: sm-loading-icon2 0.6s infinite;
}
div:nth-child(3) {
left: 32px;
left: 1.2em;
animation: sm-loading-icon2 0.6s infinite;
}
div:nth-child(4) {
left: 56px;
left: 2.1em;
animation: sm-loading-icon3 0.6s infinite;
}
@keyframes sm-loading-icon1 {
@@ -61,7 +60,7 @@
transform: translate(0, 0);
}
100% {
transform: translate(24px, 0);
transform: translate(0.9em, 0);
}
}
}

View File

@@ -20,6 +20,8 @@
<script setup lang="ts">
import { useRoute } from "vue-router";
import SMButton from "./SMButton.vue";
import SMInput from "./SMInput.vue";
defineProps({
title: {
@@ -54,6 +56,7 @@ const tabs = () => {
.masthead {
background-color: var(--primary-color);
width: 100%;
margin-bottom: 32px;
.main {
width: 100%;

View File

@@ -1,46 +1,49 @@
<template>
<SMContainer
:full="true"
:class="['sm-navbar', { 'sm-show-nav': showToggle }]"
:class="['sm-navbar-container', { 'sm-show-nav': showToggle }]"
@click="handleClickNavBar">
<template #default>
<div id="sm-nav-head">
<router-link :to="{ name: 'home' }" id="sm-logo-link">
<img
class="sm-nav-logo dark:d-none"
src="/assets/logo.png"
width="270"
height="40"
alt="STEMMechanics" />
<img
class="sm-nav-logo light:d-none"
src="/assets/logo-dark.png"
width="270"
height="40"
alt="STEMMechanics" />
</router-link>
<label id="sm-nav-toggle" @click.stop="handleClickToggleMenu"
><img
src="/assets/hamburger.svg"
width="24"
height="24"
alt="Navbar Toggle"
/></label>
</div>
<div id="sm-nav">
<ul class="left">
<template v-for="item in menuItems">
<li
v-if="item.show == undefined || item.show()"
:key="item.name">
<router-link :to="item.to">{{
item.label
}}</router-link>
</li>
</template>
</ul>
<ul class="right"></ul>
</div>
<template #inner>
<nav class="sm-navbar">
<div id="sm-nav-head">
<router-link :to="{ name: 'home' }" id="sm-logo-link">
<img
class="sm-nav-logo dark:d-none"
src="/assets/logo.png"
width="270"
height="40"
alt="STEMMechanics" />
<img
class="sm-nav-logo light:d-none"
src="/assets/logo-dark.png"
width="270"
height="40"
alt="STEMMechanics" />
</router-link>
<label
id="sm-nav-toggle"
@click.stop="handleClickToggleMenu"
><img
src="/assets/hamburger.svg"
width="24"
height="24"
alt="Navbar Toggle"
/></label>
</div>
<div id="sm-nav">
<ul>
<template v-for="item in menuItems">
<li
v-if="item.show == undefined || item.show()"
:key="item.name">
<router-link :to="item.to"
><span>{{ item.label }}</span></router-link
>
</li>
</template>
</ul>
</div>
</nav>
</template>
</SMContainer>
</template>
@@ -52,17 +55,25 @@ import { useUserStore } from "../store/UserStore";
const userStore = useUserStore();
const showToggle = ref(false);
const menuItems = [
{
name: "news",
label: "News",
to: { name: "post-list" },
icon: "newspaper-outline",
},
{
name: "workshops",
label: "Workshops",
to: { name: "event-list" },
icon: "library-outline",
},
{
name: "blog",
label: "Blog",
to: { name: "blog" },
},
{
name: "community",
label: "Community",
to: { name: "blog" },
},
{
name: "about",
label: "About",
to: { name: "blog" },
},
// {
// name: "courses",
@@ -74,7 +85,6 @@ const menuItems = [
name: "contact",
label: "Contact",
to: { name: "contact" },
icon: "mail-outline",
},
{
name: "register",
@@ -82,15 +92,12 @@ const menuItems = [
to: { name: "register" },
icon: "person-add-outline",
show: () => !userStore.id,
inNav: false,
},
{
name: "login",
label: "Log in",
to: { name: "login" },
icon: "log-in-outline",
show: () => !userStore.id,
inNav: false,
},
{
name: "dashboard",
@@ -98,7 +105,6 @@ const menuItems = [
to: { name: "dashboard" },
icon: "grid-outline",
show: () => userStore.id,
inNav: false,
},
{
name: "logout",
@@ -106,7 +112,6 @@ const menuItems = [
to: { name: "logout" },
icon: "log-out-outline",
show: () => userStore.id,
inNav: false,
},
];
@@ -128,7 +133,13 @@ const handleClickNavBar = () => {
</script>
<style lang="scss">
.sm-navbar {
body[data-route-name="page-home"] {
.sm-navbar-container {
background-color: rgba(255, 255, 255, 0.1);
}
}
.sm-navbar-container {
position: relative;
z-index: 100;
-webkit-backdrop-filter: blur(4px);
@@ -137,7 +148,7 @@ const handleClickNavBar = () => {
box-shadow: var(--base-shadow);
&.sm-show-nav {
background-color: var(--navbar-color-dropdown);
background-color: var(--navbar-color) !important;
#sm-nav {
display: flex;
@@ -148,15 +159,22 @@ const handleClickNavBar = () => {
}
}
.sm-navbar {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
#sm-nav-head {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
max-width: 1200px;
#sm-logo-link {
padding-left: 23px;
padding-right: 18px;
margin-top: -10px;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
@@ -172,19 +190,7 @@ const handleClickNavBar = () => {
}
#sm-nav-toggle {
padding: 23px;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
&:hover {
background-color: hsla(0, 0%, 50%, 0.1);
}
img {
display: block;
}
padding: 24px;
}
}
@@ -205,7 +211,7 @@ const handleClickNavBar = () => {
li a {
color: var(--base-color-text);
display: block;
padding: 12px 24px;
padding: 12px 0;
margin: 0;
text-decoration: none;
@@ -213,6 +219,10 @@ const handleClickNavBar = () => {
text-decoration: none;
background-color: hsla(0, 0%, 50%, 0.1);
}
span {
padding-left: 12px;
}
}
}
}
@@ -223,4 +233,22 @@ const handleClickNavBar = () => {
filter: invert(100%) saturate(0%) brightness(120%);
}
}
@media screen and (max-width: 768px) {
// #sm-nav-toggle {
// padding: 23px;
// -webkit-user-select: none;
// -moz-user-select: none;
// -ms-user-select: none;
// user-select: none;
// &:hover {
// background-color: hsla(0, 0%, 50%, 0.1);
// }
// img {
// display: block;
// }
// }
}
</style>

View File

@@ -1,14 +1,30 @@
<template>
<div class="sm-pagination">
<ion-icon
name="chevron-back-outline"
:class="[{ disabled: computedDisablePrevButton }]"
@click="handleClickPrev" />
<span class="sm-pagination-info">{{ computedPaginationInfo }}</span>
<ion-icon
name="chevron-forward-outline"
:class="[{ disabled: computedDisableNextButton }]"
@click="handleClickNext" />
<div class="pagination">
<div
v-if="props.modelValue > 1"
:class="[
'item',
'previous',
{ disabled: computedDisablePrevButton },
]"
@click="handleClickPrev">
<ion-icon name="chevron-back-outline" />
Previous
</div>
<div
:class="['item', { active: page == props.modelValue }]"
v-for="(page, idx) of computedPages"
:key="idx"
@click="handleClickPage(page)">
{{ page }}
</div>
<div
v-if="(props.modelValue + 3) * props.perPage <= props.total"
:class="['item', 'next', { disabled: computedDisableNextButton }]"
@click="handleClickNext">
Next
<ion-icon name="chevron-forward-outline" />
</div>
</div>
</template>
@@ -35,15 +51,28 @@ const emits = defineEmits(["update:modelValue"]);
/**
* Returns the pagination info
*/
const computedPaginationInfo = computed(() => {
if (props.total == 0) {
return "0 - 0 of 0";
const computedPages = computed(() => {
let pages = [];
if (props.modelValue - 2 > 0) {
pages.push(props.modelValue - 2);
}
const start = (props.modelValue - 1) * props.perPage + 1;
const end = Math.min(start + props.perPage - 1, props.total);
if (props.modelValue - 1 > 0) {
pages.push(props.modelValue - 1);
}
return `${start} - ${end} of ${props.total}`;
pages.push(props.modelValue);
if (props.perPage * (props.modelValue + 1) <= props.total) {
pages.push(props.modelValue + 1);
}
if (props.perPage * (props.modelValue + 2) <= props.total) {
pages.push(props.modelValue + 2);
}
return pages;
});
/**
@@ -69,67 +98,76 @@ const computedDisableNextButton = computed(() => {
/**
* Handle click on previous button
*
* @param {MouseEvent} $event The mouse event.
*/
const handleClickPrev = ($event: MouseEvent): void => {
if (
$event.target &&
($event.target as HTMLElement).classList.contains("disabled") ==
false &&
props.modelValue > 1
) {
emits("update:modelValue", props.modelValue - 1);
}
const handleClickPrev = (): void => {
emits("update:modelValue", props.modelValue - 1);
};
/**
* Handle click on next button
*
* @param {MouseEvent} $event The mouse event.
*/
const handleClickNext = ($event: MouseEvent): void => {
if (
$event.target &&
($event.target as HTMLElement).classList.contains("disabled") ==
false &&
props.modelValue < computedTotalPages.value
) {
emits("update:modelValue", props.modelValue + 1);
}
const handleClickNext = (): void => {
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);
};
</script>
<style lang="scss">
.sm-pagination {
.pagination {
display: flex;
justify-content: center;
align-items: center;
font-family: var(--header-font-family);
font-size: 90%;
font-weight: 700;
margin-bottom: 24px;
ion-icon {
border: 1px solid $secondary-color;
border-radius: 4px;
padding: 0.25rem;
.item {
display: flex;
cursor: pointer;
transition: color 0.1s ease-in-out, background-color 0.1s ease-in-out;
color: $font-color;
background-color: var(--base-color-light);
padding: 12px 16px;
border-right: 1px solid rgba(0, 0, 0, 0.2);
border-left: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: var(--base-shadow);
&.disabled {
cursor: not-allowed;
color: $secondary-color;
&.active {
background-color: var(--primary-color);
}
&:not(.disabled) {
&:hover {
background-color: $secondary-color;
color: #eee;
}
&:first-of-type {
border-left-width: 0;
}
&:last-of-type {
border-right-width: 0;
}
&.previous ion-icon {
padding-right: 12px;
}
&.next ion-icon {
padding-left: 12px;
}
&:hover {
filter: brightness(115%);
}
}
}
.sm-pagination-info {
margin: 0 map-get($spacer, 3);
@media (prefers-color-scheme: dark) {
.pagination .item.active {
background-color: var(--primary-color-light);
}
}
</style>

View File

@@ -32,7 +32,7 @@ import SMButton from "../SMButton.vue";
import SMFormCard from "../SMFormCard.vue";
import SMForm from "../SMForm.vue";
import SMFormFooter from "../SMFormFooter.vue";
import SMInput from "../SMInput.vue";
import SMInput from "../../depreciated/SMInput-old.vue";
const form: FormObject = reactive(
Form({

View File

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

View File

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

View File

@@ -50,6 +50,18 @@ export interface MediaCollection {
total: number;
}
export interface Article {
id: string;
title: string;
slug: string;
user_id: string;
user: User;
content: string;
publish_at: string;
hero: Media;
attachments: Array<Media>;
}
export interface Post {
id: string;
title: string;

View File

@@ -54,8 +54,12 @@ const defaultFormObject: FormObject = {
return valid;
},
loading: function (state = true) {
this._loading = state;
loading: function (state = undefined) {
if (state !== undefined) {
this._loading = state;
}
return this._loading;
},
message: function (message = "", type = "", icon = "") {
this._message = message;

View File

@@ -15,6 +15,25 @@ export const routes = [
},
component: () => import("@/views/Home.vue"),
},
{
path: "/blog",
name: "blog",
meta: {
title: "Blog",
},
component: () => import("@/views/Blog.vue"),
},
{
path: "/article",
redirect: "/blog",
children: [
{
path: ":slug",
name: "article",
component: () => import("@/views/Article.vue"),
},
],
},
{
path: "/verify-email",
name: "verify-email",
@@ -130,24 +149,6 @@ export const routes = [
},
component: () => import("@/views/Register.vue"),
},
{
path: "/news",
children: [
{
path: "",
name: "post-list",
meta: {
title: "News",
},
component: () => import("@/views/PostList.vue"),
},
{
path: ":slug",
name: "post-view",
component: () => import("@/views/PostView.vue"),
},
],
},
{
path: "/dashboard",
children: [
@@ -475,7 +476,8 @@ router.beforeEach(async (to, from, next) => {
});
router.afterEach((to, from) => {
// empty
const routeName = `page-${to.name}`;
document.body.dataset.routeName = routeName;
});
export default router;

View File

@@ -4,9 +4,7 @@
</header>
<main>
<router-view v-slot="{ Component }">
<transition>
<component :is="Component" />
</transition>
<component :is="Component" />
</router-view>
</main>
<footer>
@@ -26,6 +24,7 @@ import SMDialogList from "../components/SMDialog";
<style lang="scss">
main {
display: flex;
flex-direction: column;
flex: 1;
}

142
resources/js/views/Blog.vue Normal file
View File

@@ -0,0 +1,142 @@
<template>
<SMMastHead title="Blog" />
<SMContainer>
<SMInputGroup>
<SMInput
type="text"
label="Search articles"
v-model="searchInput" />
<SMButton type="submit" label="Search" @click="handeClickSearch" />
</SMInputGroup>
<SMPagination
v-model="postsPage"
:total="postsTotal"
:per-page="postsPerPage" />
<div class="posts">
<article
class="article-card"
v-for="(post, idx) in posts"
:key="idx">
<div
class="thumbnail"
:style="{
backgroundImage: `url(${mediaGetVariantUrl(
post.hero,
'medium'
)})`,
}"></div>
<div class="content">
{{ post.content }}
</div>
</article>
</div>
</SMContainer>
</template>
<script setup lang="ts">
import { Ref, ref, watch } from "vue";
import SMMessage from "../components/SMMessage.vue";
import SMPagination from "../components/SMPagination.vue";
import SMPanel from "../components/SMPanel.vue";
import SMPanelList from "../components/SMPanelList.vue";
import { api } from "../helpers/api";
import { Post, PostCollection } from "../helpers/api.types";
import { SMDate } from "../helpers/datetime";
import { mediaGetVariantUrl } from "../helpers/media";
import SMMastHead from "../components/SMMastHead.vue";
import SMInput from "../components/SMInput.vue";
import SMInputGroup from "../components/SMInputGroup.vue";
import SMForm from "../components/SMForm.vue";
const message = ref("");
const pageLoading = ref(true);
const posts: Ref<Post[]> = ref([]);
const postsPerPage = 24;
let postsPage = ref(1);
let postsTotal = ref(0);
let searchInput = ref("");
const handeClickSearch = () => {
alert(searchInput.value);
};
/**
* Load the page data.
*/
const handleLoad = () => {
message.value = "";
pageLoading.value = true;
api.get({
url: "/posts",
params: {
limit: postsPerPage,
page: postsPage.value,
},
})
.then((result) => {
const data = result.data as PostCollection;
posts.value = data.posts;
postsTotal.value = data.total;
posts.value.forEach((post) => {
post.publish_at = new SMDate(post.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(
() => postsPage.value,
() => {
handleLoad();
}
);
handleLoad();
</script>
<style lang="scss">
.posts {
display: grid;
grid-template-columns: 1fr;
gap: 30px;
.article-card {
.thumbnail {
aspect-ratio: 16 / 9;
border-radius: 7px;
background-position: center;
background-size: cover;
background-color: var(--card-background-color);
box-shadow: 0 5px 10px -3px #00000078;
}
}
}
@media (min-width: 768px) {
.posts {
grid-template-columns: 1fr 1fr;
}
}
@media (min-width: 1024px) {
.posts {
grid-template-columns: 1fr 1fr 1fr;
}
}
</style>

View File

@@ -125,7 +125,7 @@ import { useReCaptcha } from "vue-recaptcha-v3";
import SMButton from "../components/SMButton.vue";
import SMFormCard from "../components/SMFormCard.vue";
import SMForm from "../components/SMForm.vue";
import SMInput from "../components/SMInput.vue";
import SMInput from "../depreciated/SMInput-old.vue";
import { api } from "../helpers/api";
import { Form, FormControl } from "../helpers/form";
import { And, Email, Min, Required } from "../helpers/validate";

View File

@@ -47,7 +47,7 @@ import SMButton from "../components/SMButton.vue";
import SMFormCard from "../components/SMFormCard.vue";
import SMForm from "../components/SMForm.vue";
import SMFormFooter from "../components/SMFormFooter.vue";
import SMInput from "../components/SMInput.vue";
import SMInput from "../depreciated/SMInput-old.vue";
import { api } from "../helpers/api";
import { Form, FormControl } from "../helpers/form";
import { And, Max, Min, Required } from "../helpers/validate";

View File

@@ -58,7 +58,7 @@
<script setup lang="ts">
import { reactive, ref, watch } from "vue";
import SMInput from "../components/SMInput.vue";
import SMInput from "../depreciated/SMInput-old.vue";
import SMMessage from "../components/SMMessage.vue";
import SMPagination from "../components/SMPagination.vue";
import SMPanel from "../components/SMPanel.vue";

View File

@@ -53,7 +53,7 @@ import SMButton from "../components/SMButton.vue";
import SMFormCard from "../components/SMFormCard.vue";
import SMForm from "../components/SMForm.vue";
import SMFormFooter from "../components/SMFormFooter.vue";
import SMInput from "../components/SMInput.vue";
import SMInput from "../depreciated/SMInput-old.vue";
import { api } from "../helpers/api";
import { Form, FormControl } from "../helpers/form";
import { And, Min, Required } from "../helpers/validate";

View File

@@ -52,7 +52,7 @@ import SMButton from "../components/SMButton.vue";
import SMFormCard from "../components/SMFormCard.vue";
import SMForm from "../components/SMForm.vue";
import SMFormFooter from "../components/SMFormFooter.vue";
import SMInput from "../components/SMInput.vue";
import SMInput from "../depreciated/SMInput-old.vue";
import { api } from "../helpers/api";
import { Form, FormControl } from "../helpers/form";
import { And, Email, Required } from "../helpers/validate";

View File

@@ -1,130 +1,164 @@
<template>
<SMPage full class="sm-page-home">
<SMHero />
<SMContainer class="about">
<h2>Join the Fun!</h2>
<p></p>
<p>
To meet the demands of a constantly evolving world, it is
essential to nurture a new generation of scientists, engineers,
and leaders who are skilled in problem-solving. Science and
technology offer endless possibilities for innovation and
progress, and it is through STEM education that we can equip the
next generation with the tools they need to tackle these
challenges.
</p>
<p>
STEMMechanics is a family-run business that is committed to
providing accessible and inclusive STEM education to all. We
offer a wide range of STEM courses, after-school clubs, and
themed workshops across Queensland, both to the general public
and to private groups.
</p>
</SMContainer>
<SMContainer class="workshops">
<SMRow>
<SMColumn class="align-items-center flex-basis-55">
<h2>Build skills while having a great time</h2>
<p>
Our online and in-person workshops are filled with
engaging and exciting activities that kids will love.
They will have fun, make new friends, and gain valuable
skills that they can use throughout their lives.
</p>
<SMButton
:to="{ name: 'event-list' }"
label="Explore Workshops" />
</SMColumn>
<SMColumn
class="align-items-center justify-content-center flex-basis-45">
<img src="/img/green-screen.jpg" />
</SMColumn>
</SMRow>
</SMContainer>
<SMContainer class="support">
<h2>And the support doesn't stop!</h2>
<SMRow>
<SMColumn
class="align-items-center justify-content-center flex-basis-45">
<img src="/img/discord.jpg" />
</SMColumn>
<SMColumn class="align-items-center flex-basis-55">
<p>
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="button-row">
<a href="https://discord.gg/yNzk4x7mpD">Join Discord</a>
<router-link :to="{ name: 'contact' }"
>Contact Us</router-link
>
</div>
</SMColumn>
</SMRow>
</SMContainer>
<SMContainer full class="minecraft">
<SMContainer>
<h2>Play Minecraft with us</h2>
<SMHero />
<section class="container">
<h2>Latest Articles</h2>
<div class="d-flex" style="gap: 30px">
<SMArticleCard
:image="articles[0].hero.url"
:title="articles[0].title"
:excerpt="excerpt(articles[0].content)"
:to="{
name: 'article',
params: { slug: articles[0].slug },
}"
class="flex-fill"></SMArticleCard>
<div class="article-list">
<SMArticleCard
:image="articles[1].hero.url"
:title="articles[1].title"
:excerpt="excerpt(articles[1].content)"
type="row"
:to="{
name: 'article',
params: { slug: articles[1].slug },
}"></SMArticleCard>
<SMArticleCard
:image="articles[2].hero.url"
:title="articles[2].title"
:excerpt="excerpt(articles[2].content)"
type="row"
:to="{
name: 'article',
params: { slug: articles[2].slug },
}"></SMArticleCard>
<SMArticleCard
:image="articles[3].hero.url"
:title="articles[3].title"
:excerpt="excerpt(articles[3].content)"
type="row"
:to="{
name: 'article',
params: { slug: articles[3].slug },
}"></SMArticleCard>
</div>
</div>
</section>
<SMContainer class="about">
<h2>Join the Fun!</h2>
<p></p>
<p>
To meet the demands of a constantly evolving world, it is essential
to nurture a new generation of scientists, engineers, and leaders
who are skilled in problem-solving. Science and technology offer
endless possibilities for innovation and progress, and it is through
STEM education that we can equip the next generation with the tools
they need to tackle these challenges.
</p>
<p>
STEMMechanics is a family-run business that is committed to
providing accessible and inclusive STEM education to all. We offer a
wide range of STEM courses, after-school clubs, and themed workshops
across Queensland, both to the general public and to private groups.
</p>
</SMContainer>
<SMContainer class="workshops">
<SMRow>
<SMColumn class="align-items-center flex-basis-55">
<h2>Build skills while having a great time</h2>
<p>
We invite you to join us on our Minecraft servers,
supporting both Bedrock and Java clients, where you can
participate in weekly challenges and mini-games.
Our online and in-person workshops are filled with engaging
and exciting activities that kids will love. They will have
fun, make new friends, and gain valuable skills that they
can use throughout their lives.
</p>
<p class="minecraft-education">
<img
src="/img/minecraft-edu.png"
height="96"
width="96"
class="minecraft-image" />
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.
<SMButton
:to="{ name: 'event-list' }"
label="Explore Workshops" />
</SMColumn>
<SMColumn
class="align-items-center justify-content-center flex-basis-45">
<img src="/img/green-screen.jpg" />
</SMColumn>
</SMRow>
</SMContainer>
<SMContainer class="support">
<h2>And the support doesn't stop!</h2>
<SMRow>
<SMColumn
class="align-items-center justify-content-center flex-basis-45">
<img src="/img/discord.jpg" />
</SMColumn>
<SMColumn class="align-items-center flex-basis-55">
<p>
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>
<p class="pt-5">
<img
src="/img/minecraft-address.png"
height="70"
class="minecraft-address" />
</p>
</SMContainer>
</SMContainer>
<SMContainer class="subscribe">
<h2>Be the first to know</h2>
<div class="button-row">
<a href="https://discord.gg/yNzk4x7mpD">Join Discord</a>
<router-link :to="{ name: 'contact' }"
>Contact Us</router-link
>
</div>
</SMColumn>
</SMRow>
</SMContainer>
<SMContainer full class="minecraft">
<SMContainer>
<h2>Play Minecraft with us</h2>
<p>
Sign up for our mailing list to receive expert tips and tricks,
as well as updates on upcoming workshops.
We invite you to join us on our Minecraft servers, supporting
both Bedrock and Java clients, where you can participate in
weekly challenges and mini-games.
</p>
<p class="minecraft-education">
<img
src="/img/minecraft-edu.png"
height="96"
width="96"
class="minecraft-image" />
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>
<p class="pt-5">
<img
src="/img/minecraft-address.png"
height="70"
class="minecraft-address" />
</p>
<SMFormCard class="p-0" no-shadow>
<SMForm v-model="form" @submit="handleSubscribe">
<div class="form-row">
<SMInput control="email" />
<SMButton type="submit" label="Subscribe" />
</div>
</SMForm>
</SMFormCard>
</SMContainer>
</SMPage>
</SMContainer>
<SMContainer class="subscribe">
<h2>Be the first to know</h2>
<p>
Sign up for our mailing list to receive expert tips and tricks, as
well as updates on upcoming workshops.
</p>
</SMContainer>
</template>
<script setup lang="ts">
import { reactive } from "vue";
import { Ref, reactive, ref } from "vue";
import { useReCaptcha } from "vue-recaptcha-v3";
import SMButton from "../components/SMButton.vue";
import SMFormCard from "../components/SMFormCard.vue";
import SMForm from "../components/SMForm.vue";
import SMInput from "../components/SMInput.vue";
import SMInput from "../depreciated/SMInput-old.vue";
import SMHero from "../components/SMHero.vue";
import { excerpt } from "../helpers/string";
import { api } from "../helpers/api";
import { Form, FormControl } from "../helpers/form";
import { And, Email, Required } from "../helpers/validate";
import { Article } from "../helpers/api.types";
import SMArticleCard from "../components/SMArticleCard.vue";
const { executeRecaptcha, recaptchaLoaded } = useReCaptcha();
let form = reactive(
@@ -133,294 +167,292 @@ let form = reactive(
})
);
const handleSubscribe = async () => {
form.loading(true);
form.message();
const articles: Ref<Article[]> = ref([]);
try {
await recaptchaLoaded();
const captcha = await executeRecaptcha("submit");
const handleLoad = async () => {
const result = await api.get({
url: "/posts",
params: {
limit: 4,
after: 1, // is this working???
// order: "-date",
},
});
await api.post({
url: "/subscriptions",
body: {
email: form.controls.email.value,
captcha_token: captcha,
},
});
form.controls.email.value = "";
form.message("Your email address has been subscribed.", "success");
} catch (err) {
form.apiErrors(err);
}
form.loading(false);
articles.value = result.data.posts;
};
handleLoad();
</script>
<style lang="scss">
.sm-page-home {
margin-top: -127px !important;
background-color: #fff;
h2 {
font-weight: 1000;
text-align: center;
margin: 0;
}
.about {
margin-top: 5rem;
margin-left: 2rem;
margin-right: 2rem;
background-color: #3d4e5d;
color: rgb(230, 245, 235);
border-radius: 24px;
padding: 4rem 8rem;
width: auto;
align-self: center;
h2 {
font-size: 400%;
}
p {
font-size: 125%;
line-height: 150%;
}
}
.workshops {
margin: 8rem auto;
align-self: center;
h2 {
font-size: 300%;
}
p {
font-size: 125%;
line-height: 150%;
max-width: 32rem;
text-align: center;
margin: 1rem auto 2rem auto;
}
img {
border-radius: 50rem;
height: 20rem;
width: 20rem;
}
}
.support {
background-color: #e6f5eb;
color: rgb(56, 79, 95);
border-radius: 24px;
padding: 4rem 5rem;
margin-left: 2rem;
margin-right: 2rem;
width: auto;
align-self: center;
img {
border-radius: 24px;
height: 80%;
width: 80%;
transform: rotateZ(-10deg);
}
h2 {
font-size: 300%;
text-align: left;
text-align: center;
margin-bottom: 1rem;
}
p {
font-size: 125%;
line-height: 150%;
}
.button-row {
display: flex;
justify-content: space-between;
width: 100%;
margin-top: 1rem;
a {
font-weight: bold;
color: inherit;
border: 2px solid rgb(56, 79, 95);
border-radius: 24px;
padding: 0.5rem 1.5rem;
transition: color 0.2s ease-in-out, border 0.2s ease-in-out,
background 0.2s ease-in-out;
&:hover {
text-decoration: none;
background-color: rgb(56, 79, 95);
color: #e6f5eb;
}
}
}
}
.minecraft {
margin-top: 4rem;
background-image: url("/img/minecraft.png");
background-repeat: no-repeat;
background-position: center;
background-size: cover;
padding: 4rem;
color: #fff;
h2 {
font-size: 300%;
}
p {
font-size: 125%;
line-height: 150%;
text-align: center;
max-width: 44rem;
margin: 1rem auto;
}
.minecraft-education {
text-align: left;
.minecraft-image {
float: left;
margin-top: 1rem;
margin-right: 2rem;
}
}
.minecraft-address {
width: 100%;
height: 100%;
}
}
.subscribe {
margin: 6rem auto 0 auto;
align-self: center;
h2 {
font-size: 200%;
}
p {
text-align: center;
font-size: 120%;
line-height: 140%;
margin: 1rem auto;
}
.form-row {
background-color: #eee;
border-radius: 24px;
padding: 2rem;
display: flex;
flex-direction: column;
width: 100%;
max-width: 600px;
margin: 1rem auto;
}
}
.article-list {
flex: 1;
display: flex;
flex-direction: column;
gap: 20px;
}
// .sm-page-home {
// margin-top: -127px !important;
// background-color: #fff;
@media only screen and (max-width: 1024px) {
.sm-page-home {
.about {
padding: 4rem;
}
// h2 {
// font-weight: 1000;
// text-align: center;
// margin: 0;
// }
.support {
padding: 4rem;
}
}
}
// .about {
// margin-top: 5rem;
// margin-left: 2rem;
// margin-right: 2rem;
// background-color: #3d4e5d;
// color: rgb(230, 245, 235);
// border-radius: 24px;
// padding: 4rem 8rem;
// width: auto;
// align-self: center;
@media only screen and (max-width: 896px) {
.sm-page-home {
.support {
.row {
flex-direction: column;
}
}
}
}
// h2 {
// font-size: 400%;
// }
@media only screen and (max-width: 768px) {
.sm-page-home {
.about {
margin-top: 2rem;
margin-left: 0;
margin-right: 0;
border-radius: 0;
}
// p {
// font-size: 125%;
// line-height: 150%;
// }
// }
.workshops {
margin-top: 4rem;
margin-bottom: 4rem;
}
// .workshops {
// margin: 8rem auto;
// align-self: center;
.support {
margin-left: 0;
margin-right: 0;
border-radius: 0;
}
// h2 {
// font-size: 300%;
// }
.minecraft {
margin-top: 0;
padding-left: 1rem;
padding-right: 1rem;
// p {
// font-size: 125%;
// line-height: 150%;
// max-width: 32rem;
// text-align: center;
// margin: 1rem auto 2rem auto;
// }
.minecraft-education {
text-align: center;
// img {
// border-radius: 50rem;
// height: 20rem;
// width: 20rem;
// }
// }
.minecraft-image {
float: none;
display: block;
margin: 0 auto 1rem auto;
}
}
}
}
}
// .support {
// background-color: #e6f5eb;
// color: rgb(56, 79, 95);
// border-radius: 24px;
// padding: 4rem 5rem;
// margin-left: 2rem;
// margin-right: 2rem;
// width: auto;
// align-self: center;
@media only screen and (max-width: 640px) {
.sm-page-home {
.about {
padding: 2rem;
// img {
// border-radius: 24px;
// height: 80%;
// width: 80%;
// transform: rotateZ(-10deg);
// }
h2 {
font-size: 300%;
}
// h2 {
// font-size: 300%;
// text-align: left;
// text-align: center;
// margin-bottom: 1rem;
// }
p {
font-size: 100%;
line-height: 150%;
}
}
// p {
// font-size: 125%;
// line-height: 150%;
// }
.workshops,
.support,
.minecraft,
.subscribe {
padding: 2rem;
// .button-row {
// display: flex;
// justify-content: space-between;
// width: 100%;
// margin-top: 1rem;
h2 {
font-size: 200%;
}
// a {
// font-weight: bold;
// color: inherit;
// border: 2px solid rgb(56, 79, 95);
// border-radius: 24px;
// padding: 0.5rem 1.5rem;
// transition: color 0.2s ease-in-out, border 0.2s ease-in-out,
// background 0.2s ease-in-out;
p {
font-size: 100%;
}
}
}
}
// &:hover {
// text-decoration: none;
// background-color: rgb(56, 79, 95);
// color: #e6f5eb;
// }
// }
// }
// }
// .minecraft {
// margin-top: 4rem;
// background-image: url("/img/minecraft.png");
// background-repeat: no-repeat;
// background-position: center;
// background-size: cover;
// padding: 4rem;
// color: #fff;
// h2 {
// font-size: 300%;
// }
// p {
// font-size: 125%;
// line-height: 150%;
// text-align: center;
// max-width: 44rem;
// margin: 1rem auto;
// }
// .minecraft-education {
// text-align: left;
// .minecraft-image {
// float: left;
// margin-top: 1rem;
// margin-right: 2rem;
// }
// }
// .minecraft-address {
// width: 100%;
// height: 100%;
// }
// }
// .subscribe {
// margin: 6rem auto 0 auto;
// align-self: center;
// h2 {
// font-size: 200%;
// }
// p {
// text-align: center;
// font-size: 120%;
// line-height: 140%;
// margin: 1rem auto;
// }
// .form-row {
// background-color: #eee;
// border-radius: 24px;
// padding: 2rem;
// display: flex;
// flex-direction: column;
// width: 100%;
// max-width: 600px;
// margin: 1rem auto;
// }
// }
// }
// @media only screen and (max-width: 1024px) {
// .sm-page-home {
// .about {
// padding: 4rem;
// }
// .support {
// padding: 4rem;
// }
// }
// }
// @media only screen and (max-width: 896px) {
// .sm-page-home {
// .support {
// .row {
// flex-direction: column;
// }
// }
// }
// }
// @media only screen and (max-width: 768px) {
// .sm-page-home {
// .about {
// margin-top: 2rem;
// margin-left: 0;
// margin-right: 0;
// border-radius: 0;
// }
// .workshops {
// margin-top: 4rem;
// margin-bottom: 4rem;
// }
// .support {
// margin-left: 0;
// margin-right: 0;
// border-radius: 0;
// }
// .minecraft {
// margin-top: 0;
// padding-left: 1rem;
// padding-right: 1rem;
// .minecraft-education {
// text-align: center;
// .minecraft-image {
// float: none;
// display: block;
// margin: 0 auto 1rem auto;
// }
// }
// }
// }
// }
// @media only screen and (max-width: 640px) {
// .sm-page-home {
// .about {
// padding: 2rem;
// h2 {
// font-size: 300%;
// }
// p {
// font-size: 100%;
// line-height: 150%;
// }
// }
// .workshops,
// .support,
// .minecraft,
// .subscribe {
// padding: 2rem;
// h2 {
// font-size: 200%;
// }
// p {
// font-size: 100%;
// }
// }
// }
// }
</style>

View File

@@ -1,12 +1,14 @@
<template>
<div class="container">
<section class="card max-w-sm">
<div class="card-header">
<h2>Log in</h2>
<p>Enter your website login details to view your account.</p>
</div>
<SMForm v-model="form" @submit="handleSubmit">
<div class="card-body">
<SMContainer>
<SMForm v-model="form" @submit="handleSubmit">
<SMFormCard>
<template #header>
<h2>Log in</h2>
<p>
Enter your website login details to view your account.
</p>
</template>
<template #body>
<SMInput control="username">
<router-link to="/forgot-username"
>Forgot username?</router-link
@@ -17,26 +19,26 @@
>Forgot password?</router-link
>
</SMInput>
</div>
<div class="card-footer">
</template>
<template #footer>
<small>
<span class="pr-1">Need an account?</span
><router-link to="/register">Register</router-link>
</small>
<input type="submit" class="btn" title="Log in" />
</div>
</SMForm>
</section>
</div>
<SMButton :form="form" type="submit" label="Log in" />
</template>
</SMFormCard>
</SMForm>
</SMContainer>
</template>
<script setup lang="ts">
import { reactive } from "vue";
import { useRoute, useRouter } from "vue-router";
import SMButton from "../components/SMButton.vue";
import SMFormCard from "../components/SMFormCard.vue";
import SMContainer from "../components/SMContainer.vue";
import SMForm from "../components/SMForm.vue";
import SMFormFooter from "../components/SMFormFooter.vue";
import SMFormCard from "../components/SMFormCard.vue";
import SMInput from "../components/SMInput.vue";
import { api } from "../helpers/api";
import { LoginResponse } from "../helpers/api.types";
@@ -44,8 +46,9 @@ import { Form, FormControl } from "../helpers/form";
import { And, Min, Required } from "../helpers/validate";
import { useUserStore } from "../store/UserStore";
const router = useRouter();
const userStore = useUserStore();
const router = useRouter();
let form = reactive(
Form({
username: FormControl("", And([Required(), Min(4)])),

View File

@@ -1,98 +0,0 @@
<template>
<SMPage class="sm-page-post-list" :loading="pageLoading">
<template #container>
<SMMessage
v-if="message"
icon="alert-circle-outline"
type="error"
:message="message"
class="mt-5" />
<SMPanelList
:not-found="!pageLoading && posts.length == 0"
not-found-text="No news found">
<SMPanel
v-for="post in posts"
:key="post.id"
:image="mediaGetVariantUrl(post.hero, 'medium')"
:to="{ name: 'post-view', params: { slug: post.slug } }"
:title="post.title"
:date="post.publish_at"
:content="post.content"
:show-date="false"
button="Read More"
button-type="outline" />
</SMPanelList>
<SMPagination
v-model="postsPage"
:total="postsTotal"
:per-page="postsPerPage" />
</template>
</SMPage>
</template>
<script setup lang="ts">
import { Ref, ref, watch } from "vue";
import SMMessage from "../components/SMMessage.vue";
import SMPagination from "../components/SMPagination.vue";
import SMPanel from "../components/SMPanel.vue";
import SMPanelList from "../components/SMPanelList.vue";
import { api } from "../helpers/api";
import { Post, PostCollection } from "../helpers/api.types";
import { SMDate } from "../helpers/datetime";
import { mediaGetVariantUrl } from "../helpers/media";
const message = ref("");
const pageLoading = ref(true);
const posts: Ref<Post[]> = ref([]);
const postsPerPage = 9;
let postsPage = ref(1);
let postsTotal = ref(0);
/**
* Load the page data.
*/
const handleLoad = () => {
message.value = "";
pageLoading.value = true;
api.get({
url: "/posts",
params: {
limit: postsPerPage,
page: postsPage.value,
},
})
.then((result) => {
const data = result.data as PostCollection;
posts.value = data.posts;
postsTotal.value = data.total;
posts.value.forEach((post) => {
post.publish_at = new SMDate(post.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(
() => postsPage.value,
() => {
handleLoad();
}
);
handleLoad();
</script>

View File

@@ -1,82 +1,75 @@
<template>
<SMPage>
<template #container>
<SMFormCard full class="mt-5" :narrow="formDone">
<template v-if="!formDone">
<h1>Register</h1>
<SMForm v-model="form" @submit="handleSubmit">
<SMRow>
<SMColumn>
<SMInput control="username" />
</SMColumn>
<SMColumn>
<SMInput
control="password"
type="password"></SMInput>
</SMColumn>
</SMRow>
<SMRow>
<SMColumn>
<SMInput control="first_name" />
</SMColumn>
<SMColumn>
<SMInput control="last_name" />
</SMColumn>
</SMRow>
<SMRow>
<SMColumn>
<SMInput control="email" />
</SMColumn>
<SMColumn>
<SMInput control="phone">
This field is optional.
</SMInput>
</SMColumn>
</SMRow>
<SMFormFooter>
<template #left>
<div class="small">
<span class="pr-1"
>Already have an account?</span
><router-link to="/login"
>Log in</router-link
>
</div>
</template>
<template #right>
<SMButton
type="submit"
label="Register"
icon="arrow-forward-outline" />
</template>
</SMFormFooter>
</SMForm>
<SMContainer>
<SMForm v-model="form" @submit="handleSubmit">
<SMCard class="form-wide">
<template #header>
<h2>Register</h2>
</template>
<template v-else>
<h1>Email Sent!</h1>
<p class="text-center">
An email has been sent to you to confirm your details
and to finish registering your account.
</p>
<SMFormFooter>
<template #right>
<SMButton :to="{ name: 'home' }" label="Home" />
</template>
</SMFormFooter>
<template #body>
<SMRow>
<SMColumn>
<SMInput control="username" />
</SMColumn>
<SMColumn>
<SMInput
control="password"
type="password"></SMInput>
</SMColumn>
</SMRow>
<SMRow>
<SMColumn>
<SMInput control="first_name" />
</SMColumn>
<SMColumn>
<SMInput control="last_name" />
</SMColumn>
</SMRow>
<SMRow>
<SMColumn>
<SMInput control="email" />
</SMColumn>
<SMColumn>
<SMInput control="phone">
This field is optional.
</SMInput>
</SMColumn>
</SMRow>
</template>
</SMFormCard>
<template #footer>
<div class="small">
<span class="pr-1">Already have an account?</span
><router-link to="/login">Log in</router-link>
</div>
<SMButton type="submit" label="Register" />
</template>
</SMCard>
</SMForm>
<!-- </template>
<template v-else>
<h1>Email Sent!</h1>
<p class="text-center">
An email has been sent to you to confirm your details and to
finish registering your account.
</p>
<SMFormFooter>
<template #right>
<SMButton :to="{ name: 'home' }" label="Home" />
</template>
</SMFormFooter>
</template>
</SMPage>
</SMCard> -->
</SMContainer>
</template>
<script setup lang="ts">
import { reactive, ref } from "vue";
import { useReCaptcha } from "vue-recaptcha-v3";
import SMCard from "../components/SMCard.vue";
import SMButton from "../components/SMButton.vue";
import SMFormCard from "../components/SMFormCard.vue";
import SMForm from "../components/SMForm.vue";
import SMFormFooter from "../components/SMFormFooter.vue";
import SMInput from "../components/SMInput.vue";
import SMInput from "../depreciated/SMInput-old.vue";
import { api } from "../helpers/api";
import { Form, FormControl } from "../helpers/form";
import {

View File

@@ -49,7 +49,7 @@ import SMButton from "../components/SMButton.vue";
import SMFormCard from "../components/SMFormCard.vue";
import SMForm from "../components/SMForm.vue";
import SMFormFooter from "../components/SMFormFooter.vue";
import SMInput from "../components/SMInput.vue";
import SMInput from "../depreciated/SMInput-old.vue";
import { api } from "../helpers/api";
import { Form, FormControl } from "../helpers/form";
import { Required } from "../helpers/validate";

View File

@@ -49,7 +49,7 @@ import SMButton from "../components/SMButton.vue";
import SMFormCard from "../components/SMFormCard.vue";
import SMForm from "../components/SMForm.vue";
import SMFormFooter from "../components/SMFormFooter.vue";
import SMInput from "../components/SMInput.vue";
import SMInput from "../depreciated/SMInput-old.vue";
import { api } from "../helpers/api";
import { Form, FormControl } from "../helpers/form";
import { And, Max, Min, Password, Required } from "../helpers/validate";

View File

@@ -41,7 +41,7 @@ import SMButton from "../components/SMButton.vue";
import SMFormCard from "../components/SMFormCard.vue";
import SMForm from "../components/SMForm.vue";
import SMFormFooter from "../components/SMFormFooter.vue";
import SMInput from "../components/SMInput.vue";
import SMInput from "../depreciated/SMInput-old.vue";
import { api } from "../helpers/api";
import { Form, FormControl } from "../helpers/form";

View File

@@ -23,7 +23,7 @@ import { reactive } from "vue";
import { useRoute } from "vue-router";
import SMButton from "../../components/SMButton.vue";
import SMForm from "../../components/SMForm.vue";
import SMInput from "../../components/SMInput.vue";
import SMInput from "../../depreciated/SMInput-old.vue";
import { api } from "../../helpers/api";
import { Form, FormControl } from "../../helpers/form";

View File

@@ -132,7 +132,7 @@ import { useRoute, useRouter } from "vue-router";
import SMButton from "../../components/SMButton.vue";
import SMEditor from "../../components/SMEditor.vue";
import SMFormFooter from "../../components/SMFormFooter.vue";
import SMInput from "../../components/SMInput.vue";
import SMInput from "../../depreciated/SMInput-old.vue";
import { api } from "../../helpers/api";
import { SMDate } from "../../helpers/datetime";
import { Form, FormControl } from "../../helpers/form";

View File

@@ -70,7 +70,7 @@ import SMHeading from "../../components/SMHeading.vue";
import SMLoadingIcon from "../../components/SMLoadingIcon.vue";
import SMMessage from "../../components/SMMessage.vue";
import SMToolbar from "../../components/SMToolbar.vue";
import SMInput from "../../components/SMInput.vue";
import SMInput from "../../depreciated/SMInput-old.vue";
import { api } from "../../helpers/api";
import { SMDate } from "../../helpers/datetime";
import { debounce } from "../../helpers/debounce";

View File

@@ -67,7 +67,7 @@ import { useRoute, useRouter } from "vue-router";
import SMButton from "../../components/SMButton.vue";
import SMFormCard from "../../components/SMFormCard.vue";
import SMForm from "../../components/SMForm.vue";
import SMInput from "../../components/SMInput.vue";
import SMInput from "../../depreciated/SMInput-old.vue";
import { api } from "../../helpers/api";
import { Form, FormControl } from "../../helpers/form";

View File

@@ -73,7 +73,7 @@ import { debounce } from "../../helpers/debounce";
import { bytesReadable } from "../../helpers/types";
import { useUserStore } from "../../store/UserStore";
import { useToastStore } from "../../store/ToastStore";
import SMInput from "../../components/SMInput.vue";
import SMInput from "../../depreciated/SMInput-old.vue";
const router = useRouter();
const search = ref("");

View File

@@ -71,7 +71,7 @@ import SMButton from "../../components/SMButton.vue";
import SMEditor from "../../components/SMEditor.vue";
import SMForm from "../../components/SMForm.vue";
import SMFormFooter from "../../components/SMFormFooter.vue";
import SMInput from "../../components/SMInput.vue";
import SMInput from "../../depreciated/SMInput-old.vue";
import SMInputAttachments from "../../components/SMInputAttachments.vue";
import { api } from "../../helpers/api";
import { PostResponse, UserCollection } from "../../helpers/api.types";

View File

@@ -63,7 +63,7 @@ import { openDialog } from "../../components/SMDialog";
import SMDialogConfirm from "../../components/dialogs/SMDialogConfirm.vue";
import SMButton from "../../components/SMButton.vue";
import SMHeading from "../../components/SMHeading.vue";
import SMInput from "../../components/SMInput.vue";
import SMInput from "../../depreciated/SMInput-old.vue";
import SMLoadingIcon from "../../components/SMLoadingIcon.vue";
import SMMessage from "../../components/SMMessage.vue";
import SMToolbar from "../../components/SMToolbar.vue";

View File

@@ -38,7 +38,7 @@ import SMButton from "../../components/SMButton.vue";
import SMForm from "../../components/SMForm.vue";
import SMFormFooter from "../../components/SMFormFooter.vue";
import SMHeading from "../../components/SMHeading.vue";
import SMInput from "../../components/SMInput.vue";
import SMInput from "../../depreciated/SMInput-old.vue";
import { api } from "../../helpers/api";
import { UserResponse } from "../../helpers/api.types";
import { Form, FormControl } from "../../helpers/form";