This commit is contained in:
2023-02-27 19:40:03 +10:00
parent 1ee2a1189d
commit 955c06f5aa
30 changed files with 767 additions and 586 deletions

View File

@@ -1,4 +1,4 @@
interface ImportMeta { export interface ImportMetaExtras extends ImportMeta {
env: { env: {
APP_URL: string; APP_URL: string;
[key: string]: string; [key: string]: string;

View File

@@ -5,6 +5,7 @@
:class="[ :class="[
'sm-button', 'sm-button',
classType, classType,
{ 'sm-button-small': small },
{ 'sm-button-block': block }, { 'sm-button-block': block },
{ 'sm-dropdown-button': dropdown }, { 'sm-dropdown-button': dropdown },
]" ]"
@@ -37,7 +38,12 @@
v-else-if="!isEmpty(to) && typeof to == 'string'" v-else-if="!isEmpty(to) && typeof to == 'string'"
:href="to" :href="to"
:disabled="disabled" :disabled="disabled"
:class="['sm-button', classType, { 'sm-button-block': block }]" :class="[
'sm-button',
classType,
{ 'sm-button-small': small },
{ 'sm-button-block': block },
]"
:type="buttonType"> :type="buttonType">
{{ label }} {{ label }}
<ion-icon v-if="icon" :icon="icon" /> <ion-icon v-if="icon" :icon="icon" />
@@ -46,7 +52,12 @@
v-else-if="!isEmpty(to) && typeof to == 'object'" v-else-if="!isEmpty(to) && typeof to == 'object'"
:to="to" :to="to"
:disabled="disabled" :disabled="disabled"
:class="['sm-button', classType, { 'sm-button-block': block }]"> :class="[
'sm-button',
classType,
{ 'sm-button-small': small },
{ 'sm-button-block': block },
]">
<ion-icon v-if="icon && iconLocation == 'before'" :icon="icon" /> <ion-icon v-if="icon && iconLocation == 'before'" :icon="icon" />
{{ label }} {{ label }}
<ion-icon v-if="icon && iconLocation == 'after'" :icon="icon" /> <ion-icon v-if="icon && iconLocation == 'after'" :icon="icon" />
@@ -67,7 +78,7 @@ const props = defineProps({
}, },
iconLocation: { iconLocation: {
type: String, type: String,
default: "before", default: "after",
required: false, required: false,
validator: (value: string) => { validator: (value: string) => {
return ["before", "after"].includes(value); return ["before", "after"].includes(value);
@@ -89,6 +100,11 @@ const props = defineProps({
default: false, default: false,
required: false, required: false,
}, },
small: {
type: Boolean,
default: false,
required: false,
},
dropdown: { dropdown: {
type: Object, type: Object,
default: null, default: null,

View File

@@ -27,8 +27,8 @@ const emits = defineEmits(["submit"]);
/** /**
* Handle the user submitting the form. * Handle the user submitting the form.
*/ */
const handleSubmit = function () { const handleSubmit = async function () {
if (props.modelValue.validate()) { if (await props.modelValue.validate()) {
emits("submit"); emits("submit");
} }
}; };

View File

@@ -5,7 +5,7 @@
<script setup lang="ts"> <script setup lang="ts">
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { computed } from "vue"; import { computed } from "vue";
import "../../../import-meta"; import { ImportMetaExtras } from "../../../import-meta";
const props = defineProps({ const props = defineProps({
html: { html: {
@@ -22,7 +22,9 @@ const computedContent = computed(() => {
let html = ""; let html = "";
const regex = new RegExp( const regex = new RegExp(
`<a ([^>]*?)href="${import.meta.env.APP_URL}(.*?>.*?)</a>`, `<a ([^>]*?)href="${
(import.meta as ImportMetaExtras).env.APP_URL
}(.*?>.*?)</a>`,
"ig" "ig"
); );

View File

@@ -286,6 +286,7 @@ const handleMediaSelect = async (event) => {
flex-direction: column; flex-direction: column;
margin-bottom: map-get($spacer, 4); margin-bottom: map-get($spacer, 4);
flex: 1; flex: 1;
width: 100%;
&.sm-input-active { &.sm-input-active {
label { label {

View File

@@ -23,7 +23,7 @@
</template> </template>
</ul> </ul>
<SMButton <SMButton
:to="{ name: 'workshop-list' }" :to="{ name: 'event-list' }"
class="sm-navbar-cta" class="sm-navbar-cta"
label="Find a workshop" label="Find a workshop"
icon="arrow-forward-outline" /> icon="arrow-forward-outline" />
@@ -70,13 +70,13 @@ const menuItems = [
{ {
name: "news", name: "news",
label: "News", label: "News",
to: { name: "news" }, to: { name: "post-list" },
icon: "newspaper-outline", icon: "newspaper-outline",
}, },
{ {
name: "workshops", name: "workshops",
label: "Workshops", label: "Workshops",
to: { name: "workshop-list" }, to: { name: "event-list" },
icon: "library-outline", icon: "library-outline",
}, },
{ {

View File

@@ -0,0 +1,135 @@
<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>
</template>
<script setup lang="ts">
import { computed } from "vue";
const props = defineProps({
modelValue: {
type: Number,
required: true,
},
total: {
type: Number,
required: true,
},
perPage: {
type: Number,
required: true,
},
});
const emits = defineEmits(["update:modelValue"]);
/**
* Returns the pagination info
*/
const computedPaginationInfo = computed(() => {
if (props.total == 0) {
return "0 - 0 of 0";
}
const start = (props.modelValue - 1) * props.perPage + 1;
const end = start + props.perPage - 1;
return `${start} - ${end} of ${props.total}`;
});
/**
* Return the total number of pages.
*/
const computedTotalPages = computed(() => {
return Math.ceil(props.total / props.perPage);
});
/**
* Return if the previous button should be disabled.
*/
const computedDisablePrevButton = computed(() => {
return props.modelValue <= 1;
});
/**
* Return if the next button should be disabled.
*/
const computedDisableNextButton = computed(() => {
return props.modelValue >= computedTotalPages.value;
});
/**
* Handle click on previous button
*
* @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);
}
};
/**
* 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);
}
};
</script>
<style lang="scss">
.sm-pagination {
display: flex;
justify-content: center;
align-items: center;
ion-icon {
border: 1px solid $secondary-color;
border-radius: 4px;
padding: 0.25rem;
cursor: pointer;
transition: color 0.1s ease-in-out, background-color 0.1s ease-in-out;
color: $font-color;
&.disabled {
cursor: not-allowed;
color: $secondary-color;
}
&:not(.disabled) {
&:hover {
background-color: $secondary-color;
color: #eee;
}
}
}
.sm-pagination-info {
margin: 0 map-get($spacer, 3);
}
}
</style>

View File

@@ -31,7 +31,11 @@
{{ computedContent }} {{ computedContent }}
</div> </div>
<div v-if="button.length > 0" class="sm-panel-button"> <div v-if="button.length > 0" class="sm-panel-button">
<SMButton :to="to" :type="buttonType" :label="button" /> <SMButton
:to="to"
:type="buttonType"
:block="true"
:label="button" />
</div> </div>
</div> </div>
</router-link> </router-link>
@@ -288,5 +292,9 @@ watch(
line-height: 130%; line-height: 130%;
flex: 1; flex: 1;
} }
.sm-panel-button {
margin-top: map-get($spacer, 4);
}
} }
</style> </style>

View File

@@ -1,14 +1,25 @@
<template> <template>
<div class="sm-toolbar"> <div class="sm-toolbar">
<div class="sm-toolbar-column sm-toolbar-column-left"> <div v-if="slots.left" class="sm-toolbar-column sm-toolbar-column-left">
<slot name="left"></slot> <slot name="left"></slot>
</div> </div>
<div class="sm-toolbar-column sm-toolbar-column-right"> <div v-if="slots.default" class="sm-toolbar-column">
<slot></slot>
</div>
<div
v-if="slots.right"
class="sm-toolbar-column sm-toolbar-column-right">
<slot name="right"></slot> <slot name="right"></slot>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts">
import { useSlots } from "vue";
const slots = useSlots();
</script>
<style lang="scss"> <style lang="scss">
.sm-toolbar { .sm-toolbar {
display: flex; display: flex;
@@ -17,68 +28,41 @@
.sm-toolbar-column { .sm-toolbar-column {
display: flex; display: flex;
flex: 1;
flex-direction: row; flex-direction: row;
align-items: center; align-items: flex-start;
&.sm-toolbar-column-left { &.sm-toolbar-column-left {
justify-content: flex-start; justify-content: flex-start;
} }
input { & > * {
margin-bottom: 0; margin: 0 map-get($spacer, 1);
&:first-child {
margin-left: 0;
}
&:last-child {
margin-right: 0;
}
} }
// &.form-footer-column-left, &.form-footer-column-right {
// a, button {
// margin-left: map-get($spacer, 1);
// margin-right: map-get($spacer, 1);
// &:first-of-type {
// margin-left: 0;
// }
// &:last-of-type {
// margin-right: 0;
// }
// }
// }
&.sm-toolbar-column-right { &.sm-toolbar-column-right {
justify-content: flex-end; justify-content: flex-end;
} }
// } }
// } }
// @media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
// .form-footer { .sm-toolbar {
// flex-direction: column-reverse; .sm-toolbar-column {
flex-direction: column;
// .form-footer-column { & > * {
// &.form-footer-column-left, &.form-footer-column-right { margin: 0;
// display: flex; }
// flex-direction: column-reverse; }
// justify-content: center;
// & > * {
// 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;
// }
// }
// &.form-footer-column-left {
// margin-bottom: -#{map-get($spacer, 1) / 2};
// }
// &.form-footer-column-right {
// margin-top: -#{map-get($spacer, 1) / 2};
// }
// }
} }
} }
</style> </style>

View File

@@ -20,7 +20,7 @@ interface ApiOptions {
export interface ApiResponse { export interface ApiResponse {
status: number; status: number;
message: string; message: string;
data: Record<string, unknown>; data: unknown;
json?: Record<string, unknown>; json?: Record<string, unknown>;
} }
@@ -84,10 +84,13 @@ export const api = {
const fetchOptions: RequestInit = { const fetchOptions: RequestInit = {
method: options.method || "GET", method: options.method || "GET",
headers: options.headers, headers: options.headers,
body: options.body,
signal: options.signal || null, signal: options.signal || null,
}; };
if (typeof options.body == "string" && options.body.length > 0) {
fetchOptions.body = options.body;
}
const progressStore = useProgressStore(); const progressStore = useProgressStore();
progressStore.start(); progressStore.start();

View File

@@ -1,6 +1,15 @@
export interface Event { export interface Event {
id: string;
title: string;
hero: string;
content: string;
start_at: string; start_at: string;
end_at: string; end_at: string;
location: string;
address: string;
status: string;
registration_type: string;
registration_data: string;
} }
export interface EventResponse { export interface EventResponse {
@@ -8,7 +17,7 @@ export interface EventResponse {
} }
export interface EventCollection { export interface EventCollection {
events: Event; events: Event[];
} }
export interface Media { export interface Media {
@@ -50,6 +59,7 @@ export interface PostResponse {
export interface PostCollection { export interface PostCollection {
posts: Array<Post>; posts: Array<Post>;
total: number;
} }
export interface User { export interface User {

View File

@@ -6,7 +6,7 @@ import {
ValidationResult, ValidationResult,
} from "./validate"; } from "./validate";
type FormObjectValidateFunction = (item: string | null) => boolean; type FormObjectValidateFunction = (item: string | null) => Promise<boolean>;
type FormObjectLoadingFunction = (state: boolean) => void; type FormObjectLoadingFunction = (state: boolean) => void;
type FormObjectMessageFunction = ( type FormObjectMessageFunction = (
message?: string, message?: string,
@@ -30,26 +30,27 @@ export interface FormObject {
} }
const defaultFormObject: FormObject = { const defaultFormObject: FormObject = {
validate: function (item = null) { validate: async function (item = null) {
const keys = item ? [item] : Object.keys(this.controls); const keys = item ? [item] : Object.keys(this.controls);
let valid = true; let valid = true;
keys.every(async (key) => { await Promise.all(
if ( keys.map(async (key) => {
typeof this[key] == "object" && if (
Object.keys(this[key]).includes("validation") typeof this.controls[key] == "object" &&
) { Object.keys(this.controls[key]).includes("validation")
this[key].validation.result = await this[ ) {
key const validationResult = await this.controls[
].validation.validator.validate(this[key].value); key
].validation.validator.validate(this.controls[key].value);
this.controls[key].validation.result = validationResult;
if (!this[key].validation.result.valid) { if (!validationResult.valid) {
valid = false; valid = false;
}
} }
} })
);
return true;
});
return valid; return valid;
}, },

View File

@@ -37,14 +37,6 @@ export const routes = [
}, },
component: () => import("@/views/ResetPassword.vue"), component: () => import("@/views/ResetPassword.vue"),
}, },
{
path: "/about",
name: "about",
meta: {
title: "About",
},
component: () => import("@/views/About.vue"),
},
{ {
path: "/privacy", path: "/privacy",
name: "privacy", name: "privacy",
@@ -90,16 +82,16 @@ export const routes = [
children: [ children: [
{ {
path: "", path: "",
name: "workshop-list", name: "event-list",
meta: { meta: {
title: "Workshops", title: "Workshops",
}, },
component: () => import("@/views/WorkshopList.vue"), component: () => import("@/views/EventList.vue"),
}, },
{ {
path: ":id", path: ":id",
name: "workshop-view", name: "event-view",
component: () => import("@/views/WorkshopView.vue"), component: () => import("@/views/EventView.vue"),
}, },
], ],
}, },
@@ -141,16 +133,16 @@ export const routes = [
children: [ children: [
{ {
path: "", path: "",
name: "news", name: "post-list",
meta: { meta: {
title: "News", title: "News",
}, },
component: () => import("@/views/NewsList.vue"), component: () => import("@/views/PostList.vue"),
}, },
{ {
path: ":slug", path: ":slug",
name: "post-view", name: "post-view",
component: () => import("@/views/NewsView.vue"), component: () => import("@/views/PostView.vue"),
}, },
], ],
}, },
@@ -171,7 +163,7 @@ export const routes = [
children: [ children: [
{ {
path: "", path: "",
name: "post-list", name: "dashboard-post-list",
meta: { meta: {
title: "Posts", title: "Posts",
middleware: "authenticated", middleware: "authenticated",
@@ -181,7 +173,7 @@ export const routes = [
}, },
{ {
path: "create", path: "create",
name: "post-create", name: "dashboard-post-create",
meta: { meta: {
title: "Create Post", title: "Create Post",
middleware: "authenticated", middleware: "authenticated",
@@ -191,7 +183,7 @@ export const routes = [
}, },
{ {
path: ":id", path: ":id",
name: "post-edit", name: "dashboard-post-edit",
meta: { meta: {
title: "Edit Post", title: "Edit Post",
middleware: "authenticated", middleware: "authenticated",
@@ -206,7 +198,7 @@ export const routes = [
children: [ children: [
{ {
path: "", path: "",
name: "event-list", name: "dashboard-event-list",
meta: { meta: {
title: "Events", title: "Events",
middleware: "authenticated", middleware: "authenticated",
@@ -216,7 +208,7 @@ export const routes = [
}, },
{ {
path: "create", path: "create",
name: "event-create", name: "dashboard-event-create",
meta: { meta: {
title: "Create Event", title: "Create Event",
middleware: "authenticated", middleware: "authenticated",
@@ -226,7 +218,7 @@ export const routes = [
}, },
{ {
path: ":id", path: ":id",
name: "event-edit", name: "dashboard-event-edit",
meta: { meta: {
title: "Event Post", title: "Event Post",
middleware: "authenticated", middleware: "authenticated",
@@ -238,7 +230,7 @@ export const routes = [
}, },
{ {
path: "details", path: "details",
name: "account-details", name: "dashboard-account-details",
meta: { meta: {
title: "Account Details", title: "Account Details",
middleware: "authenticated", middleware: "authenticated",
@@ -250,7 +242,7 @@ export const routes = [
children: [ children: [
{ {
path: "", path: "",
name: "user-list", name: "dashboard-user-list",
meta: { meta: {
title: "Users", title: "Users",
middleware: "authenticated", middleware: "authenticated",
@@ -260,7 +252,7 @@ export const routes = [
}, },
{ {
path: ":id", path: ":id",
name: "user-edit", name: "dashboard-user-edit",
meta: { meta: {
title: "Edit User", title: "Edit User",
middleware: "authenticated", middleware: "authenticated",
@@ -275,7 +267,7 @@ export const routes = [
children: [ children: [
{ {
path: "", path: "",
name: "media", name: "dashboard-media",
meta: { meta: {
title: "Media", title: "Media",
middleware: "authenticated", middleware: "authenticated",
@@ -285,7 +277,7 @@ export const routes = [
}, },
{ {
path: "upload", path: "upload",
name: "media-upload", name: "dashboard-media-upload",
meta: { meta: {
title: "Upload Media", title: "Upload Media",
middleware: "authenticated", middleware: "authenticated",
@@ -295,7 +287,7 @@ export const routes = [
}, },
{ {
path: "edit/:id", path: "edit/:id",
name: "media-edit", name: "dashboard-media-edit",
meta: { meta: {
title: "Edit Media", title: "Edit Media",
middleware: "authenticated", middleware: "authenticated",
@@ -307,7 +299,7 @@ export const routes = [
}, },
{ {
path: "discord-bot-logs", path: "discord-bot-logs",
name: "discord-bot-logs", name: "dashboard-discord-bot-logs",
meta: { meta: {
title: "Discord Bot Logs", title: "Discord Bot Logs",
middleware: "authenticated", middleware: "authenticated",

View File

@@ -1,8 +1,8 @@
<template> <template>
<SMPage class="workshop-list"> <SMPage class="sm-workshop-list">
<template #container> <template #container>
<h1>Workshops</h1> <h1>Workshops</h1>
<div class="toolbar"> <SMToolbar>
<SMInput <SMInput
v-model="filterKeywords" v-model="filterKeywords"
label="Keywords" label="Keywords"
@@ -17,21 +17,21 @@
label="Date Range" label="Date Range"
:feedback-invalid="dateRangeError" :feedback-invalid="dateRangeError"
@change="handleFilter" /> @change="handleFilter" />
</div> </SMToolbar>
<SMMessage <SMMessage
v-if="formMessage.message" v-if="formMessage"
:icon="formMessage.icon" icon="alert-circle-outline"
:type="formMessage.type" type="error"
:message="formMessage.message" :message="formMessage"
class="mt-5" /> class="mt-5" />
<SMPanelList <SMPanelList
:loading="loading" :loading="loading"
:not-found="events.value?.length == 0" :not-found="events.length == 0"
not-found-text="No workshops found"> not-found-text="No workshops found">
<SMPanel <SMPanel
v-for="event in events.value" v-for="event in events"
:key="event.id" :key="event.id"
:to="{ name: 'workshop-view', params: { id: event.id } }" :to="{ name: 'event-view', params: { id: event.id } }"
:title="event.title" :title="event.title"
:image="event.hero" :image="event.hero"
:show-time="true" :show-time="true"
@@ -54,30 +54,25 @@ import SMInput from "../components/SMInput.vue";
import SMMessage from "../components/SMMessage.vue"; import SMMessage from "../components/SMMessage.vue";
import SMPanel from "../components/SMPanel.vue"; import SMPanel from "../components/SMPanel.vue";
import SMPanelList from "../components/SMPanelList.vue"; import SMPanelList from "../components/SMPanelList.vue";
import SMToolbar from "../components/SMToolbar.vue";
import { api } from "../helpers/api"; import { api } from "../helpers/api";
import { Event, EventCollection } from "../helpers/api.types";
import { SMDate } from "../helpers/datetime"; import { SMDate } from "../helpers/datetime";
const loading = ref(true); const loading = ref(true);
const events = reactive([]); let events: Event[] = reactive([]);
const dateRangeError = ref(""); const dateRangeError = ref("");
const formMessage = reactive({ const formMessage = ref("");
icon: "",
type: "",
message: "",
});
const filterKeywords = ref(""); const filterKeywords = ref("");
const filterLocation = ref(""); const filterLocation = ref("");
const filterDateRange = ref(""); const filterDateRange = ref("");
/**
* Load page data.
*/
const handleLoad = async () => { const handleLoad = async () => {
formMessage.type = "error";
formMessage.icon = "alert-circle-outline";
formMessage.message = "";
events.value = [];
let query = {}; let query = {};
query["limit"] = 10; query["limit"] = 10;
@@ -111,11 +106,16 @@ const handleLoad = async () => {
dateRangeError.value = ""; dateRangeError.value = "";
} else { } else {
dateRangeError.value = "Invalid date range"; dateRangeError.value = "Invalid date range";
return;
} }
} else { } else {
dateRangeError.value = ""; dateRangeError.value = "";
} }
loading.value = true;
formMessage.value = "";
events = [];
if (Object.keys(query).length == 1 && Object.keys(query)[0] == "limit") { if (Object.keys(query).length == 1 && Object.keys(query)[0] == "limit") {
query["end_at"] = query["end_at"] =
">" + ">" +
@@ -127,10 +127,12 @@ const handleLoad = async () => {
params: query, params: query,
}) })
.then((result) => { .then((result) => {
if (result.data.events) { const data = result.data as EventCollection;
events.value = result.data.events;
events.value.forEach((item) => { if (data && data.events) {
events = data.events;
events.forEach((item) => {
item.start_at = new SMDate(item.start_at, { item.start_at = new SMDate(item.start_at, {
format: "yyyy-MM-dd HH:mm:ss", format: "yyyy-MM-dd HH:mm:ss",
utc: true, utc: true,
@@ -145,7 +147,7 @@ const handleLoad = async () => {
}) })
.catch((error) => { .catch((error) => {
if (error.status != 404) { if (error.status != 404) {
formMessage.message = formMessage.value =
error.response?.data?.message || error.response?.data?.message ||
"Could not load any events from the server."; "Could not load any events from the server.";
} }
@@ -156,7 +158,6 @@ const handleLoad = async () => {
}; };
const handleFilter = async () => { const handleFilter = async () => {
loading.value = true;
handleLoad(); handleLoad();
}; };
@@ -188,7 +189,7 @@ handleLoad();
} }
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
.workshop-list .toolbar { .sm-workshop-list .toolbar {
flex-direction: column; flex-direction: column;
& > * { & > * {

View File

@@ -1,21 +1,25 @@
<template> <template>
<SMPage :full="true" :loading="imageUrl.length == 0" class="workshop-view"> <SMPage
:full="true"
:loading="imageUrl.length == 0"
class="sm-workshop-view"
:error="pageError">
<div <div
class="workshop-image" class="sm-workshop-image"
:style="{ backgroundImage: `url('${imageUrl}')` }"></div> :style="{ backgroundImage: `url('${imageUrl}')` }"></div>
<SMContainer> <SMContainer>
<SMMessage <SMMessage
v-if="formMessage.message" v-if="formMessage"
:icon="formMessage.icon" icon="alert-circle-outline"
:type="formMessage.type" type="error"
:message="formMessage.message" :message="formMessage"
class="mt-5" /> class="mt-5" />
<SMContainer class="workshop-page"> <SMContainer class="sm-workshop-page">
<div class="workshop-body"> <div class="sm-workshop-body">
<h2 class="workshop-title">{{ event.title }}</h2> <h2 class="sm-workshop-title">{{ event.title }}</h2>
<SMHTML :html="event.content" class="workshop-content" /> <SMHTML :html="event.content" class="sm-workshop-content" />
</div> </div>
<div class="workshop-info"> <div class="sm-workshop-info">
<div <div
v-if=" v-if="
event.status == 'closed' || event.status == 'closed' ||
@@ -24,17 +28,17 @@
format: 'ymd', format: 'ymd',
}).isBefore()) }).isBefore())
" "
class="workshop-registration workshop-registration-closed"> class="sm-workshop-registration sm-workshop-registration-closed">
Registration for this event has closed. Registration for this event has closed.
</div> </div>
<div <div
v-if="event.status == 'soon'" v-if="event.status == 'soon'"
class="workshop-registration workshop-registration-soon"> class="sm-workshop-registration sm-workshop-registration-soon">
Registration for this event will open soon. Registration for this event will open soon.
</div> </div>
<div <div
v-if="event.status == 'cancelled'" v-if="event.status == 'cancelled'"
class="workshop-registration workshop-registration-cancelled"> class="sm-workshop-registration sm-workshop-registration-cancelled">
This event has been cancelled. This event has been cancelled.
</div> </div>
<div <div
@@ -45,7 +49,7 @@
}).isAfter() && }).isAfter() &&
event.registration_type == 'none' event.registration_type == 'none'
" "
class="workshop-registration workshop-registration-none"> class="sm-workshop-registration sm-workshop-registration-none">
Registration not required for this event.<br />Arrive Registration not required for this event.<br />Arrive
early to avoid disappointment as seating maybe limited. early to avoid disappointment as seating maybe limited.
</div> </div>
@@ -57,12 +61,13 @@
}).isAfter() && }).isAfter() &&
event.registration_type != 'none' event.registration_type != 'none'
" "
class="workshop-registration workshop-registration-url"> class="sm-workshop-registration sm-workshop-registration-url">
<SMButton <SMButton
:href="registerUrl" :href="registerUrl"
:block="true"
label="Register for Event"></SMButton> label="Register for Event"></SMButton>
</div> </div>
<div class="workshop-date"> <div class="sm-workshop-date">
<h4><ion-icon name="calendar-outline" />Date / Time</h4> <h4><ion-icon name="calendar-outline" />Date / Time</h4>
<p <p
v-for="(line, index) in workshopDate" v-for="(line, index) in workshopDate"
@@ -71,7 +76,7 @@
{{ line }} {{ line }}
</p> </p>
</div> </div>
<div class="workshop-location"> <div class="sm-workshop-location">
<h4><ion-icon name="location-outline" />Location</h4> <h4><ion-icon name="location-outline" />Location</h4>
<p> <p>
{{ {{
@@ -88,27 +93,37 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, ref } from "vue"; import { computed, Ref, ref } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import SMButton from "../components/SMButton.vue"; import SMButton from "../components/SMButton.vue";
import SMHTML from "../components/SMHTML.vue"; import SMHTML from "../components/SMHTML.vue";
import SMMessage from "../components/SMMessage.vue"; import SMMessage from "../components/SMMessage.vue";
import { api } from "../helpers/api"; import { api } from "../helpers/api";
import { Event, EventResponse, MediaResponse } from "../helpers/api.types";
import { SMDate } from "../helpers/datetime"; import { SMDate } from "../helpers/datetime";
import { imageLoad } from "../helpers/image";
import { useApplicationStore } from "../store/ApplicationStore"; import { useApplicationStore } from "../store/ApplicationStore";
import { ApiEvent, ApiMedia } from "../helpers/api.types";
import { imageLoad } from "../helpers/image";
const applicationStore = useApplicationStore(); const applicationStore = useApplicationStore();
const event = ref({});
/**
* Event data
*/
const event: Ref<Event | null> = ref(null);
const imageUrl = ref(""); const imageUrl = ref("");
const route = useRoute(); const route = useRoute();
const formMessage = reactive({
icon: "", /**
type: "", * Page message.
message: "", */
}); const formMessage = ref("");
/**
* Page error.
*/
let pageError = 200;
const workshopDate = computed(() => { const workshopDate = computed(() => {
let str: string[] = []; let str: string[] = [];
@@ -166,23 +181,23 @@ const registerUrl = computed(() => {
return href; return href;
}); });
/**
* Load the page data.
*/
const handleLoad = async () => { const handleLoad = async () => {
formMessage.type = "error"; formMessage.value = "";
formMessage.icon = "alert-circle-outline";
formMessage.message = "";
api.get(`/events/${route.params.id}`) api.get({
url: "/events/{event}",
params: {
event: route.params.id,
},
})
.then((result) => { .then((result) => {
event.value = const eventData = result.data as EventResponse;
result.data &&
(result.data as ApiEvent).event &&
Object.keys((result.data as ApiEvent).event).length > 0
? (result.data as ApiEvent).event
: {};
if (event.value) {
// event.value = result.data.event as ApiEventItem;
if (eventData && eventData.event) {
event.value = eventData.event;
event.value.start_at = new SMDate(event.value.start_at, { event.value.start_at = new SMDate(event.value.start_at, {
format: "ymd", format: "ymd",
utc: true, utc: true,
@@ -195,53 +210,46 @@ const handleLoad = async () => {
applicationStore.setDynamicTitle(event.value.title); applicationStore.setDynamicTitle(event.value.title);
handleLoadImage(); handleLoadImage();
} else { } else {
formMessage.message = pageError = 404;
"Could not load event information from the server.";
} }
}) })
.catch((error) => { .catch((error) => {
formMessage.message = formMessage.value =
error.data?.message || error.data?.message ||
"Could not load event information from the server."; "Could not load event information from the server.";
}); });
// try {
// const result = await api.get(`/events/${route.params.id}`);
// event.value = result.data.event as ApiEventItem;
// event.value.start_at = timestampUtcToLocal(event.value.start_at);
// event.value.end_at = timestampUtcToLocal(event.value.end_at);
// applicationStore.setDynamicTitle(event.value.title);
// handleLoadImage();
// } catch (error) {
// formMessage.message =
// error.data?.message ||
// "Could not load event information from the server.";
// }
}; };
/**
* Load the hero image.
*/
const handleLoadImage = async () => { const handleLoadImage = async () => {
try { api.get({
const result = await api.get(`/media/${event.value.hero}`); url: "/media/{medium}",
const data = result.data as ApiMedia; params: {
medium: event.value.hero,
},
})
.then((result) => {
const data = result.data as MediaResponse;
if (data && data.medium) { if (data && data.medium) {
imageLoad(data.medium.url, (url) => { imageLoad(data.medium.url, (url) => {
imageUrl.value = url; imageUrl.value = url;
}); });
} }
} catch (error) { })
/* empty */ .catch(() => {
} /* empty */
});
}; };
handleLoad(); handleLoad();
</script> </script>
<style lang="scss"> <style lang="scss">
.workshop-view { .sm-workshop-view {
.workshop-image { .sm-workshop-image {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@@ -253,27 +261,27 @@ handleLoad();
background-color: #eee; background-color: #eee;
transition: background-image 0.2s; transition: background-image 0.2s;
.workshop-image-loader { .sm-workshop-image-loader {
font-size: 5rem; font-size: 5rem;
color: $secondary-color; color: $secondary-color;
} }
} }
.workshop-page { .sm-workshop-page {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
.workshop-body, .sm-workshop-body,
.workshop-info { .sm-workshop-info {
line-height: 1.5rem; line-height: 1.5rem;
} }
.workshop-body { .sm-workshop-body {
flex: 1; flex: 1;
text-align: left; text-align: left;
} }
.workshop-info { .sm-workshop-info {
width: 18rem; width: 18rem;
margin-left: 2rem; margin-left: 2rem;
@@ -296,17 +304,13 @@ handleLoad();
font-size: 90%; font-size: 90%;
} }
.workshop-registration { .sm-workshop-registration {
margin-top: 1.5rem; margin-top: 1.5rem;
line-height: 1.25rem; line-height: 1.25rem;
.button {
display: block;
}
} }
.workshop-registration-none, .sm-workshop-registration-none,
.workshop-registration-soon { .sm-workshop-registration-soon {
border: 1px solid #ffeeba; border: 1px solid #ffeeba;
background-color: #fff3cd; background-color: #fff3cd;
color: #856404; color: #856404;
@@ -315,8 +319,8 @@ handleLoad();
padding: 0.5rem; padding: 0.5rem;
} }
.workshop-registration-closed, .sm-workshop-registration-closed,
.workshop-registration-cancelled { .sm-workshop-registration-cancelled {
border: 1px solid #f5c2c7; border: 1px solid #f5c2c7;
background-color: #f8d7da; background-color: #f8d7da;
color: #842029; color: #842029;
@@ -325,8 +329,8 @@ handleLoad();
padding: 0.5rem; padding: 0.5rem;
} }
.workshop-date, .sm-workshop-date,
.workshop-location { .sm-workshop-location {
padding: 0 1rem; padding: 0 1rem;
} }
} }
@@ -334,14 +338,14 @@ handleLoad();
} }
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
.workshop-view .workshop-page { .sm-workshop-view .sm-workshop-page {
flex-direction: column; flex-direction: column;
.workshop-body { .sm-workshop-body {
text-align: center; text-align: center;
} }
.workshop-info { .sm-workshop-info {
width: 100%; width: 100%;
margin-left: 0; margin-left: 0;

View File

@@ -41,7 +41,7 @@
skills that they can use throughout their lives. skills that they can use throughout their lives.
</p> </p>
<SMButton <SMButton
:to="{ name: 'workshop-list' }" :to="{ name: 'event-list' }"
label="Explore Workshops" /> label="Explore Workshops" />
</SMColumn> </SMColumn>
<SMColumn <SMColumn
@@ -111,7 +111,7 @@
as well as updates on upcoming workshops. as well as updates on upcoming workshops.
</p> </p>
<SMDialog class="p-0" no-shadow> <SMDialog class="p-0" no-shadow>
<SMForm v-model="form" @submit.prevent="handleSubscribe"> <SMForm v-model="form" @submit="handleSubscribe">
<div class="form-row"> <div class="form-row">
<SMInput control="email" /> <SMInput control="email" />
<SMButton type="submit" label="Subscribe" /> <SMButton type="submit" label="Subscribe" />
@@ -133,6 +133,7 @@ import SMForm from "../components/SMForm.vue";
import SMInput from "../components/SMInput.vue"; import SMInput from "../components/SMInput.vue";
import { api } from "../helpers/api"; import { api } from "../helpers/api";
import { EventCollection, PostCollection } from "../helpers/api.types";
import { SMDate } from "../helpers/datetime"; import { SMDate } from "../helpers/datetime";
import { Form, FormControl } from "../helpers/form"; import { Form, FormControl } from "../helpers/form";
import { excerpt } from "../helpers/string"; import { excerpt } from "../helpers/string";
@@ -156,47 +157,55 @@ const handleLoad = async () => {
params: { params: {
limit: 3, limit: 3,
}, },
}).then((response) => { })
if (response.data.posts) { .then((result) => {
response.data.posts.forEach((post) => { const data = result.data as PostCollection;
posts.push({
title: post.title,
content: excerpt(post.content, 200),
image: post.hero,
url: { name: "post-view", params: { slug: post.slug } },
cta: "Read More...",
});
});
}
});
try { if (data && data.posts) {
let result = await api.get({ data.posts.forEach((post) => {
url: "/events", posts.push({
params: { title: post.title,
limit: 3, content: excerpt(post.content, 200),
end_at: image: post.hero,
">" + url: { name: "post-view", params: { slug: post.slug } },
new SMDate("now").format("yyyy-MM-dd HH:mm:ss", { cta: "Read More...",
utc: true, });
}), });
}, }
})
.catch(() => {
/* empty */
}); });
if (result.data.events) { api.get({
result.data.events.forEach((event) => { url: "/events",
events.push({ params: {
title: event.title, limit: 3,
content: excerpt(event.content, 200), end_at:
image: event.hero, ">" +
url: { name: "workshop-view", params: { id: event.id } }, new SMDate("now").format("yyyy-MM-dd HH:mm:ss", {
cta: "View Workshop", utc: true,
}),
},
})
.then((result) => {
const data = result.data as EventCollection;
if (data && data.events) {
data.events.forEach((event) => {
events.push({
title: event.title,
content: excerpt(event.content, 200),
image: event.hero,
url: { name: "event-view", params: { id: event.id } },
cta: "View Workshop",
});
}); });
}); }
} })
} catch (error) { .catch(() => {
/* empty */ /* empty */
} });
for (let i = 1; i <= Math.max(posts.length, events.length); i++) { for (let i = 1; i <= Math.max(posts.length, events.length); i++) {
if (i <= posts.length) { if (i <= posts.length) {
@@ -219,12 +228,12 @@ const handleSubscribe = async () => {
await api.post({ await api.post({
url: "/subscriptions", url: "/subscriptions",
body: { body: {
email: form.email.value, email: form.controls.email.value,
captcha_token: captcha, captcha_token: captcha,
}, },
}); });
form.email.value = ""; form.controls.email.value = "";
form.message("Your email address has been subscribed.", "success"); form.message("Your email address has been subscribed.", "success");
} catch (err) { } catch (err) {
form.apiErrors(err); form.apiErrors(err);

View File

@@ -57,6 +57,9 @@ const form = reactive(
const redirectQuery = useRoute().query.redirect; const redirectQuery = useRoute().query.redirect;
/**
* Handle the user submitting the login form.
*/
const handleSubmit = async () => { const handleSubmit = async () => {
form.message(); form.message();
form.loading(true); form.loading(true);
@@ -70,7 +73,7 @@ const handleSubmit = async () => {
}, },
}); });
const login = result.data as unknown as LoginResponse; const login = result.data as LoginResponse;
userStore.setUserDetails(login.user); userStore.setUserDetails(login.user);
userStore.setUserToken(login.token); userStore.setUserToken(login.token);
@@ -84,6 +87,7 @@ const handleSubmit = async () => {
router.push({ name: "dashboard" }); router.push({ name: "dashboard" });
} }
} catch (err) { } catch (err) {
form.controls.password.value = "";
form.apiErrors(err); form.apiErrors(err);
} finally { } finally {
form.loading(false); form.loading(false);

View File

@@ -1,49 +1,35 @@
<template> <template>
<SMPage no-breadcrumbs background="/img/background.jpg"> <SMPage no-breadcrumbs background="/img/background.jpg">
<SMRow> <SMLoader :loading="true" />
<SMDialog narrow class="mt-5" :loading="formLoading">
<h1>Logged out</h1>
<SMRow>
<SMColumn class="justify-content-center">
<p class="mt-0 text-center">
You have now been logged out
</p>
</SMColumn>
</SMRow>
<SMRow>
<SMColumn class="justify-content-center">
<SMButton :to="{ name: 'home' }" label="Home" />
</SMColumn>
</SMRow>
</SMDialog>
</SMRow>
</SMPage> </SMPage>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { useRouter } from "vue-router";
import SMLoader from "../components/SMLoader.vue";
import { api } from "../helpers/api"; import { api } from "../helpers/api";
import { useToastStore } from "../store/ToastStore";
import { useUserStore } from "../store/UserStore"; import { useUserStore } from "../store/UserStore";
import SMButton from "../components/SMButton.vue"; const router = useRouter();
import SMDialog from "../components/SMDialog.vue";
const userStore = useUserStore(); const userStore = useUserStore();
const formLoading = ref(false); const toastStore = useToastStore();
/**
* Logout the current user and redirect to home page.
*/
const logout = async () => { const logout = async () => {
formLoading.value = true; api.post({
url: "/logout",
try { }).finally(() => {
await api.post({ userStore.clearUser();
url: "/logout", toastStore.addToast({
title: "Logged Out",
content: "You have been logged out.",
type: "success",
}); });
} catch (err) { router.push({ name: "home" });
console.log(err); });
}
userStore.clearUser();
formLoading.value = false;
}; };
logout(); logout();

View File

@@ -1,42 +1,45 @@
<template> <template>
<SMContainer class="rules"> <SMPage class="sm-minecraft">
<h1>Connecting to our Minecraft Server</h1> <template #container>
<ol> <h1>Connecting to our Minecraft Server</h1>
<li> <ol>
Open up your Minecraft on your computer (Java) or tablet <li>
(Bedrock) and make sure you are using version 1.19.3 Open up your Minecraft on your computer (Java) or tablet
</li> (Bedrock) and make sure you are using version 1.19.3
<li>Click Multiplayer</li> </li>
<li>Click Add Server</li> <li>Click Multiplayer</li>
<li>Enter Server Name STEMMechanics</li> <li>Click Add Server</li>
<li>Enter Server Address mc.stemmech.com.au</li> <li>Enter Server Name STEMMechanics</li>
<li> <li>Enter Server Address mc.stemmech.com.au</li>
We have a custom resourcepack which you can enable before <li>
joining We have a custom resourcepack which you can enable before
</li> joining
<li>Click Done</li> </li>
<li>Join the Server!</li> <li>Click Done</li>
</ol> <li>Join the Server!</li>
<h2>Goodbye Drustcraft</h2> </ol>
<p> <h2>Goodbye Drustcraft</h2>
STEMMechanics launched the Drustcraft server three years ago and <p>
since then, players have had countless enjoyable experiences. Cities STEMMechanics launched the Drustcraft server three years ago and
were built, bosses defeated, and most importantly, a tight-knit since then, players have had countless enjoyable experiences.
community formed. Cities were built, bosses defeated, and most importantly, a
</p> tight-knit community formed.
<p> </p>
Maintaining the server design became overwhelming and took away the <p>
fun of playing Minecraft. Hence, in January, the decision was made Maintaining the server design became overwhelming and took away
to shut down Drustcraft and offer a more straightforward Minecraft the fun of playing Minecraft. Hence, in January, the decision
server, retaining the beloved elements of Drustcraft like was made to shut down Drustcraft and offer a more
mini-games, bosses, and survival. Join us on the new STEMMechanics straightforward Minecraft server, retaining the beloved elements
Minecraft server, where the Drustcraft community awaits. of Drustcraft like mini-games, bosses, and survival. Join us on
</p> the new STEMMechanics Minecraft server, where the Drustcraft
</SMContainer> community awaits.
</p>
</template>
</SMPage>
</template> </template>
<style lang="scss"> <style lang="scss">
.rules { .sm-minecraft {
h2 { h2 {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }

View File

@@ -1,166 +0,0 @@
<template>
<SMPage
:loading="pageLoading"
full
class="page-post-view"
:page-error="error">
<div
class="heading-image"
:style="{
backgroundImage: `url('${post.hero_url}')`,
}"></div>
<SMContainer>
<div class="heading-info">
<h1>{{ post.title }}</h1>
<div class="date-author">
<ion-icon name="calendar-outline" />
{{ formattedPublishAt(post.publish_at) }}, by
{{ post.user_username }}
</div>
</div>
<component :is="formattedContent" ref="content"></component>
<SMAttachments :attachments="post.attachments || []" />
</SMContainer>
</SMPage>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { useRoute } from "vue-router";
import { api } from "../helpers/api";
import { SMDate } from "../helpers/datetime";
import { useApplicationStore } from "../store/ApplicationStore";
import SMAttachments from "../components/SMAttachments.vue";
const applicationStore = useApplicationStore();
const route = useRoute();
let post = ref({});
let content = ref(null);
let error = ref(0);
let pageLoading = ref(true);
const loadData = async () => {
if (route.params.slug) {
try {
let res = await api.get({
url: "/posts",
params: {
slug: `=${route.params.slug}`,
limit: 1,
},
});
if (!res.data.posts) {
error.value = 500;
} else {
if (res.data.total == 0) {
error.value = 404;
} else {
post.value = res.data.posts[0];
post.value.publish_at = new SMDate(post.value.publish_at, {
format: "ymd",
utc: true,
}).format("yyyy/MM/dd HH:mm:ss");
applicationStore.setDynamicTitle(post.value.title);
try {
let result = await api.get({
url: `/media/${post.value.hero}`,
});
post.value.hero_url = result.data.medium.url;
} catch (error) {
/* empty */
}
try {
let result = await api.get({
url: `/users/${post.value.user_id}`,
});
post.value.user_username = result.data.user.username;
} catch (error) {
/* empty */
}
}
}
} catch (err) {
error.value = 500;
}
}
pageLoading.value = false;
};
const formattedPublishAt = (dateStr) => {
return new SMDate(dateStr, { format: "yMd" }).format("MMMM d, yyyy");
};
const formattedContent = computed(() => {
let html = post.value.content;
if (html) {
const regex = new RegExp(
`<a ([^>]*?)href="${import.meta.env.APP_URL}(.*?>.*?)</a>`,
"ig"
);
html = html.replace(regex, '<router-link $1to="$2</router-link>');
}
return {
template: `<div class="content">${html}</div>`,
};
});
loadData();
</script>
<style lang="scss">
.page-post-view {
.heading-image {
background-color: #eee;
background-position: center;
background-repeat: no-repeat;
background-size: cover;
height: 15rem;
}
.heading-info {
padding: 0 map-get($spacer, 3);
h1 {
text-align: left;
margin-bottom: 0.5rem;
text-overflow: ellipsis;
overflow: hidden;
word-wrap: break-word;
}
.date-author {
font-size: 80%;
svg {
margin-right: 0.5rem;
}
}
}
.content {
margin-top: map-get($spacer, 4);
padding: 0 map-get($spacer, 3);
a span {
color: $primary-color !important;
}
p {
line-height: 1.5rem;
}
}
}
@media only screen and (max-width: 768px) {
.page-post-view .heading-image {
height: 10rem;
}
}
</style>

View File

@@ -1,5 +1,5 @@
<template> <template>
<SMPage class="news-list"> <SMPage class="sm-post-list" :loading="pageLoading">
<template #container> <template #container>
<SMMessage <SMMessage
v-if="message" v-if="message"
@@ -8,8 +8,7 @@
:message="message" :message="message"
class="mt-5" /> class="mt-5" />
<SMPanelList <SMPanelList
:loading="loading" :not-found="!pageLoading && posts.length == 0"
:not-found="!loading && posts.length == 0"
not-found-text="No news found"> not-found-text="No news found">
<SMPanel <SMPanel
v-for="post in posts" v-for="post in posts"
@@ -23,37 +22,51 @@
button="Read More" button="Read More"
button-type="outline" /> button-type="outline" />
</SMPanelList> </SMPanelList>
<SMPagination
v-model="postsPage"
:total="postsTotal"
:per-page="postsPerPage" />
</template> </template>
</SMPage> </SMPage>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Ref, ref } from "vue"; import { Ref, ref, watch } from "vue";
import SMMessage from "../components/SMMessage.vue"; import SMMessage from "../components/SMMessage.vue";
import SMPagination from "../components/SMPagination.vue";
import SMPanel from "../components/SMPanel.vue"; import SMPanel from "../components/SMPanel.vue";
import SMPanelList from "../components/SMPanelList.vue"; import SMPanelList from "../components/SMPanelList.vue";
import { api } from "../helpers/api"; import { api } from "../helpers/api";
import { Post, PostCollection } from "../helpers/api.types"; import { Post, PostCollection } from "../helpers/api.types";
import { SMDate } from "../helpers/datetime"; import { SMDate } from "../helpers/datetime";
const message = ref(""); const message = ref("");
const loading = ref(true); const pageLoading = ref(true);
const posts: Ref<Post[]> = ref([]); const posts: Ref<Post[]> = ref([]);
const handleLoad = async () => { const postsPerPage = 9;
let postsPage = ref(1);
let postsTotal = ref(0);
/**
* Load the page data.
*/
const handleLoad = () => {
message.value = ""; message.value = "";
pageLoading.value = true;
api.get({ api.get({
url: "/posts", url: "/posts",
params: { params: {
limit: 5, limit: postsPerPage,
page: postsPage.value,
}, },
}) })
.then((result) => { .then((result) => {
const data = result.data as PostCollection; const data = result.data as PostCollection;
posts.value = data.posts; posts.value = data.posts;
postsTotal.value = data.total;
posts.value.forEach((post) => { posts.value.forEach((post) => {
post.publish_at = new SMDate(post.publish_at, { post.publish_at = new SMDate(post.publish_at, {
format: "ymd", format: "ymd",
@@ -62,13 +75,23 @@ const handleLoad = async () => {
}); });
}) })
.catch((error) => { .catch((error) => {
message.value = if (error.status != 404) {
error.data?.message || "The server is currently not available"; message.value =
error.data?.message ||
"The server is currently not available";
}
}) })
.finally(() => { .finally(() => {
loading.value = false; pageLoading.value = false;
}); });
}; };
watch(
() => postsPage.value,
() => {
handleLoad();
}
);
handleLoad(); handleLoad();
</script> </script>

View File

@@ -0,0 +1,180 @@
<template>
<SMPage
:loading="pageLoading"
full
class="sm-page-post-view"
:page-error="pageError">
<div class="sm-heading-image" :style="styleObject"></div>
<SMContainer>
<div class="sm-heading-info">
<h1>{{ post.title }}</h1>
<div class="sm-date-author">
<ion-icon name="calendar-outline" />
{{ formattedPublishAt(post.publish_at) }}, by
{{ postUser.username }}
</div>
</div>
<SMHTML :html="post.content" />
<SMAttachments :attachments="post.attachments || []" />
</SMContainer>
</SMPage>
</template>
<script setup lang="ts">
import { ref, Ref } from "vue";
import { useRoute } from "vue-router";
import SMAttachments from "../components/SMAttachments.vue";
import SMHTML from "../components/SMHTML.vue";
import { api } from "../helpers/api";
import {
MediaResponse,
Post,
PostCollection,
User,
UserResponse,
} from "../helpers/api.types";
import { SMDate } from "../helpers/datetime";
import { useApplicationStore } from "../store/ApplicationStore";
const applicationStore = useApplicationStore();
/**
* The post data.
*/
let post: Ref<Post> = ref(null);
/**
* The current page error.
*/
let pageError = ref(200);
/**
* Is the page loading.
*/
let pageLoading = ref(false);
/**
* Post styles.
*/
let styleObject = {};
/**
* Post user.
*/
let postUser: User | null = null;
const loadData = () => {
let slug = useRoute().params.slug || "";
if (slug.length > 0) {
pageLoading.value = true;
api.get({
url: "/posts/",
params: {
slug: `=${slug}`,
limit: 1,
},
}).then((result) => {
const data = result.data as PostCollection;
if (data && data.posts && data.total && data.total > 0) {
post.value = data.posts[0];
post.value.publish_at = new SMDate(post.value.publish_at, {
format: "ymd",
utc: true,
}).format("yyyy/MM/dd HH:mm:ss");
applicationStore.setDynamicTitle(post.value.title);
// Get hero image
api.get({
url: "/media/{medium}",
params: {
medium: post.value.hero,
},
})
.then((mediumResult) => {
const mediumData = mediumResult.data as MediaResponse;
if (mediumData && mediumData.medium) {
styleObject[
"backgroundImage"
] = `url('${mediumData.medium.url}')`;
}
})
.catch(() => {
/* empty */
});
// Get user data
api.get({
url: "/users/{id}",
params: {
id: post.value.user_id,
},
})
.then((userResult) => {
const userData = userResult.data as UserResponse;
if (userData && userData.user) {
postUser = userData.user;
}
})
.catch(() => {
/* empty */
});
} else {
pageError.value = 404;
}
});
} else {
pageError.value = 404;
}
};
const formattedPublishAt = (dateStr) => {
return new SMDate(dateStr, { format: "yMd" }).format("MMMM d, yyyy");
};
loadData();
</script>
<style lang="scss">
.sm-page-post-view {
.sm-heading-image {
background-color: #eee;
background-position: center;
background-repeat: no-repeat;
background-size: cover;
height: 15rem;
}
.sm-heading-info {
padding: 0 map-get($spacer, 3);
h1 {
text-align: left;
margin-bottom: 0.5rem;
text-overflow: ellipsis;
overflow: hidden;
word-wrap: break-word;
}
.date-author {
font-size: 80%;
svg {
margin-right: 0.5rem;
}
}
}
}
@media only screen and (max-width: 768px) {
.sm-page-post-view .sm-heading-image {
height: 10rem;
}
}
</style>

View File

@@ -1,5 +1,5 @@
<template> <template>
<SMContainer class="privacy"> <SMContainer class="sm-privacy">
<h1>Privacy Policy</h1> <h1>Privacy Policy</h1>
<h3>We take our customers' privacy & security seriously.</h3> <h3>We take our customers' privacy & security seriously.</h3>
<p> <p>
@@ -322,7 +322,7 @@
</template> </template>
<style lang="scss"> <style lang="scss">
.privacy { .sm-privacy {
h4 { h4 {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }

View File

@@ -71,12 +71,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref } from "vue"; import { reactive, ref } from "vue";
import { useReCaptcha } from "vue-recaptcha-v3";
import SMButton from "../components/SMButton.vue"; import SMButton from "../components/SMButton.vue";
import SMDialog from "../components/SMDialog.vue"; import SMDialog from "../components/SMDialog.vue";
import SMForm from "../components/SMForm.vue"; import SMForm from "../components/SMForm.vue";
import SMFormFooter from "../components/SMFormFooter.vue"; import SMFormFooter from "../components/SMFormFooter.vue";
import SMInput from "../components/SMInput.vue"; import SMInput from "../components/SMInput.vue";
import { api } from "../helpers/api"; import { api } from "../helpers/api";
import { Form, FormControl } from "../helpers/form"; import { Form, FormControl } from "../helpers/form";
import { import {
@@ -89,14 +89,11 @@ import {
Required, Required,
} from "../helpers/validate"; } from "../helpers/validate";
import { useReCaptcha } from "vue-recaptcha-v3";
const { executeRecaptcha, recaptchaLoaded } = useReCaptcha(); const { executeRecaptcha, recaptchaLoaded } = useReCaptcha();
let abortController: AbortController | null = null; let abortController: AbortController | null = null;
const checkUsername = async (value: string): boolean | string => { const checkUsername = (value: string): boolean | string => {
if (lastUsernameCheck.value != value) { if (lastUsernameCheck.value != value) {
console.log("api-get");
lastUsernameCheck.value = value; lastUsernameCheck.value = value;
if (abortController != null) { if (abortController != null) {
@@ -106,14 +103,13 @@ const checkUsername = async (value: string): boolean | string => {
abortController = new AbortController(); abortController = new AbortController();
let x = await api api.get({
.get({ url: "/users",
url: "/users", params: {
params: { username: value,
username: value, },
}, signal: abortController.signal,
signal: abortController.signal, })
})
.then((response) => { .then((response) => {
console.log("The username has already been taken.", response); console.log("The username has already been taken.", response);
return "The username has already been taken."; return "The username has already been taken.";
@@ -129,15 +125,13 @@ const checkUsername = async (value: string): boolean | string => {
return true; return true;
}); });
return x;
} }
console.log("here");
return true; return true;
}; };
const formDone = ref(false); const formDone = ref(false);
const lastUsernameCheck = ref("");
const form = reactive( const form = reactive(
Form({ Form({
first_name: FormControl("", Required()), first_name: FormControl("", Required()),
@@ -159,36 +153,21 @@ const handleSubmit = async () => {
await api.post({ await api.post({
url: "/register", url: "/register",
body: { body: {
first_name: form.first_name.value, first_name: form.controls.first_name.value,
last_name: form.last_name.value, last_name: form.controls.last_name.value,
email: form.email.value, email: form.controls.email.value,
phone: form.phone.value, phone: form.controls.phone.value,
username: form.username.value, username: form.controls.username.value,
password: form.password.value, password: form.controls.password.value,
captcha_token: captcha, captcha_token: captcha,
}, },
}); });
formDone.value = true; formDone.value = true;
} catch (err) { } catch (error) {
form.apiErrors(err); form.apiErrors(error);
} finally {
form.loading(false);
} }
form.loading(false);
}; };
const lastUsernameCheck = ref("");
// const debouncedFilter = debounce(checkUsername, 1000);
// let oldUsernameValue = "";
// watch(
// form,
// (value) => {
// if (value.username.value !== oldUsernameValue) {
// oldUsernameValue = value.username.value;
// // debouncedFilter(lastUsernameCheck.value);
// }
// },
// { deep: true }
// );
</script> </script>

View File

@@ -50,7 +50,6 @@ import SMDialog from "../components/SMDialog.vue";
import SMForm from "../components/SMForm.vue"; import SMForm from "../components/SMForm.vue";
import SMFormFooter from "../components/SMFormFooter.vue"; import SMFormFooter from "../components/SMFormFooter.vue";
import SMInput from "../components/SMInput.vue"; import SMInput from "../components/SMInput.vue";
import { api } from "../helpers/api"; import { api } from "../helpers/api";
import { Form, FormControl } from "../helpers/form"; import { Form, FormControl } from "../helpers/form";
import { Required } from "../helpers/validate"; import { Required } from "../helpers/validate";
@@ -73,7 +72,7 @@ const handleSubmit = async () => {
await api.post({ await api.post({
url: "/users/resendVerifyEmailCode", url: "/users/resendVerifyEmailCode",
body: { body: {
username: form.username.value, username: form.controls.username.value,
captcha_token: captcha, captcha_token: captcha,
}, },
}); });
@@ -85,8 +84,8 @@ const handleSubmit = async () => {
} else { } else {
form.apiErrors(error); form.apiErrors(error);
} }
} finally {
form.loading(false);
} }
form.loading(false);
}; };
</script> </script>

View File

@@ -50,7 +50,6 @@ import SMDialog from "../components/SMDialog.vue";
import SMForm from "../components/SMForm.vue"; import SMForm from "../components/SMForm.vue";
import SMFormFooter from "../components/SMFormFooter.vue"; import SMFormFooter from "../components/SMFormFooter.vue";
import SMInput from "../components/SMInput.vue"; import SMInput from "../components/SMInput.vue";
import { api } from "../helpers/api"; import { api } from "../helpers/api";
import { Form, FormControl } from "../helpers/form"; import { Form, FormControl } from "../helpers/form";
import { And, Max, Min, Password, Required } from "../helpers/validate"; import { And, Max, Min, Password, Required } from "../helpers/validate";
@@ -65,7 +64,12 @@ const form = reactive(
); );
if (useRoute().query.code !== undefined) { if (useRoute().query.code !== undefined) {
form.code.value = useRoute().query.code; let queryCode = useRoute().query.code;
if (Array.isArray(queryCode)) {
queryCode = queryCode[0];
}
form.controls.code.value = queryCode;
} }
const handleSubmit = async () => { const handleSubmit = async () => {
@@ -78,17 +82,17 @@ const handleSubmit = async () => {
await api.post({ await api.post({
url: "/users/resetPassword", url: "/users/resetPassword",
body: { body: {
code: form.code.value, code: form.controls.code.value,
password: form.password.value, password: form.controls.password.value,
captcha_token: captcha, captcha_token: captcha,
}, },
}); });
formDone.value = true; formDone.value = true;
} catch (error) { } catch (error) {
form.apiError(error); form.apiErrors(error);
} finally {
form.loading(false);
} }
form.loading(false);
}; };
</script> </script>

View File

@@ -1,5 +1,5 @@
<template> <template>
<SMPage class="rules"> <SMPage class="sm-rules">
<h1>Rules</h1> <h1>Rules</h1>
<p> <p>
Oh gosh, no body likes rules but to ensure that we have a fun, Oh gosh, no body likes rules but to ensure that we have a fun,
@@ -78,7 +78,7 @@
<script setup lang="ts"></script> <script setup lang="ts"></script>
<style lang="scss"> <style lang="scss">
.rules { .sm-rules {
h2 { h2 {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }

View File

@@ -1,5 +1,5 @@
<template> <template>
<SMPage class="terms"> <SMPage class="sm-terms">
<h1>Terms and Conditions</h1> <h1>Terms and Conditions</h1>
<p> <p>
Please read these terms carefully. By accessing or using our website Please read these terms carefully. By accessing or using our website
@@ -562,5 +562,3 @@
</p> </p>
</SMPage> </SMPage>
</template> </template>
<script setup lang="ts"></script>

View File

@@ -65,7 +65,7 @@ const handleSubmit = async () => {
await api.delete({ await api.delete({
url: "/subscriptions", url: "/subscriptions",
body: { body: {
email: form.email.value, email: form.controls.email.value,
captcha_token: captcha, captcha_token: captcha,
}, },
}); });
@@ -73,13 +73,18 @@ const handleSubmit = async () => {
formDone.value = true; formDone.value = true;
} catch (error) { } catch (error) {
form.apiErrors(error); form.apiErrors(error);
} finally {
form.loading(false);
} }
form.loading(false);
}; };
if (useRoute().query.email !== undefined) { if (useRoute().query.email !== undefined) {
form.email.value = useRoute().query.email; let queryEmail = useRoute().query.email;
if (Array.isArray(queryEmail)) {
queryEmail = queryEmail[0];
}
form.controls.email.value = queryEmail;
handleSubmit(); handleSubmit();
} }
</script> </script>

View File

@@ -9,42 +9,42 @@
</router-link> </router-link>
<router-link <router-link
v-if="userStore.permissions.includes('admin/posts')" v-if="userStore.permissions.includes('admin/posts')"
to="/dashboard/posts" :to="{ name: 'dashboard-post-list' }"
class="box"> class="box">
<ion-icon name="newspaper-outline" /> <ion-icon name="newspaper-outline" />
<h2>Posts</h2> <h2>Posts</h2>
</router-link> </router-link>
<router-link <router-link
v-if="userStore.permissions.includes('admin/users')" v-if="userStore.permissions.includes('admin/users')"
:to="{ name: 'user-list' }" :to="{ name: 'dashboard-user-list' }"
class="box"> class="box">
<ion-icon name="people-outline" /> <ion-icon name="people-outline" />
<h2>Users</h2> <h2>Users</h2>
</router-link> </router-link>
<router-link <router-link
v-if="userStore.permissions.includes('admin/events')" v-if="userStore.permissions.includes('admin/events')"
to="/dashboard/events" :to="{ name: 'dashboard-event-list' }"
class="box"> class="box">
<ion-icon name="calendar-outline" /> <ion-icon name="calendar-outline" />
<h2>Events</h2> <h2>Events</h2>
</router-link> </router-link>
<router-link <router-link
v-if="userStore.permissions.includes('admin/courses')" v-if="userStore.permissions.includes('admin/courses')"
to="/dashboard/courses" :to="{ name: 'dashboard-course-list' }"
class="box"> class="box">
<ion-icon name="school-outline" /> <ion-icon name="school-outline" />
<h2>{{ courseBoxTitle }}</h2> <h2>{{ courseBoxTitle }}</h2>
</router-link> </router-link>
<router-link <router-link
v-if="userStore.permissions.includes('admin/media')" v-if="userStore.permissions.includes('admin/media')"
to="/dashboard/media" :to="{ name: 'dashboard-media-list' }"
class="box"> class="box">
<ion-icon name="film-outline" /> <ion-icon name="film-outline" />
<h2>Media</h2> <h2>Media</h2>
</router-link> </router-link>
<router-link <router-link
v-if="userStore.permissions.includes('logs/discord')" v-if="userStore.permissions.includes('logs/discord')"
:to="{ name: 'discord-bot-logs' }" :to="{ name: 'dashboard-discord-bot-logs' }"
class="box"> class="box">
<ion-icon name="logo-discord" /> <ion-icon name="logo-discord" />
<h2>Discord Bot Logs</h2> <h2>Discord Bot Logs</h2>