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: {
APP_URL: string;
[key: string]: string;

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,7 +23,7 @@
</template>
</ul>
<SMButton
:to="{ name: 'workshop-list' }"
:to="{ name: 'event-list' }"
class="sm-navbar-cta"
label="Find a workshop"
icon="arrow-forward-outline" />
@@ -70,13 +70,13 @@ const menuItems = [
{
name: "news",
label: "News",
to: { name: "news" },
to: { name: "post-list" },
icon: "newspaper-outline",
},
{
name: "workshops",
label: "Workshops",
to: { name: "workshop-list" },
to: { name: "event-list" },
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 }}
</div>
<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>
</router-link>
@@ -288,5 +292,9 @@ watch(
line-height: 130%;
flex: 1;
}
.sm-panel-button {
margin-top: map-get($spacer, 4);
}
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -57,6 +57,9 @@ const form = reactive(
const redirectQuery = useRoute().query.redirect;
/**
* Handle the user submitting the login form.
*/
const handleSubmit = async () => {
form.message();
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.setUserToken(login.token);
@@ -84,6 +87,7 @@ const handleSubmit = async () => {
router.push({ name: "dashboard" });
}
} catch (err) {
form.controls.password.value = "";
form.apiErrors(err);
} finally {
form.loading(false);

View File

@@ -1,49 +1,35 @@
<template>
<SMPage no-breadcrumbs background="/img/background.jpg">
<SMRow>
<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>
<SMLoader :loading="true" />
</SMPage>
</template>
<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 { useToastStore } from "../store/ToastStore";
import { useUserStore } from "../store/UserStore";
import SMButton from "../components/SMButton.vue";
import SMDialog from "../components/SMDialog.vue";
const router = useRouter();
const userStore = useUserStore();
const formLoading = ref(false);
const toastStore = useToastStore();
/**
* Logout the current user and redirect to home page.
*/
const logout = async () => {
formLoading.value = true;
try {
await api.post({
api.post({
url: "/logout",
});
} catch (err) {
console.log(err);
}
}).finally(() => {
userStore.clearUser();
formLoading.value = false;
toastStore.addToast({
title: "Logged Out",
content: "You have been logged out.",
type: "success",
});
router.push({ name: "home" });
});
};
logout();

View File

@@ -1,5 +1,6 @@
<template>
<SMContainer class="rules">
<SMPage class="sm-minecraft">
<template #container>
<h1>Connecting to our Minecraft Server</h1>
<ol>
<li>
@@ -20,23 +21,25 @@
<h2>Goodbye Drustcraft</h2>
<p>
STEMMechanics launched the Drustcraft server three years ago and
since then, players have had countless enjoyable experiences. Cities
were built, bosses defeated, and most importantly, a tight-knit
community formed.
since then, players have had countless enjoyable experiences.
Cities were built, bosses defeated, and most importantly, a
tight-knit community formed.
</p>
<p>
Maintaining the server design became overwhelming and took away the
fun of playing Minecraft. Hence, in January, the decision was made
to shut down Drustcraft and offer a more straightforward Minecraft
server, retaining the beloved elements of Drustcraft like
mini-games, bosses, and survival. Join us on the new STEMMechanics
Minecraft server, where the Drustcraft community awaits.
Maintaining the server design became overwhelming and took away
the fun of playing Minecraft. Hence, in January, the decision
was made to shut down Drustcraft and offer a more
straightforward Minecraft server, retaining the beloved elements
of Drustcraft like mini-games, bosses, and survival. Join us on
the new STEMMechanics Minecraft server, where the Drustcraft
community awaits.
</p>
</SMContainer>
</template>
</SMPage>
</template>
<style lang="scss">
.rules {
.sm-minecraft {
h2 {
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>
<SMPage class="news-list">
<SMPage class="sm-post-list" :loading="pageLoading">
<template #container>
<SMMessage
v-if="message"
@@ -8,8 +8,7 @@
:message="message"
class="mt-5" />
<SMPanelList
:loading="loading"
:not-found="!loading && posts.length == 0"
:not-found="!pageLoading && posts.length == 0"
not-found-text="No news found">
<SMPanel
v-for="post in posts"
@@ -23,37 +22,51 @@
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 } from "vue";
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";
const message = ref("");
const loading = ref(true);
const pageLoading = ref(true);
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 = "";
pageLoading.value = true;
api.get({
url: "/posts",
params: {
limit: 5,
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",
@@ -62,13 +75,23 @@ const handleLoad = async () => {
});
})
.catch((error) => {
if (error.status != 404) {
message.value =
error.data?.message || "The server is currently not available";
error.data?.message ||
"The server is currently not available";
}
})
.finally(() => {
loading.value = false;
pageLoading.value = false;
});
};
watch(
() => postsPage.value,
() => {
handleLoad();
}
);
handleLoad();
</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>
<SMContainer class="privacy">
<SMContainer class="sm-privacy">
<h1>Privacy Policy</h1>
<h3>We take our customers' privacy & security seriously.</h3>
<p>
@@ -322,7 +322,7 @@
</template>
<style lang="scss">
.privacy {
.sm-privacy {
h4 {
margin-bottom: 0.5rem;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -65,7 +65,7 @@ const handleSubmit = async () => {
await api.delete({
url: "/subscriptions",
body: {
email: form.email.value,
email: form.controls.email.value,
captcha_token: captcha,
},
});
@@ -73,13 +73,18 @@ const handleSubmit = async () => {
formDone.value = true;
} catch (error) {
form.apiErrors(error);
}
} finally {
form.loading(false);
}
};
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();
}
</script>

View File

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