This commit is contained in:
2023-11-14 09:40:58 +10:00
parent 7d9b6793d3
commit 6f53c7ea6f
51 changed files with 110 additions and 5546 deletions

1
.gitignore vendored
View File

@@ -17,3 +17,4 @@ yarn-error.log
/.fleet
/.idea
/.vscode
.DS_Store

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('password_reset_tokens');
}
};

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

3
resources/css/app.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

1
resources/js/app.js Normal file
View File

@@ -0,0 +1 @@
import './bootstrap';

View File

@@ -1,345 +0,0 @@
<template>
<SMPageStatus v-if="!userHasPermission('admin/articles')" :status="403" />
<template v-else>
<SMMastHead
:title="pageHeading"
:back-link="{ name: 'dashboard-article-list' }"
back-title="Back to Articles" />
<SMLoading v-if="form.loading()" />
<div v-else class="max-w-4xl mx-auto px-4 mt-8">
<SMForm
:model-value="form"
@submit="handleSubmit"
@failed-validation="handleFailValidation">
<div>
<SMInput
class="mb-4"
control="title"
autofocus
@blur="updateSlug()" />
</div>
<div class="flex flex-col md:flex-row gap-4">
<SMInput class="mb-4" control="slug" />
<SMInput
class="mb-4"
type="datetime"
control="publish_at"
label="Publish Date" />
</div>
<div>
<SMSelectFile
class="mb-4"
control="hero"
label="Hero image"
required
allow-upload />
</div>
<div>
<SMDropdown
class="mb-4"
control="user_id"
label="Created By"
type="select"
:options="authors" />
</div>
<div>
<SMEditor
class="mb-4"
v-model:model-value="form.controls.content.value" />
</div>
<h2 class="mt-8">Gallery</h2>
<p class="small">
{{ gallery.length }} image{{
gallery.length != 1 ? "s" : ""
}}
</p>
<SMImageGallery show-editor v-model:model-value="gallery" />
<SMAttachments
class="mb-4"
show-editor
v-model:model-value="attachments" />
<div class="flex flex-justify-end">
<input
type="submit"
class="font-medium px-6 py-3.1 rounded-2 hover:shadow-md text-lg transition bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
value="Save" />
</div>
</SMForm>
</div>
</template>
</template>
<script setup lang="ts">
import { reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import SMEditor from "../../components/SMEditor.vue";
import SMForm from "../../components/SMForm.vue";
import SMInput from "../../components/SMInput.vue";
import SMDropdown from "../../components/SMDropdown.vue";
import SMAttachments from "../../components/SMAttachments.vue";
import { api } from "../../helpers/api";
import {
ArticleResponse,
Media,
UserCollection,
} from "../../helpers/api.types";
import { SMDate } from "../../helpers/datetime";
import { Form, FormControl } from "../../helpers/form";
import { And, DateTime, Min, Required } from "../../helpers/validate";
import { useToastStore } from "../../store/ToastStore";
import { useUserStore } from "../../store/UserStore";
import SMMastHead from "../../components/SMMastHead.vue";
import SMPageStatus from "../../components/SMPageStatus.vue";
import { userHasPermission } from "../../helpers/utils";
import SMSelectFile from "../../components/SMSelectFile.vue";
import SMLoading from "../../components/SMLoading.vue";
import SMImageGallery from "../../components/SMImageGallery.vue";
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
let pageError = ref(200);
const authors = ref({});
const attachments = ref([]);
const pageHeading = route.params.id ? "Edit Article" : "Create Article";
const gallery = ref([]);
const form = reactive(
Form({
title: FormControl("", And([Required(), Min(8)])),
slug: FormControl("", And([Required(), Min(6)])),
publish_at: FormControl(
route.params.id ? "" : new SMDate("now").format("d/M/yy h:mm aa"),
DateTime(),
),
hero: FormControl("", Required()),
user_id: FormControl(userStore.id),
content: FormControl(),
}),
);
const updateSlug = async () => {
if (form.controls.slug.value == "" && form.controls.title.value != "") {
let idx = 0;
let pre_slug = (form.controls.title.value as string)
.toLowerCase()
.replace(/[^a-z0-9 ]/gim, "")
.replace(/ +/g, "-")
.replace(/-+/g, "-")
.replace(/^-*(.+?)-*$/, "$1");
// eslint-disable-next-line no-constant-condition
while (true) {
let slug = pre_slug;
try {
if (idx > 1) {
slug += "-" + idx;
}
await api.get({
url: "/articles",
params: {
slug: slug,
},
});
idx++;
} catch (error) {
if (error.status == 404) {
if (form.controls.slug.value == "") {
form.controls.slug.value = slug;
}
}
return;
}
}
}
};
/**
* Load the page data.
*/
const loadData = async () => {
try {
if (route.params.id) {
form.loading(true);
let result = await api.get({
url: "/articles/{id}",
params: {
id: route.params.id,
},
});
const data = result.data as ArticleResponse;
if (data && data.article) {
form.controls.title.value = data.article.title;
form.controls.slug.value = data.article.slug;
form.controls.user_id.value = data.article.user.id;
form.controls.content.value = data.article.content;
form.controls.publish_at.value = data.article.publish_at
? new SMDate(data.article.publish_at, {
format: "yMd",
utc: true,
}).format("dd/MM/yyyy HH:mm")
: "";
form.controls.content.value = data.article.content;
form.controls.hero.value = data.article.hero;
attachments.value = data.article.attachments;
gallery.value = data.article.gallery;
} else {
pageError.value = 404;
}
}
} catch (error) {
pageError.value = error.status;
} finally {
form.loading(false);
}
};
const handleSubmit = async (enableFormCallBack) => {
try {
let data = {
title: form.controls.title.value,
slug: form.controls.slug.value,
publish_at: new SMDate(
form.controls.publish_at.value as string,
).format("yyyy/MM/dd HH:mm:ss", { utc: true }),
user_id: form.controls.user_id.value,
content: form.controls.content.value,
hero: (form.controls.hero.value as Media).id,
gallery: gallery.value.map((item) => item.id),
attachments: attachments.value.map((item) => item.id),
};
if (route.params.id) {
await api.put({
url: `/articles/{id}`,
params: {
id: route.params.id,
},
body: data,
});
} else {
await api.post({
url: "/articles",
body: data,
});
}
useToastStore().addToast({
title: route.params.id ? "Article Updated" : "Article Created",
content: route.params.id
? "The article has been updated."
: "The article has been created.",
type: "success",
});
const urlParams = new URLSearchParams(window.location.search);
const returnUrl = urlParams.get("return");
if (returnUrl) {
router.push(decodeURIComponent(returnUrl));
} else {
router.push({ name: "dashboard-article-list" });
}
} catch (error) {
console.log(error);
form.apiErrors(error, (message) => {
useToastStore().addToast({
title: "An error occurred",
content: message,
type: "danger",
});
});
enableFormCallBack();
}
};
const handleFailValidation = () => {
useToastStore().addToast({
title: "Save Error",
content:
"There are some errors in the form. Fix these before continuing.",
type: "danger",
});
};
const createStorageKey = (file) => {
var date = new Date();
var day = date.toISOString().slice(0, 10);
var name = date.getTime() + "-" + file.name;
return ["tmp", day, name].join("/");
};
const attachmentAdd = async (event) => {
if (event.attachment.file) {
const key = createStorageKey(event.attachment.file);
var fileFormData = new FormData();
fileFormData.append("key", key);
fileFormData.append("Content-Type", event.attachment.file.type);
fileFormData.append("file", event.attachment.file);
try {
let res = await api.post({
url: "/media",
body: fileFormData,
headers: {
"Content-Type": "multipart/form-data",
},
progress: (progressEvent) =>
event.attachment.setUploadProgress(
(progressEvent.loaded * progressEvent.total) / 100,
),
});
event.attachment.setAttributes({
url: res.data.media.url,
href: res.data.media.url,
});
} catch (err) {
event.preventDefault();
alert(
err.response?.data?.message ||
"An unexpected server error occurred",
);
}
}
};
const loadOptionsAuthors = async () => {
api.get({
url: "/users",
params: {
fields: "id,display_name",
limit: 100,
},
})
.then((result) => {
const data = result.data as UserCollection;
if (data && data.users) {
authors.value = {};
data.users.forEach((item) => {
authors.value[item.id] = `${item.display_name}`;
});
}
})
.catch((error) => {
form.apiErrors(error, (message) => {
useToastStore().addToast({
title: "An error occurred",
content: message,
type: "danger",
});
});
});
};
loadOptionsAuthors();
loadData();
</script>

View File

@@ -1,290 +0,0 @@
<template>
<SMPageStatus v-if="!userHasPermission('admin/articles')" :status="403" />
<template v-else>
<SMMastHead
title="Article List"
:back-link="{ name: 'dashboard' }"
back-title="Return to Dashboard" />
<div class="max-w-7xl mx-auto mt-8 px-4">
<div
class="flex flex-col md:flex-row gap-4 items-center flex-justify-between mb-4">
<router-link
role="button"
:to="{ name: 'dashboard-article-create' }"
class="font-medium w-full md:w-auto text-center px-6 py-3.1 rounded-md hover:shadow-md transition bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
>Create Article</router-link
>
<SMInput
v-model="itemSearch"
label="Search"
class="w-full md:max-w-xl"
@keyup.enter="handleSearch">
<template #append>
<button
type="button"
class="font-medium px-4 py-3.1 rounded-r-2 hover:shadow-md transition bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
@click="handleSearch">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-6">
<path
d="M796-121 533-384q-30 26-69.959 40.5T378-329q-108.162 0-183.081-75Q120-479 120-585t75-181q75-75 181.5-75t181 75Q632-691 632-584.85 632-542 618-502q-14 40-42 75l264 262-44 44ZM377-389q81.25 0 138.125-57.5T572-585q0-81-56.875-138.5T377-781q-82.083 0-139.542 57.5Q180-666 180-585t57.458 138.5Q294.917-389 377-389Z"
fill="currentColor" />
</svg>
</button>
</template>
</SMInput>
</div>
<SMLoading large v-if="itemsLoading" />
<div
v-else-if="!itemsLoading && items.length == 0"
class="py-12 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-24 text-gray-5">
<path
d="M453-280h60v-240h-60v240Zm26.982-314q14.018 0 23.518-9.2T513-626q0-14.45-9.482-24.225-9.483-9.775-23.5-9.775-14.018 0-23.518 9.775T447-626q0 13.6 9.482 22.8 9.483 9.2 23.5 9.2Zm.284 514q-82.734 0-155.5-31.5t-127.266-86q-54.5-54.5-86-127.341Q80-397.681 80-480.5q0-82.819 31.5-155.659Q143-709 197.5-763t127.341-85.5Q397.681-880 480.5-880q82.819 0 155.659 31.5Q709-817 763-763t85.5 127Q880-563 880-480.266q0 82.734-31.5 155.5T763-197.684q-54 54.316-127 86Q563-80 480.266-80Zm.234-60Q622-140 721-239.5t99-241Q820-622 721.188-721 622.375-820 480-820q-141 0-240.5 98.812Q140-622.375 140-480q0 141 99.5 240.5t241 99.5Zm-.5-340Z"
fill="currentColor" />
</svg>
<p class="text-lg text-gray-5">
{{ "No articles where found" }}
</p>
</div>
<template v-else>
<SMPagination
v-if="items.length < itemsTotal"
class="mb-4"
v-model="itemsPage"
:total="itemsTotal"
:per-page="itemsPerPage" />
<SMTable :headers="headers" :items="items">
<template #item-actions="item">
<button
type="button"
class="bg-transparent cursor-pointer hover:text-sky-5"
title="Edit"
@click="handleEdit(item)">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-6">
<path
d="M180-180h44l443-443-44-44-443 443v44Zm614-486L666-794l42-42q17-17 42-17t42 17l44 44q17 17 17 42t-17 42l-42 42Zm-42 42L248-120H120v-128l504-504 128 128Zm-107-21-22-22 44 44-22-22Z"
fill="currentColor" />
</svg>
</button>
<button
type="button"
class="bg-transparent cursor-pointer hover:text-red-7"
title="Delete"
@click="handleDelete(item)">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-6">
<path
d="M261-120q-24.75 0-42.375-17.625T201-180v-570h-41v-60h188v-30h264v30h188v60h-41v570q0 24-18 42t-42 18H261Zm438-630H261v570h438v-570ZM367-266h60v-399h-60v399Zm166 0h60v-399h-60v399ZM261-750v570-570Z"
fill="currentColor" />
</svg>
</button>
</template>
</SMTable>
</template>
</div>
</template>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { openDialog } from "../../components/SMDialog";
import SMDialogConfirm from "../../components/dialogs/SMDialogConfirm.vue";
import SMPageStatus from "../../components/SMPageStatus.vue";
import { api } from "../../helpers/api";
import { Article, ArticleCollection } from "../../helpers/api.types";
import { SMDate } from "../../helpers/datetime";
import { useToastStore } from "../../store/ToastStore";
import SMInput from "../../components/SMInput.vue";
import SMMastHead from "../../components/SMMastHead.vue";
import SMTable from "../../components/SMTable.vue";
import SMPagination from "../../components/SMPagination.vue";
import SMLoading from "../../components/SMLoading.vue";
import { updateRouterParams } from "../../helpers/url";
import { userHasPermission } from "../../helpers/utils";
const route = useRoute();
const router = useRouter();
const toastStore = useToastStore();
const items = ref([]);
const itemsLoading = ref(true);
const itemSearch = ref((route.query.search as string) || "");
const itemsTotal = ref(0);
const itemsPerPage = 25;
const itemsPage = ref(parseInt((route.query.page as string) || "1"));
const headers = [
{ text: "Title", value: "title", sortable: true },
{ text: "Author", value: "user.display_name", sortable: true },
{ text: "Last Updates", value: "updated_at", sortable: true },
{ text: "Actions", value: "actions" },
];
/**
* Watch if page number changes.
*/
watch(itemsPage, () => {
handleLoad();
});
/**
* Handle searching for item.
*/
const handleSearch = () => {
itemsPage.value = 1;
handleLoad();
};
/**
* Handle user selecting option in action button.
* @param {Article} item The article item.
* @param {string} extra The option selected.
* @param option
*/
const handleActionButton = (item: Article, option: string): void => {
if (option.length == 0) {
handleEdit(item);
} else if (option.toLowerCase() == "delete") {
handleDelete(item);
}
};
/**
* Handle loading the page and list
*/
const handleLoad = async () => {
itemsLoading.value = true;
items.value = [];
itemsTotal.value = 0;
updateRouterParams(router, {
search: itemSearch.value,
page: itemsPage.value == 1 ? "" : itemsPage.value.toString(),
});
try {
let params = {
page: itemsPage.value,
limit: itemsPerPage,
};
if (itemSearch.value.length > 0) {
params[
"filter"
] = `title:${itemSearch.value},OR,name:${itemSearch.value},OR,description:${itemSearch.value}`;
}
let result = await api.get({
url: "/articles",
params: params,
});
const data = result.data as ArticleCollection;
data.articles.forEach(async (row) => {
if (row.created_at !== "undefined") {
row.created_at = new SMDate(row.created_at, {
format: "ymd",
utc: true,
}).relative();
}
if (row.updated_at !== "undefined") {
row.updated_at = new SMDate(row.updated_at, {
format: "ymd",
utc: true,
}).relative();
}
items.value.push(row);
});
itemsTotal.value = data.total;
} catch (error) {
if (error.status != 404) {
toastStore.addToast({
title: "Server Error",
content:
"An error occurred retrieving the list from the server.",
type: "danger",
});
}
} finally {
itemsLoading.value = false;
}
};
/**
* User requests to edit the item
* @param {Artile} item The article item.
*/
const handleEdit = (item: Article) => {
router.push({
name: "dashboard-article-edit",
params: { id: item.id },
query: {
return: encodeURIComponent(
window.location.pathname + window.location.search,
),
},
});
};
/**
* Request to delete a article item from the server.
* @param {Article} item The article object to delete.
*/
const handleDelete = async (item: Article) => {
let result = await openDialog(SMDialogConfirm, {
title: "Delete File?",
text: `Are you sure you want to delete the file <strong>${item.title}</strong>?`,
cancel: {
type: "secondary",
label: "Cancel",
},
confirm: {
type: "danger",
label: "Delete File",
},
});
if (result == true) {
try {
await api.delete({
url: "/articles/{id}",
params: {
id: item.id,
},
});
toastStore.addToast({
title: "File Deleted",
content: `The file ${item.title} has been deleted.`,
type: "success",
});
handleLoad();
} catch (error) {
toastStore.addToast({
title: "Error Deleting File",
content:
error.data?.message ||
"An unexpected server error occurred",
type: "danger",
});
}
}
};
handleLoad();
</script>

View File

@@ -1,164 +0,0 @@
<template>
<SMMastHead title="Dashboard" />
<div
class="max-w-7xl mx-auto px-4 grid sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-8 mt-12">
<router-link
to="/dashboard/details"
class="bg-white border-1 border-rounded-xl text-black decoration-none hover:shadow-md transition min-w-48 p-8 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-18 mb-4">
<path
d="M222-255q63-44 125-67.5T480-346q71 0 133.5 23.5T739-255q44-54 62.5-109T820-480q0-145-97.5-242.5T480-820q-145 0-242.5 97.5T140-480q0 61 19 116t63 109Zm257.814-195Q422-450 382.5-489.686q-39.5-39.686-39.5-97.5t39.686-97.314q39.686-39.5 97.5-39.5t97.314 39.686q39.5 39.686 39.5 97.5T577.314-489.5q-39.686 39.5-97.5 39.5Zm.654 370Q398-80 325-111.5q-73-31.5-127.5-86t-86-127.266Q80-397.532 80-480.266T111.5-635.5q31.5-72.5 86-127t127.266-86q72.766-31.5 155.5-31.5T635.5-848.5q72.5 31.5 127 86t86 127.032q31.5 72.532 31.5 155T848.5-325q-31.5 73-86 127.5t-127.032 86q-72.532 31.5-155 31.5ZM480-140q55 0 107.5-16T691-212q-51-36-104-55t-107-19q-54 0-107 19t-104 55q51 40 103.5 56T480-140Zm0-370q34 0 55.5-21.5T557-587q0-34-21.5-55.5T480-664q-34 0-55.5 21.5T403-587q0 34 21.5 55.5T480-510Zm0-77Zm0 374Z"
fill="currentColor" />
</svg>
<h3 class="font-normal text-2xl">My Details</h3>
</router-link>
<router-link
v-if="userStore.permissions.includes('admin/articles')"
:to="{ name: 'dashboard-article-list' }"
class="bg-white border-1 border-rounded-xl text-black decoration-none hover:shadow-md transition min-w-48 p-8 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-18 mb-4">
<path
d="M277-279h275v-60H277v60Zm0-171h406v-60H277v60Zm0-171h406v-60H277v60Zm-97 501q-24 0-42-18t-18-42v-600q0-24 18-42t42-18h600q24 0 42 18t18 42v600q0 24-18 42t-42 18H180Zm0-60h600v-600H180v600Zm0-600v600-600Z"
fill="currentColor" />
</svg>
<h3 class="font-normal text-2xl">Articles</h3>
</router-link>
<router-link
v-if="userStore.permissions.includes('admin/users')"
:to="{ name: 'dashboard-user-list' }"
class="bg-white border-1 border-rounded-xl text-black decoration-none hover:shadow-md transition min-w-48 p-8 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-18 mb-4">
<path
d="M400-485q-66 0-108-42t-42-108q0-66 42-108t108-42q66 0 108 42t42 108q0 66-42 108t-108 42ZM80-164v-94q0-35 17.5-63t50.5-43q72-32 133.5-46T400-424h23q-6 14-9 27.5t-5 32.5h-9q-58 0-113.5 12.5T172-310q-16 8-24 22.5t-8 29.5v34h269q5 18 12 32.5t17 27.5H80Zm587 44-10-66q-17-5-34.5-14.5T593-222l-55 12-25-42 47-44q-2-9-2-25t2-25l-47-44 25-42 55 12q12-12 29.5-21.5T657-456l10-66h54l10 66q17 5 34.5 14.5T795-420l55-12 25 42-47 44q2 9 2 25t-2 25l47 44-25 42-55-12q-12 12-29.5 21.5T731-186l-10 66h-54Zm27-121q36 0 58-22t22-58q0-36-22-58t-58-22q-36 0-58 22t-22 58q0 36 22 58t58 22ZM400-545q39 0 64.5-25.5T490-635q0-39-25.5-64.5T400-725q-39 0-64.5 25.5T310-635q0 39 25.5 64.5T400-545Zm0-90Zm9 411Z"
fill="currentColor" />
</svg>
<h3 class="font-normal text-2xl">Users</h3>
</router-link>
<router-link
v-if="userStore.permissions.includes('admin/events')"
:to="{ name: 'dashboard-event-list' }"
class="bg-white border-1 border-rounded-xl text-black decoration-none hover:shadow-md transition min-w-48 p-8 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-18 mb-4">
<path
d="M180-80q-24 0-42-18t-18-42v-620q0-24 18-42t42-18h65v-60h65v60h340v-60h65v60h65q24 0 42 18t18 42v301h-60v-111H180v430h319v60H180Zm709-219-71-71 29-29q8.311-8 21.156-8Q881-407 889-399l29 29q8 8.311 8 21.156Q926-336 918-328l-29 29ZM559-40v-71l216-216 71 71L630-40h-71ZM180-630h600v-130H180v130Zm0 0v-130 130Z"
fill="currentColor" />
</svg>
<h3 class="font-normal text-2xl">Events</h3>
</router-link>
<router-link
v-if="userStore.permissions.includes('admin/courses')"
:to="{ name: 'dashboard-event-list' }"
class="bg-white border-1 border-rounded-xl text-black decoration-none hover:shadow-md transition min-w-48 p-8 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-18 mb-4">
<path
d="M479-120 189-279v-240L40-600l439-240 441 240v317h-60v-282l-91 46v240L479-120Zm0-308 315-172-315-169-313 169 313 172Zm0 240 230-127v-168L479-360 249-485v170l230 127Zm1-240Zm-1 74Zm0 0Z"
fill="currentColor" />
</svg>
<h3 class="font-normal text-2xl">{{ courseBoxTitle }}</h3>
</router-link>
<router-link
v-if="userStore.permissions.includes('admin/media')"
:to="{ name: 'dashboard-media-list' }"
class="bg-white border-1 border-rounded-xl text-black decoration-none hover:shadow-md transition min-w-48 p-8 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-18 mb-4">
<path
d="M220-240q-24 0-42-18t-18-42v-520q0-24 18-42t42-18h245l60 60h335q24 0 42 18t18 42v460q0 24-18 42t-42 18H220Zm0-60h640v-460H500l-60-60H220v520Zm590 180H100q-24 0-42-18t-18-42v-580h60v580h710v60ZM334-411h412L614-587 504-441l-79-86-91 116ZM220-300v-520 520Z"
fill="currentColor" />
</svg>
<h3 class="font-normal text-2xl">Media</h3>
</router-link>
<router-link
v-if="userStore.permissions.includes('admin/media')"
:to="{ name: 'dashboard-analytics-list' }"
class="bg-white border-1 border-rounded-xl text-black decoration-none hover:shadow-md transition min-w-48 p-8 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-18 mb-4">
<path
d="M120-120v-76l60-60v136h-60Zm165 0v-236l60-60v296h-60Zm165 0v-296l60 61v235h-60Zm165 0v-235l60-60v295h-60Zm165 0v-396l60-60v456h-60ZM120-356v-85l280-278 160 160 280-281v85L560-474 400-634 120-356Z"
fill="currentColor" />
</svg>
<h3 class="font-normal text-2xl">Analytics</h3>
</router-link>
<!-- <router-link
v-if="userStore.permissions.includes('admin/media')"
:to="{ name: 'dashboard-media-list' }"
class="admin-card minecraft"
style="background-image: url('/img/minecraft.png')">
<img src="/img/minecraft-grass-block.png" />
<h3>Minecraft</h3>
</router-link> -->
<router-link
v-if="userStore.permissions.includes('admin/shortlinks')"
:to="{ name: 'dashboard-shortlink-list' }"
class="bg-white border-1 border-rounded-xl text-black decoration-none hover:shadow-md transition min-w-48 p-8 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-18 mb-4">
<path
d="M450-280H280q-83 0-141.5-58.5T80-480q0-83 58.5-141.5T280-680h170v60H280q-58.333 0-99.167 40.765-40.833 40.764-40.833 99Q140-422 180.833-381q40.834 41 99.167 41h170v60ZM325-450v-60h310v60H325Zm185 170v-60h170q58.333 0 99.167-40.765 40.833-40.764 40.833-99Q820-538 779.167-579 738.333-620 680-620H510v-60h170q83 0 141.5 58.5T880-480q0 83-58.5 141.5T680-280H510Z"
fill="currentColor" />
</svg>
<h3 class="font-normal text-2xl">Shortlinks</h3>
</router-link>
<router-link
v-if="userStore.permissions.includes('logs/discord')"
:to="{ name: 'dashboard-discord-bot-logs' }"
class="bg-white border-1 border-rounded-xl text-black decoration-none hover:shadow-md transition min-w-48 p-8 text-center">
<svg
viewBox="0 -28.5 256 256"
xmlns="http://www.w3.org/2000/svg"
class="h-18 mb-4">
<g>
<path
d="M216.856339,16.5966031 C200.285002,8.84328665 182.566144,3.2084988 164.041564,0 C161.766523,4.11318106 159.108624,9.64549908 157.276099,14.0464379 C137.583995,11.0849896 118.072967,11.0849896 98.7430163,14.0464379 C96.9108417,9.64549908 94.1925838,4.11318106 91.8971895,0 C73.3526068,3.2084988 55.6133949,8.86399117 39.0420583,16.6376612 C5.61752293,67.146514 -3.4433191,116.400813 1.08711069,164.955721 C23.2560196,181.510915 44.7403634,191.567697 65.8621325,198.148576 C71.0772151,190.971126 75.7283628,183.341335 79.7352139,175.300261 C72.104019,172.400575 64.7949724,168.822202 57.8887866,164.667963 C59.7209612,163.310589 61.5131304,161.891452 63.2445898,160.431257 C105.36741,180.133187 151.134928,180.133187 192.754523,160.431257 C194.506336,161.891452 196.298154,163.310589 198.110326,164.667963 C191.183787,168.842556 183.854737,172.420929 176.223542,175.320965 C180.230393,183.341335 184.861538,190.991831 190.096624,198.16893 C211.238746,191.588051 232.743023,181.531619 254.911949,164.955721 C260.227747,108.668201 245.831087,59.8662432 216.856339,16.5966031 Z M85.4738752,135.09489 C72.8290281,135.09489 62.4592217,123.290155 62.4592217,108.914901 C62.4592217,94.5396472 72.607595,82.7145587 85.4738752,82.7145587 C98.3405064,82.7145587 108.709962,94.5189427 108.488529,108.914901 C108.508531,123.290155 98.3405064,135.09489 85.4738752,135.09489 Z M170.525237,135.09489 C157.88039,135.09489 147.510584,123.290155 147.510584,108.914901 C147.510584,94.5396472 157.658606,82.7145587 170.525237,82.7145587 C183.391518,82.7145587 193.761324,94.5189427 193.539891,108.914901 C193.539891,123.290155 183.391518,135.09489 170.525237,135.09489 Z"
fill="currentColor"
fill-rule="nonzero"></path>
</g>
</svg>
<h3 class="font-normal text-2xl">Discord</h3>
</router-link>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useUserStore } from "../../store/UserStore";
import SMMastHead from "../../components/SMMastHead.vue";
const userStore = useUserStore();
const courseBoxTitle = computed(() => {
if (userStore.permissions.includes("admin/courses")) {
return "Courses";
} else {
return "My Courses";
}
});
</script>
<style lang="scss">
.page-dashboard a:not([role="button"]) {
color: rgba(55, 65, 81);
}
</style>

View File

@@ -1,71 +0,0 @@
<template>
<SMPage permission="logs/discord">
<SMMastHead
title="Discord"
:back-link="{ name: 'dashboard' }"
back-title="Back to Dashboard" />
<SMContainer class="flex-grow-1">
<SMRow>
<SMColumn>
<SMTabGroup>
<SMTab label="Output">
<code v-if="logOutputContent.length > 0">{{
logOutputContent
}}</code>
</SMTab>
<SMTab label="Errors">
<code v-if="logErrorContent.length > 0">{{
logErrorContent
}}</code>
</SMTab>
</SMTabGroup>
</SMColumn>
</SMRow>
<button type="button" @click="loadData">Reload Logs</button>
</SMContainer>
</SMPage>
</template>
<script setup lang="ts">
import { ref } from "vue";
import SMTab from "../../components/SMTab.vue";
import SMTabGroup from "../../components/SMTabGroup.vue";
import { api } from "../../helpers/api";
import { LogsDiscordResponse } from "../../helpers/api.types";
import { useToastStore } from "../../store/ToastStore";
import SMMastHead from "../../components/SMMastHead.vue";
let formLoading = ref(false);
let logOutputContent = ref("");
let logErrorContent = ref("");
const loadData = async () => {
try {
formLoading.value = true;
const result = await api.get({ url: "/logs/discord" });
const data = result.data as LogsDiscordResponse;
if (data) {
logOutputContent.value = data.log.output || "";
if (logOutputContent.value.length === 0) {
logOutputContent.value = "Log file is empty";
}
logErrorContent.value = data.log.error || "";
if (logErrorContent.value.length === 0) {
logErrorContent.value = "Log file is empty";
}
}
} catch (error) {
useToastStore().addToast({
title: "Server Error",
content: "Could not load logs from server",
type: "danger",
});
} finally {
formLoading.value = false;
}
};
loadData();
</script>

View File

@@ -1,397 +0,0 @@
<template>
<SMPageStatus v-if="!userHasPermission('admin/events')" :status="403" />
<template v-else>
<SMMastHead
:title="pageHeading"
:back-link="{ name: 'dashboard-event-list' }"
back-title="Back to Events" />
<div class="max-w-4xl mx-auto px-4 mt-8">
<SMLoading v-if="pageLoading" />
<SMForm
v-else
:model-value="form"
@submit="handleSubmit"
@failed-validation="handleFailValidation">
<div class="flex gap-4">
<SMInput class="mb-4" control="title" />
<SMDropdown
class="mb-4"
control="location"
type="select"
:options="{
online: 'Online',
physical: 'Physical',
}" />
</div>
<div
class="flex flex-col md:flex-row gap-4"
v-if="form.controls.location.value !== 'online'">
<SMInput class="mb-4" control="address" />
<SMInput class="mb-4" control="location_url" />
</div>
<div class="flex flex-col md:flex-row gap-4">
<SMInput
type="datetime"
class="mb-4"
control="start_at"
label="Start Date/Time" />
<SMInput
type="datetime"
class="mb-4"
control="end_at"
label="End Date/Time" />
</div>
<div class="flex flex-col md:flex-row gap-4">
<SMInput
type="datetime"
class="mb-4"
control="publish_at"
label="Publish Date/Time" />
<SMDropdown
type="select"
class="mb-4"
control="status"
:options="{
draft: 'Draft',
soon: 'Opening Soon',
open: 'Open',
scheduled: 'Scheduled',
full: 'Full',
closed: 'Closed',
cancelled: 'Cancelled',
}" />
</div>
<div class="flex flex-col md:flex-row gap-4">
<SMInput class="mb-4" control="price"
>Leave blank to hide from public. Also supports TBD and
TBC.</SMInput
>
<SMInput class="mb-4" control="ages"
>Leave blank to hide from public.</SMInput
>
</div>
<div class="flex flex-col md:flex-row gap-4">
<SMDropdown
type="select"
class="mb-4"
control="registration_type"
label="Registration"
:options="{
none: 'None',
email: 'Email',
link: 'Link',
message: 'Message',
}" />
<SMInput
v-if="registration_data?.visible"
class="mb-4"
control="registration_data"
:label="registration_data?.title"
:type="registration_data?.type" />
</div>
<div class="mb-4">
<SMSelectFile
control="hero"
label="Hero image"
allow-upload />
</div>
<SMEditor
class="mb-4"
v-model:model-value="form.controls.content.value" />
<SMAttachments
class="mb-4"
show-editor
v-model:model-value="attachments" />
<div class="flex flex-justify-end">
<input
type="submit"
class="font-medium px-6 py-3.1 rounded-2 hover:shadow-md text-lg transition bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
value="Save" />
</div>
</SMForm>
</div>
</template>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import SMEditor from "../../components/SMEditor.vue";
import SMInput from "../../components/SMInput.vue";
import { api } from "../../helpers/api";
import { SMDate } from "../../helpers/datetime";
import { Form, FormControl } from "../../helpers/form";
import {
And,
Custom,
DateTime,
Email,
Min,
Required,
Url,
} from "../../helpers/validate";
import SMAttachments from "../../components/SMAttachments.vue";
import SMForm from "../../components/SMForm.vue";
import { EventResponse } from "../../helpers/api.types";
import { useToastStore } from "../../store/ToastStore";
import SMMastHead from "../../components/SMMastHead.vue";
import SMLoading from "../../components/SMLoading.vue";
import SMPageStatus from "../../components/SMPageStatus.vue";
import { userHasPermission } from "../../helpers/utils";
import SMDropdown from "../../components/SMDropdown.vue";
import SMSelectFile from "../../components/SMSelectFile.vue";
const route = useRoute();
const router = useRouter();
const pageError = ref(200);
const pageLoading = ref(false);
const pageHeading = route.params.id ? "Edit Event" : "Create Event";
const attachments = ref([]);
const address_data = computed(() => {
let data = {
title: "",
required: false,
};
if (form?.controls.location.value === "online") {
data.required = false;
} else if (form?.controls.location.value === "physical") {
data.title = "Address";
data.required = true;
}
return data;
});
const registration_data = computed(() => {
let data = {
visible: false,
title: "",
type: "text",
};
if (form?.controls.registration_type.value === "email") {
data.visible = true;
data.title = "Registration email";
data.type = "email";
} else if (form?.controls.registration_type.value === "link") {
data.visible = true;
data.title = "Registration URL";
data.type = "url";
} else if (form?.controls.registration_type.value === "message") {
data.visible = true;
data.title = "Registration message";
data.type = "text";
}
return data;
});
let form = reactive(
Form({
title: FormControl("", And([Required(), Min(6)])),
location: FormControl("online"),
address: FormControl(
"",
Custom(async (value) => {
return address_data?.value.required && value.length == 0
? "A venue address is required"
: true;
}),
),
location_url: FormControl("", Url()),
start_at: FormControl("", And([Required(), DateTime()])),
end_at: FormControl(
"",
And([
Required(),
DateTime({
after: (v) => {
return form.controls.start_at.value;
},
invalidAfterMessage:
"The ending date/time must be after the starting date/time.",
}),
]),
),
publish_at: FormControl(
route.params.id ? "" : new SMDate("now").format("d/M/yy h:mm aa"),
DateTime(),
),
status: FormControl("draft"),
registration_type: FormControl("none"),
registration_data: FormControl(
"",
Custom(async (v) => {
let validationResult = {
valid: true,
invalidMessages: [""],
};
if (form.controls.registration_type.value == "email") {
validationResult = await Email().validate(v);
} else if (form.controls.registration_type.value == "url") {
validationResult = await Url().validate(v);
}
if (!validationResult.valid) {
return validationResult.invalidMessages[0];
}
return true;
}),
),
hero: FormControl("", Required()),
content: FormControl(),
price: FormControl(),
ages: FormControl(),
}),
);
const loadData = async () => {
if (route.params.id) {
try {
pageLoading.value = true;
const result = await api.get({
url: "/events/{id}",
params: { id: route.params.id },
});
const data = result.data as EventResponse;
if (!data || !data.event) {
throw new Error("The server is currently not available");
}
form.controls.title.value = data.event.title;
form.controls.location.value = data.event.location;
form.controls.location_url.value = data.event.location_url;
form.controls.address.value = data.event.address
? data.event.address
: "";
form.controls.start_at.value = new SMDate(data.event.start_at, {
format: "ymd",
utc: true,
}).format("dd/MM/yyyy h:mm aa");
form.controls.end_at.value = new SMDate(data.event.end_at, {
format: "ymd",
utc: true,
}).format("dd/MM/yyyy h:mm aa");
form.controls.status.value = data.event.status;
form.controls.publish_at.value = new SMDate(data.event.publish_at, {
format: "ymd",
utc: true,
}).format("dd/MM/yyyy h:mm aa");
form.controls.registration_type.value =
data.event.registration_type;
form.controls.registration_data.value =
data.event.registration_data;
form.controls.content.value = data.event.content
? data.event.content
: "";
form.controls.hero.value = data.event.hero;
form.controls.price.value = data.event.price;
form.controls.ages.value = data.event.ages;
attachments.value = data.event.attachments;
} catch (err) {
pageError.value = err.status;
} finally {
pageLoading.value = false;
}
}
};
const handleSubmit = async (enableFormCallBack) => {
try {
let data = {
title: form.controls.title.value,
location: form.controls.location.value,
location_url: form.controls.location_url.value,
address: form.controls.address.value,
start_at: new SMDate(form.controls.start_at.value, {
format: "dmy",
}).format("yyyy/MM/dd HH:mm:ss", { utc: true }),
end_at: new SMDate(form.controls.end_at.value, {
format: "dmy",
}).format("yyyy/MM/dd HH:mm:ss", { utc: true }),
status: form.controls.status.value,
publish_at:
form.controls.publish_at.value == ""
? ""
: new SMDate(form.controls.publish_at.value, {
format: "dmy",
}).format("yyyy/MM/dd HH:mm:ss", { utc: true }),
registration_type: form.controls.registration_type.value,
registration_data: form.controls.registration_data.value,
content: form.controls.content.value,
hero: form.controls.hero.value.id,
price: form.controls.price.value,
ages: form.controls.ages.value,
attachments: attachments.value.map((item) => item.id),
};
let event_id = "";
if (route.params.id) {
event_id = route.params.id as string;
await api.put({
url: "/events/{id}",
params: {
id: route.params.id,
},
body: data,
});
} else {
let result = await api.post({
url: "/events",
body: data,
});
if (result.data) {
const data = result.data as EventResponse;
event_id = data.event.id;
}
}
useToastStore().addToast({
title: route.params.id ? "Event Updated" : "Event Created",
content: route.params.id
? "The event has been updated."
: "The event has been created.",
type: "success",
});
const urlParams = new URLSearchParams(window.location.search);
const returnUrl = urlParams.get("return");
if (returnUrl) {
router.push(decodeURIComponent(returnUrl));
} else {
router.push({ name: "dashboard-event-list" });
}
} catch (error) {
useToastStore().addToast({
title: "Server error",
content: "An error occurred saving the event.",
type: "danger",
});
enableFormCallBack();
}
};
const handleFailValidation = () => {
useToastStore().addToast({
title: "Save Error",
content:
"There are some errors in the form. Fix these before continuing.",
type: "danger",
});
};
loadData();
</script>
<style lang="scss"></style>

View File

@@ -1,405 +0,0 @@
<template>
<SMPageStatus v-if="!userHasPermission('admin/events')" :status="403" />
<template v-else>
<SMMastHead
title="Events"
:back-link="{ name: 'dashboard' }"
back-title="Return to Dashboard" />
<div class="max-w-7xl mx-auto mt-8 p-4">
<div
class="flex flex-col md:flex-row gap-4 items-center flex-justify-between mb-4">
<router-link
role="button"
:to="{ name: 'dashboard-event-create' }"
class="font-medium w-full md:w-auto text-center px-6 py-3.1 rounded-md hover:shadow-md transition bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
>Create Event</router-link
>
<SMInput
v-model="itemSearch"
label="Search"
class="w-full md:max-w-xl"
@keyup.enter="handleSearch">
<template #append>
<button
type="button"
class="font-medium px-4 py-3.1 rounded-r-2 hover:shadow-md transition bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
@click="handleSearch">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-6">
<path
d="M796-121 533-384q-30 26-69.959 40.5T378-329q-108.162 0-183.081-75Q120-479 120-585t75-181q75-75 181.5-75t181 75Q632-691 632-584.85 632-542 618-502q-14 40-42 75l264 262-44 44ZM377-389q81.25 0 138.125-57.5T572-585q0-81-56.875-138.5T377-781q-82.083 0-139.542 57.5Q180-666 180-585t57.458 138.5Q294.917-389 377-389Z"
fill="currentColor" />
</svg>
</button>
</template>
</SMInput>
</div>
<SMLoading large v-if="itemsLoading" />
<div
v-else-if="!itemsLoading && items.length == 0"
class="py-12 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-24 text-gray-5">
<path
d="M453-280h60v-240h-60v240Zm26.982-314q14.018 0 23.518-9.2T513-626q0-14.45-9.482-24.225-9.483-9.775-23.5-9.775-14.018 0-23.518 9.775T447-626q0 13.6 9.482 22.8 9.483 9.2 23.5 9.2Zm.284 514q-82.734 0-155.5-31.5t-127.266-86q-54.5-54.5-86-127.341Q80-397.681 80-480.5q0-82.819 31.5-155.659Q143-709 197.5-763t127.341-85.5Q397.681-880 480.5-880q82.819 0 155.659 31.5Q709-817 763-763t85.5 127Q880-563 880-480.266q0 82.734-31.5 155.5T763-197.684q-54 54.316-127 86Q563-80 480.266-80Zm.234-60Q622-140 721-239.5t99-241Q820-622 721.188-721 622.375-820 480-820q-141 0-240.5 98.812Q140-622.375 140-480q0 141 99.5 240.5t241 99.5Zm-.5-340Z"
fill="currentColor" />
</svg>
<p class="text-lg text-gray-5">
{{ "No events where found" }}
</p>
</div>
<template v-else>
<SMPagination
v-if="items.length < itemsTotal"
class="mb-4"
v-model="itemsPage"
:total="itemsTotal"
:per-page="itemsPerPage" />
<SMTable
class="sm-table-events"
:headers="headers"
:items="items">
<template #item-start_at="item">{{
formattedDate(item.start_at)
}}</template>
<template #item-location="item"
>{{ parseEventLocation(item) }}
</template>
<template #item-status="item"
>{{ toTitleCase(item.status) }}
</template>
<template #item-actions="item">
<button
type="button"
class="bg-transparent cursor-pointer hover:text-sky-5"
title="Edit"
@click="handleEdit(item)">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-6">
<path
d="M180-180h44l443-443-44-44-443 443v44Zm614-486L666-794l42-42q17-17 42-17t42 17l44 44q17 17 17 42t-17 42l-42 42Zm-42 42L248-120H120v-128l504-504 128 128Zm-107-21-22-22 44 44-22-22Z"
fill="currentColor" />
</svg>
</button>
<button
type="button"
class="bg-transparent cursor-pointer hover:text-sky-5"
title="View"
@click="handleView(item)">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-6">
<path
d="M180-120q-24.75 0-42.375-17.625T120-180v-600q0-24.75 17.625-42.375T180-840h600q24.75 0 42.375 17.625T840-780v600q0 24.75-17.625 42.375T780-120H180Zm0-60h600v-520H180v520Zm300.041-105Q400-285 337-328.152q-63-43.151-92-112Q274-509 336.959-552t143-43Q560-595 623-551.849q63 43.152 92 112.001Q686-371 623.041-328t-143 43ZM480-335q57 0 104.949-27.825T660-440q-27.102-49.35-75.051-77.175Q537-545 480-545t-104.949 27.825Q327.102-489.35 300-440q27.102 49.35 75.051 77.175Q423-335 480-335Zm0-105Zm.118 50Q501-390 515.5-404.618q14.5-14.617 14.5-35.5Q530-461 515.382-475.5q-14.617-14.5-35.5-14.5Q459-490 444.5-475.382q-14.5 14.617-14.5 35.5Q430-419 444.618-404.5q14.617 14.5 35.5 14.5Z"
fill="currentColor" />
</svg>
</button>
<button
type="button"
class="bg-transparent cursor-pointer hover:text-sky-5"
title="Duplicate"
@click="handleDuplicate(item)">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-6">
<path
d="M180-81q-24 0-42-18t-18-42v-603h60v603h474v60H180Zm120-120q-24 0-42-18t-18-42v-560q0-24 18-42t42-18h440q24 0 42 18t18 42v560q0 24-18 42t-42 18H300Zm0-60h440v-560H300v560Zm0 0v-560 560Z"
fill="currentColor" />
</svg>
</button>
<button
type="button"
class="bg-transparent cursor-pointer hover:text-red-7"
title="Delete"
@click="handleDelete(item)">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-6">
<path
d="M261-120q-24.75 0-42.375-17.625T201-180v-570h-41v-60h188v-30h264v30h188v60h-41v570q0 24-18 42t-42 18H261Zm438-630H261v570h438v-570ZM367-266h60v-399h-60v399Zm166 0h60v-399h-60v399ZM261-750v570-570Z"
fill="currentColor" />
</svg>
</button>
</template>
</SMTable>
</template>
</div>
</template>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { openDialog } from "../../components/SMDialog";
import { api } from "../../helpers/api";
import { EventCollection, Event, EventResponse } from "../../helpers/api.types";
import { SMDate } from "../../helpers/datetime";
import { updateRouterParams } from "../../helpers/url";
import { useToastStore } from "../../store/ToastStore";
import { toTitleCase } from "../../helpers/string";
import { userHasPermission } from "../../helpers/utils";
import SMDialogConfirm from "../../components/dialogs/SMDialogConfirm.vue";
import SMInput from "../../components/SMInput.vue";
import SMLoading from "../../components/SMLoading.vue";
import SMMastHead from "../../components/SMMastHead.vue";
import SMPagination from "../../components/SMPagination.vue";
import SMTable from "../../components/SMTable.vue";
import SMPageStatus from "../../components/SMPageStatus.vue";
const route = useRoute();
const router = useRouter();
const toastStore = useToastStore();
const items = ref([]);
const itemsLoading = ref(true);
const itemSearch = ref((route.query.search as string) || "");
const itemsTotal = ref(0);
const itemsPerPage = 25;
const itemsPage = ref(parseInt((route.query.page as string) || "1"));
const headers = [
{ text: "Title", value: "title", sortable: true },
{ text: "Starts", value: "start_at", sortable: true },
{ text: "Status", value: "status", sortable: true },
{ text: "Location", value: "location", sortable: true },
{ text: "Actions", value: "actions" },
];
/**
* Watch if page number changes.
*/
watch(itemsPage, () => {
handleLoad();
});
/**
* Handle searching for item.
*/
const handleSearch = () => {
itemsPage.value = 1;
handleLoad();
};
/**
* Handle loading the page and list
*/
const handleLoad = async () => {
itemsLoading.value = true;
items.value = [];
itemsTotal.value = 0;
updateRouterParams(router, {
search: itemSearch.value,
page: itemsPage.value == 1 ? "" : itemsPage.value.toString(),
});
try {
let params = {
page: itemsPage.value,
limit: itemsPerPage,
};
if (itemSearch.value.length > 0) {
params[
"filter"
] = `title:${itemSearch.value},OR,content:${itemSearch.value}`;
}
let result = await api.get({
url: "/events",
params: params,
});
const data = result.data as EventCollection;
data.events.forEach(async (row) => {
items.value.push(row);
});
itemsTotal.value = data.total;
} catch (error) {
if (error.status != 404) {
toastStore.addToast({
title: "Server Error",
content:
"An error occurred retrieving the list from the server.",
type: "danger",
});
}
} finally {
itemsLoading.value = false;
}
};
const formattedDate = (d: string): string => {
return new SMDate(d, {
format: "ymd",
utc: true,
}).format("MMM d yyyy, h:mm aa");
};
/**
* Handle viewing an event.
* @param item
*/
const handleView = (item: Event): void => {
// router.push({ name: "event", params: { id: item.id } });
window.open(
router.resolve({ name: "event", params: { id: item.id } }).href,
"_blank",
);
};
/**
* Handle duplicating an event.
* @param item
*/
const handleDuplicate = async (item: Event): Promise<void> => {
try {
let data = {
title: `Copy of ${item.title}`,
location: item.location,
location_url: item.location_url,
address: item.address,
start_at: item.start_at,
end_at: item.end_at,
status: "draft",
publish_at: item.publish_at,
registration_type: item.registration_type,
registration_data: item.registration_data,
content: item.content,
hero: item.hero.id,
price: item.price,
ages: item.ages,
attachments: item.attachments.map((item) => item.id).join(","),
};
let result = await api.post({
url: "/events",
body: data,
});
let event = result.data as EventResponse;
useToastStore().addToast({
title: "Event Duplicated",
content: "The event has been duplicated.",
type: "success",
});
router.push({
name: "dashboard-event-edit",
params: { id: event.event.id },
query: {
return: encodeURIComponent(
window.location.pathname + window.location.search,
),
},
});
} catch (error) {
console.log(error);
useToastStore().addToast({
title: "Server error",
content: "An error occurred duplicating the event.",
type: "danger",
});
}
};
/**
* User requests to edit the item
* @param {Event} item The event item.
*/
const handleEdit = (item: Event) => {
router.push({
name: "dashboard-event-edit",
params: { id: item.id },
query: {
return: encodeURIComponent(
window.location.pathname + window.location.search,
),
},
});
};
/**
* Request to delete an event item from the server.
* @param {Event} item The event object to delete.
*/
const handleDelete = async (item: Event) => {
let result = await openDialog(SMDialogConfirm, {
title: "Delete Event?",
text: `Are you sure you want to delete the event <strong>${item.title}</strong>?`,
cancel: {
type: "secondary",
label: "Cancel",
},
confirm: {
type: "danger",
label: "Delete",
},
});
if (result == true) {
try {
await api.delete({
url: "/events/{id}",
params: {
id: item.id,
},
});
const index = items.value.findIndex(
(lookupItem) => item.id === lookupItem.id,
);
if (index !== -1) {
items.value.splice(index, 1);
}
toastStore.addToast({
title: "Event Deleted",
content: `The event ${item.title} has been deleted.`,
type: "success",
});
} catch (error) {
toastStore.addToast({
title: "Error Deleting Event",
content:
error.data?.message ||
"An unexpected server error occurred",
type: "danger",
});
}
}
};
/**
* Parse Event location for humans.
* @param {Event} item The event object to delete.
* @returns {string} human readable location.
*/
const parseEventLocation = (item: Event) => {
if (item.location == "online") {
return "Online";
}
return item.address;
};
handleLoad();
</script>
<style lang="scss">
.sm-table-events {
tbody tr td:last-child {
white-space: nowrap;
}
}
</style>

View File

@@ -1,576 +0,0 @@
<template>
<SMPageStatus v-if="!userHasPermission('admin/media')" :status="403" />
<template v-else>
<SMMastHead
:title="pageHeading"
:back-link="{ name: 'dashboard-media-list' }"
back-title="Back to Media" />
<SMLoading v-if="form.loading()" />
<div v-else class="max-w-4xl mx-auto px-4 mt-8">
<SMForm
:model-value="form"
@submit="handleSubmit"
@failed-validation="handleFailValidation">
<div>
<SMImageGallery class="mb-4" :model-value="galleryItems" />
</div>
<SMSelectFile
v-if="!editMultiple"
control="file"
upload-only
accepts="*"
class="mb-4" />
<SMInput control="title" class="mb-4" />
<div class="flex flex-col md:flex-row gap-4">
<SMDropdown
class="mb-4"
control="security_type"
type="select"
:options="{
'': 'None',
permission: 'Permission',
password: 'Password',
}" />
<SMInput
v-if="form.controls.security_type.value != ''"
class="mb-4"
control="security_data"
:label="
toTitleCase(
form.controls.security_type.value.toString(),
)
" />
</div>
<div
v-if="!editMultiple"
class="flex flex-col md:flex-row gap-4">
<SMInput
class="mb-4"
v-model="computedFileSize"
disabled
label="File Size" />
<SMInput
class="mb-4"
v-model="fileData.mime_type"
disabled
label="File Mime Type" />
</div>
<div
v-if="!editMultiple"
class="flex flex-col md:flex-row gap-4">
<SMInput
class="mb-4"
v-model="fileData.status"
disabled
label="Status" />
<SMInput
class="mb-4"
v-model="fileData.dimensions"
disabled
label="Dimensions" />
</div>
<SMInput
v-if="!editMultiple"
class="mb-4"
v-model="fileData.url"
disabled
label="URL" />
<SMInput class="mb-4" textarea control="description" />
<div class="flex flex-justify-end gap-4">
<button
v-if="route.params.id"
type="button"
class="font-medium px-6 py-1.5 rounded-md hover:shadow-md transition text-sm bg-red-600 hover:bg-red-500 text-white cursor-pointer"
@click="handleDelete">
{{ editMultiple ? "Delete All" : "Delete" }}
</button>
<input
role="button"
type="submit"
class="font-medium px-6 py-1.5 rounded-md hover:shadow-md transition text-sm bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
:value="editMultiple ? 'Save All' : 'Save'" />
</div>
</SMForm>
</div>
</template>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ApiOptions, api } from "../../helpers/api";
import { Form, FormControl } from "../../helpers/form";
import { bytesReadable } from "../../helpers/types";
import { And, Required } from "../../helpers/validate";
import {
Media,
MediaJobResponse,
MediaResponse,
} from "../../helpers/api.types";
import { closeDialog, openDialog } from "../../components/SMDialog";
import DialogConfirm from "../../components/dialogs/SMDialogConfirm.vue";
import SMForm from "../../components/SMForm.vue";
import SMInput from "../../components/SMInput.vue";
import SMDropdown from "../../components/SMDropdown.vue";
import SMMastHead from "../../components/SMMastHead.vue";
import SMLoading from "../../components/SMLoading.vue";
import { useToastStore } from "../../store/ToastStore";
import SMPageStatus from "../../components/SMPageStatus.vue";
import SMSelectFile from "../../components/SMSelectFile.vue";
import { userHasPermission } from "../../helpers/utils";
import SMImageGallery from "../../components/SMImageGallery.vue";
import { toTitleCase } from "../../helpers/string";
import SMDialogProgress from "../../components/dialogs/SMDialogProgress.vue";
import { mediaGetWebURL } from "../../helpers/media";
const route = useRoute();
const router = useRouter();
const pageError = ref(200);
const editMultiple = "id" in route.params && route.params.id.includes(",");
const pageHeading = route.params.id
? editMultiple
? "Edit Multiple Media"
: "Edit Media"
: "Upload Media";
const galleryItems = ref([]);
const form = reactive(
Form({
file: FormControl("", And([Required()])),
title: FormControl("", Required()),
description: FormControl(),
security_type: FormControl(),
security_data: FormControl(),
}),
);
const fileData = reactive({
url: "Not available",
mime_type: "--",
size: 0,
storage: "--",
status: "--",
dimensions: "--",
user: {},
});
const imageUrl = ref("");
const handleLoad = async () => {
if (route.params.id) {
if (editMultiple === false) {
try {
form.loading(true);
let result = await api.get({
url: "/media/{id}",
params: {
id: route.params.id,
},
});
const data = result.data as MediaResponse;
form.controls.file.value = data.medium;
form.controls.title.value = data.medium.title;
form.controls.description.value = data.medium.description;
form.controls.security_type.value = data.medium.security_type;
form.controls.security_data.value = data.medium.security_data;
fileData.url = mediaGetWebURL(data.medium);
fileData.mime_type = data.medium.mime_type;
fileData.size = data.medium.size;
fileData.storage = data.medium.storage;
fileData.status =
data.medium.status == "" ? "OK" : data.medium.status;
fileData.dimensions = data.medium.dimensions;
imageUrl.value = fileData.url;
} catch (err) {
pageError.value = err.status;
} finally {
form.loading(false);
}
} else {
(route.params.id as string).split(",").forEach(async (id) => {
try {
form.loading(true);
let result = await api.get({
url: "/media/{id}",
params: {
id: id,
},
});
const data = result.data as MediaResponse;
galleryItems.value.push(data.medium);
} catch (err) {
pageError.value = err.status;
} finally {
form.loading(false);
}
});
}
}
};
const dialogDataSetStatus = (dialogData, status, progress, add) => {
if (add) {
dialogData.rows.push(status);
dialogData.progress.push(progress);
} else {
const index = dialogData.rows.length - 1;
if (status.length > 0) {
dialogData.rows[index] = status;
}
if (progress > -1) {
dialogData.progress[index] = progress;
}
}
};
const handleSubmit = async (enableFormCallBack) => {
if (editMultiple === false) {
let dialogData = ref({
title: "Saving Media",
rows: [],
progress: [],
});
openDialog(SMDialogProgress, dialogData.value);
let submitData = new FormData();
// add file if there is one
if (form.controls.file.value instanceof File) {
submitData.append("file", form.controls.file.value);
dialogDataSetStatus(
dialogData.value,
`Uploading File: ${form.controls.file.value.name}`,
0,
true,
);
}
submitData.append("title", form.controls.title.value as string);
submitData.append(
"security_type",
form.controls.security_type.value as string,
);
submitData.append(
"security_data",
form.controls.security_type.value == ""
? ""
: (form.controls.security_data.value as string),
);
submitData.append(
"description",
form.controls.description.value as string,
);
let apiRequest: ApiOptions = {
url: "/media",
body: submitData,
headers: {
"Content-Type": "multipart/form-data",
},
progress: (progressEvent) => {
dialogDataSetStatus(
dialogData.value,
"",
Math.floor(
(progressEvent.loaded / progressEvent.total) * 100,
),
false,
);
},
};
if (submitData.has("file") == true) {
apiRequest.chunk = "file";
}
if (route.params.id) {
apiRequest.url = "/media/{id}";
apiRequest.method = "PUT";
apiRequest.params = {
id: route.params.id,
};
}
api.chunk(apiRequest)
.then((result) => {
if (submitData.has("file") == true) {
dialogDataSetStatus(
dialogData.value,
"Upload Complete",
100,
false,
);
}
dialogDataSetStatus(dialogData.value, "Processing", 0, true);
const mediaJobId = (result.data as MediaJobResponse).media_job
.id;
const mediaJobUpdate = async () => {
api.get({
url: "/media/jobs/{id}",
params: {
id: mediaJobId,
},
})
.then((result) => {
const data = result.data as MediaJobResponse;
const statusText = toTitleCase(
data.media_job.status_text,
);
if (data.media_job.status != "complete") {
if (data.media_job.status == "queued") {
dialogDataSetStatus(
dialogData.value,
"Queued for processing",
0,
false,
);
} else if (
data.media_job.status == "processing"
) {
dialogDataSetStatus(
dialogData.value,
statusText,
data.media_job.progress,
false,
);
} else if (
data.media_job.status == "invalid" ||
data.media_job.status == "failed"
) {
useToastStore().addToast({
title: "Error Processing Media",
content: statusText,
type: "danger",
});
form.controls.file.setValidationResult(
false,
statusText,
);
closeDialog();
enableFormCallBack();
return;
}
window.setTimeout(mediaJobUpdate, 500);
} else {
useToastStore().addToast({
title: route.params.id
? "Media Updated"
: "Media Created",
content: route.params.id
? "The media item has been updated."
: "The media item been created.",
type: "success",
});
closeDialog();
enableFormCallBack();
// return to dashboard
const urlParams = new URLSearchParams(
window.location.search,
);
const returnUrl = urlParams.get("return");
if (returnUrl) {
router.push(decodeURIComponent(returnUrl));
} else {
router.push({
name: "dashboard-media-list",
});
}
return;
}
})
.catch(() => {
useToastStore().addToast({
title: "Error Uploading Media",
content: "A server error occurred.",
type: "danger",
});
closeDialog();
enableFormCallBack();
});
};
mediaJobUpdate();
})
.catch((error) => {
if (error.status == 413) {
form.controls.file.setValidationResult(
false,
"The file size is too large",
);
useToastStore().addToast({
title: "Error Uploading Media",
content: "The file size is too large.",
type: "danger",
});
} else {
useToastStore().addToast({
title: "Error Uploading Media",
content: "A server error occurred.",
type: "danger",
});
}
closeDialog();
enableFormCallBack();
});
} else {
let successCount = 0;
let errorCount = 0;
(route.params.id as string).split(",").forEach(async (id) => {
try {
let data = {
title: form.controls.title.value,
content: form.controls.content.value,
};
await api.put({
url: "/media/{id}",
params: {
id: id,
},
body: data,
});
successCount++;
} catch (err) {
errorCount++;
}
});
if (errorCount === 0) {
useToastStore().addToast({
title: "Media Updated",
content: `The selected media have been updated.`,
type: "success",
});
} else if (successCount === 0) {
useToastStore().addToast({
title: "Error Updating Media",
content: "An unexpected server error occurred.",
type: "danger",
});
} else {
useToastStore().addToast({
title: "Some Media Updated",
content: `Only ${successCount} media items where updated. ${errorCount} could not because of an unexpected error.`,
type: "warning",
});
}
}
};
const handleFailValidation = () => {
useToastStore().addToast({
title: "Save Error",
content:
"There are some errors in the form. Fix these before continuing.",
type: "danger",
});
};
const handleDelete = async () => {
let result = await openDialog(DialogConfirm, {
title: "Delete File?",
text: `Are you sure you want to delete the file <strong>${form.controls.title.value}</strong>?`,
cancel: {
type: "secondary",
label: "Cancel",
},
confirm: {
type: "danger",
label: "Delete File",
},
});
if (result) {
try {
await api.delete({
url: "/media/{id}",
params: {
id: route.params.id,
},
});
router.push({ name: "media" });
} catch (error) {
useToastStore().addToast({
title: "Error Deleting File",
content:
error.data?.message ||
"An unexpected server error occurred",
type: "danger",
});
}
}
};
const computedFileSize = computed(() => {
if (isNaN(+fileData.size) == true) {
return fileData.size;
}
return bytesReadable(fileData.size);
});
watch(
() => form.controls.file.value,
(newValue) => {
if (typeof newValue === "object" && newValue !== null) {
if ("type" in newValue && typeof newValue.type === "string") {
fileData.mime_type = newValue.type;
} else if (
"mime_type" in newValue &&
typeof newValue.mime_type === "string"
) {
fileData.mime_type = newValue.mime_type;
}
if ("size" in newValue && typeof newValue.size === "number") {
fileData.size = newValue.size;
}
}
fileData.mime_type =
(newValue as File).type || (newValue as Media).mime_type;
fileData.size = (newValue as File).size;
if ((form.controls.title.value as string).length == 0) {
form.controls.title.value = (newValue as File).name
.replace(/\.[^/.]+$/, "")
.replace(/[^\w\s]/g, " ")
.toLowerCase()
.replace(/\b\w/g, (c) => c.toUpperCase());
}
},
);
handleLoad();
</script>
<style lang="scss">
.page-dashboard-media-edit {
.media-container {
display: flex;
justify-content: center;
align-items: center;
}
}
</style>

View File

@@ -1,484 +0,0 @@
<template>
<SMPageStatus v-if="!userHasPermission('admin/media')" :status="403" />
<template v-else>
<SMMastHead
title="Media"
:back-link="{ name: 'dashboard' }"
back-title="Return to Dashboard" />
<div class="max-w-7xl mx-auto mt-8 px-8">
<div
class="flex flex-col md:flex-row gap-4 items-center flex-justify-between mb-4">
<router-link
role="button"
:to="{ name: 'dashboard-media-create' }"
class="font-medium w-full md:w-auto text-center px-6 py-3.1 rounded-md hover:shadow-md transition bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
>Upload Media</router-link
>
<SMInput
v-model="itemSearch"
label="Search"
class="w-full md:max-w-xl"
@keyup.enter="handleSearch">
<template #append>
<button
type="button"
class="font-medium px-4 py-3.1 rounded-r-2 hover:shadow-md transition bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
@click="handleSearch">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-6">
<path
d="M796-121 533-384q-30 26-69.959 40.5T378-329q-108.162 0-183.081-75Q120-479 120-585t75-181q75-75 181.5-75t181 75Q632-691 632-584.85 632-542 618-502q-14 40-42 75l264 262-44 44ZM377-389q81.25 0 138.125-57.5T572-585q0-81-56.875-138.5T377-781q-82.083 0-139.542 57.5Q180-666 180-585t57.458 138.5Q294.917-389 377-389Z"
fill="currentColor" />
</svg>
</button>
</template>
</SMInput>
</div>
<SMLoading large v-if="itemsLoading" />
<div
v-else-if="!itemsLoading && items.length == 0"
class="py-12 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-24 text-gray-5">
<path
d="M453-280h60v-240h-60v240Zm26.982-314q14.018 0 23.518-9.2T513-626q0-14.45-9.482-24.225-9.483-9.775-23.5-9.775-14.018 0-23.518 9.775T447-626q0 13.6 9.482 22.8 9.483 9.2 23.5 9.2Zm.284 514q-82.734 0-155.5-31.5t-127.266-86q-54.5-54.5-86-127.341Q80-397.681 80-480.5q0-82.819 31.5-155.659Q143-709 197.5-763t127.341-85.5Q397.681-880 480.5-880q82.819 0 155.659 31.5Q709-817 763-763t85.5 127Q880-563 880-480.266q0 82.734-31.5 155.5T763-197.684q-54 54.316-127 86Q563-80 480.266-80Zm.234-60Q622-140 721-239.5t99-241Q820-622 721.188-721 622.375-820 480-820q-141 0-240.5 98.812Q140-622.375 140-480q0 141 99.5 240.5t241 99.5Zm-.5-340Z"
fill="currentColor" />
</svg>
<p class="text-lg text-gray-5">
{{ "No media where found" }}
</p>
</div>
<template v-else>
<SMPagination
v-if="items.length < itemsTotal"
class="mb-4"
v-model="itemsPage"
:total="itemsTotal"
:per-page="itemsPerPage" />
<SMTable
:headers="headers"
:items="items"
class="sm-table-media mb-4">
<template #item-select="item">
<SMCheckbox
v-model="itemsSelected[item.id]"
@click.stop />
</template>
<template #item-size="item">
{{ bytesReadable(item.size) }}
</template>
<template #item-title="item">
<div class="flex gap-2">
<div
class="w-100 h-100 max-h-15 max-w-20 mr-2 bg-contain bg-no-repeat bg-center relative"
:style="{
backgroundImage: `url('${mediaGetThumbnail(
item,
)}')`,
}">
<div
v-if="item.security_type != ''"
class="absolute right--1 top--1 h-4 w-4">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24">
<title>locked</title>
<path
d="M18,8C19.097,8 20,8.903 20,10L20,20C20,21.097 19.097,22 18,22L6,22C4.903,22 4,21.097 4,20L4,10C4,8.89 4.9,8 6,8L7,8L7,6C7,3.257 9.257,1 12,1C14.743,1 17,3.257 17,6L17,8L18,8M12,3C10.354,3 9,4.354 9,6L9,8L15,8L15,6C15,4.354 13.646,3 12,3Z" />
</svg>
</div>
</div>
<div class="flex flex-col flex-justify-center">
<span>{{ item.title }}</span>
<span class="small">({{ item.name }})</span>
</div>
</div>
</template>
<template #item-actions="item">
<button
type="button"
class="bg-transparent cursor-pointer hover:text-sky-5"
title="Edit"
@click="handleEdit(item)">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-6">
<path
d="M180-180h44l443-443-44-44-443 443v44Zm614-486L666-794l42-42q17-17 42-17t42 17l44 44q17 17 17 42t-17 42l-42 42Zm-42 42L248-120H120v-128l504-504 128 128Zm-107-21-22-22 44 44-22-22Z"
fill="currentColor" />
</svg>
</button>
<a
:href="mediaGetWebURL(item)"
class="bg-transparent cursor-pointer hover:text-sky-5"
title="Download"
target="_blank">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-6">
<path
d="M220-160q-24 0-42-18t-18-42v-143h60v143h520v-143h60v143q0 24-18 42t-42 18H220Zm260-153L287-506l43-43 120 120v-371h60v371l120-120 43 43-193 193Z"
fill="currrentColor" />
</svg>
</a>
<button
type="button"
class="bg-transparent cursor-pointer hover:text-red-7"
title="Delete"
@click="handleDelete(item)">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-6">
<path
d="M261-120q-24.75 0-42.375-17.625T201-180v-570h-41v-60h188v-30h264v30h188v60h-41v570q0 24-18 42t-42 18H261Zm438-630H261v570h438v-570ZM367-266h60v-399h-60v399Zm166 0h60v-399h-60v399ZM261-750v570-570Z"
fill="currentColor" />
</svg>
</button>
</template>
</SMTable>
<div class="flex flex-justify-start gap-4 flex-items-center">
<button
type="button"
class="font-medium px-6 py-1.5 rounded-md hover:shadow-md transition text-sm bg-sky-600 hover:bg-sky-500 text-white cursor-pointer disabled:bg-gray-3 disabled:text-white disabled:cursor-not-allowed disabled:hover:shadow-none"
:disabled="computedSelectedCount == 0"
@click="handleEditSelected">
Edit Selected
</button>
<button
type="button"
class="font-medium px-6 py-1.5 rounded-md hover:shadow-md transition text-sm bg-red-600 hover:bg-red-500 text-white cursor-pointer disabled:bg-gray-3 disabled:text-white disabled:cursor-not-allowed disabled:hover:shadow-none"
:disabled="computedSelectedCount == 0"
@click="handleDeleteSelected">
Delete Selected
</button>
<div class="small">
{{ computedSelectedCount }} selected
</div>
</div>
</template>
</div>
</template>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { openDialog } from "../../components/SMDialog";
import SMDialogConfirm from "../../components/dialogs/SMDialogConfirm.vue";
import { api } from "../../helpers/api";
import { Media, MediaCollection } from "../../helpers/api.types";
import { SMDate } from "../../helpers/datetime";
import { bytesReadable } from "../../helpers/types";
import { useToastStore } from "../../store/ToastStore";
import SMInput from "../../components/SMInput.vue";
import SMMastHead from "../../components/SMMastHead.vue";
import SMTable from "../../components/SMTable.vue";
import SMPagination from "../../components/SMPagination.vue";
import SMLoading from "../../components/SMLoading.vue";
import { addQueryParam, updateRouterParams } from "../../helpers/url";
import { userHasPermission } from "../../helpers/utils";
import SMPageStatus from "../../components/SMPageStatus.vue";
import SMCheckbox from "../../components/SMCheckbox.vue";
import { mediaGetThumbnail, mediaGetWebURL } from "../../helpers/media";
const route = useRoute();
const router = useRouter();
const toastStore = useToastStore();
const items = ref([]);
const itemsLoading = ref(true);
const itemSearch = ref((route.query.search as string) || "");
const itemsTotal = ref(0);
const itemsPerPage = 25;
const itemsPage = ref(parseInt((route.query.page as string) || "1"));
const itemsSelected = ref({});
const headers = [
{ text: "", value: "select", sortable: false },
{ text: "Title (Name)", value: "title", sortable: true },
{ text: "Size", value: "size", sortable: true },
{ text: "Uploaded By", value: "user.display_name", sortable: true },
{ text: "Actions", value: "actions" },
];
/**
* Watch if page number changes.
*/
watch(itemsPage, () => {
handleLoad();
});
/**
* Handle searching for item.
*/
const handleSearch = () => {
itemsPage.value = 1;
handleLoad();
};
/**
* Handle loading the page and list
*/
const handleLoad = async () => {
itemsLoading.value = true;
items.value = [];
itemsTotal.value = 0;
updateRouterParams(router, {
search: itemSearch.value,
page: itemsPage.value == 1 ? "" : itemsPage.value.toString(),
});
try {
let params = {
page: itemsPage.value,
limit: itemsPerPage,
sort: "-created_at",
};
if (itemSearch.value.length > 0) {
params[
"filter"
] = `title:${itemSearch.value},OR,name:${itemSearch.value},OR,description:${itemSearch.value}`;
}
let result = await api.get({
url: "/media",
params: params,
});
const data = result.data as MediaCollection;
data.media.forEach(async (row) => {
if (row.created_at !== "undefined") {
row.created_at = new SMDate(row.created_at, {
format: "ymd",
utc: true,
}).relative();
}
if (row.updated_at !== "undefined") {
row.updated_at = new SMDate(row.updated_at, {
format: "ymd",
utc: true,
}).relative();
}
if (
Object.prototype.hasOwnProperty.call(
itemsSelected.value,
row.id,
) == false
) {
itemsSelected.value[row.id] = false;
}
items.value.push(row);
});
itemsTotal.value = data.total;
} catch (error) {
if (error.status != 404) {
toastStore.addToast({
title: "Server Error",
content:
"An error occurred retrieving the list from the server.",
type: "danger",
});
}
} finally {
itemsLoading.value = false;
}
};
const handleSelect = (item: Media) => {
if (Object.prototype.hasOwnProperty.call(itemsSelected.value, item.id)) {
itemsSelected.value[item.id] = !itemsSelected.value[item.id];
} else {
itemsSelected.value[item.id] = true;
}
};
/**
* User requests to edit the item
* @param {Media} item The media item.
*/
const handleEdit = (item: Media) => {
router.push({
name: "dashboard-media-edit",
params: { id: item.id },
query: {
return: encodeURIComponent(
window.location.pathname + window.location.search,
),
},
});
};
/**
* Request to delete a media item from the server.
* @param {Media} item The media object to delete.
*/
const handleDelete = async (item: Media) => {
let result = await openDialog(SMDialogConfirm, {
title: "Delete File?",
text: `Are you sure you want to delete the file <strong>${item.title}</strong>?`,
cancel: {
type: "secondary",
label: "Cancel",
},
confirm: {
type: "danger",
label: "Delete File",
},
});
if (result == true) {
try {
await api.delete({
url: "/media/{id}",
params: {
id: item.id,
},
});
toastStore.addToast({
title: "File Deleted",
content: `The file ${item.title} has been deleted.`,
type: "success",
});
handleLoad();
} catch (error) {
toastStore.addToast({
title: "Error Deleting File",
content:
error.data?.message ||
"An unexpected server error occurred",
type: "danger",
});
}
}
};
/**
* Request to delete selected media item from the server.
*/
const handleDeleteSelected = async () => {
let result = await openDialog(SMDialogConfirm, {
title: "Delete Files?",
text: `Are you sure you want to delete the <strong>${computedSelectedCount.value}</strong> selected files?`,
cancel: {
type: "secondary",
label: "Cancel",
},
confirm: {
type: "danger",
label: "Delete File",
},
});
if (result == true) {
itemsLoading.value = true;
let errorCount = 0;
let successCount = 0;
const deleteItems = Object.entries(itemsSelected.value).filter(
([key, value]) => value === true,
);
await Promise.all(
deleteItems.map(async ([key, value]) => {
// Perform actions for each item that is true
// Perform asynchronous operation
try {
await api.delete({
url: "/media/{id}",
params: {
id: key,
},
});
successCount++;
} catch (error) {
errorCount++;
}
}),
);
if (errorCount === 0) {
toastStore.addToast({
title: "Files Deleted",
content: `The selected files have been deleted.`,
type: "success",
});
} else if (successCount === 0) {
toastStore.addToast({
title: "Error Deleting Files",
content: "An unexpected server error occurred.",
type: "danger",
});
} else {
toastStore.addToast({
title: "Some Files Deleted",
content: `Only ${successCount} files where deleted. ${errorCount} could not because of an unexpected error.`,
type: "warning",
});
}
handleLoad();
}
};
/**
* Request to edit selected media item from the server.
*/
const handleEditSelected = async () => {
const editItems = Object.entries(itemsSelected.value)
.filter(([key, value]) => value === true)
.map(([key, value]) => key)
.join(",");
router.push({
name: "dashboard-media-edit",
params: { id: editItems },
query: {
return: encodeURIComponent(
window.location.pathname + window.location.search,
),
},
});
};
/**
* Handle the user requesting to download the item.
* @param {Media} item The media item.
*/
const handleDownload = (item: Media) => {
// window.open(`${item.url}?download=1`, "_blank");
// window.open(addQueryParam(mediaGetWebURL(item), "download", "1"), "_blank");
window.open(mediaGetWebURL(item), "_blank");
};
const computedSelectedCount = computed(() => {
const selectedValues = Object.values(itemsSelected.value);
const trueValues = selectedValues.filter((value) => value === true);
return trueValues.length;
});
handleLoad();
</script>
<style lang="scss">
.sm-table-media {
tbody tr td:last-child {
white-space: nowrap;
}
}
</style>

View File

@@ -1,203 +0,0 @@
<template>
<SMPageStatus v-if="!userHasPermission('admin/shortlinks')" :status="403" />
<template v-else>
<SMMastHead
:title="pageHeading"
:back-link="
route.params.id || isCreating
? { name: 'dashboard-shortlink-list' }
: { name: 'dashboard' }
"
:back-title="
route.params.id || isCreating
? 'Back to Shortlinks'
: 'Back to Dashboard'
" />
<SMLoading v-if="form.loading()" />
<div v-else class="max-w-4xl mx-auto px-4 mt-8">
<SMForm :model-value="form" @submit="handleSubmit">
<SMInput class="mb-4" control="code" />
<SMInput
class="mb-4"
type="static"
v-model="used"
label="Times used" />
<SMInput class="mb-4" control="url" />
<input
role="button"
type="submit"
class="font-medium px-6 py-1.5 rounded-md hover:shadow-md transition text-sm bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
:value="saveButtonLabel" />
</SMForm>
</div>
</template>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import SMForm from "../../components/SMForm.vue";
import SMInput from "../../components/SMInput.vue";
import { api } from "../../helpers/api";
import { ShortlinkResponse } from "../../helpers/api.types";
import { Form, FormControl } from "../../helpers/form";
import { And, Length, Max, Min, Required } from "../../helpers/validate";
import SMMastHead from "../../components/SMMastHead.vue";
import { useToastStore } from "../../store/ToastStore";
import SMPageStatus from "../../components/SMPageStatus.vue";
import { userHasPermission } from "../../helpers/utils";
const route = useRoute();
const router = useRouter();
const isCreating = route.path.endsWith("/create");
let form = reactive(
Form({
code: FormControl("", And([Required(), Length(4)])),
url: FormControl("", And([Required(), Min(4), Max(255)])),
}),
);
const used = ref(0);
/**
* Load the page data.
* @param enableFormCallBack
*/
const loadData = async (enableFormCallBack) => {
if (route.params.id) {
try {
form.loading(true);
const result = await api.get({
url: "/shortlinks/{id}",
params: {
id: route.params.id,
},
});
const data = result.data as ShortlinkResponse;
if (data && data.shortlink) {
form.controls.code.value = data.shortlink.code;
form.controls.url.value = data.shortlink.url;
used.value = data.shortlink.used;
}
} catch (error) {
form.apiErrors(error, (message) => {
useToastStore().addToast({
title: "An error occurred",
content: message,
type: "danger",
});
});
enableFormCallBack();
} finally {
form.loading(false);
}
} else {
let foundCode = false;
while (foundCode == false) {
const randomCode = Math.random()
.toString(36)
.substring(2, 6)
.toLowerCase();
try {
await api.get({
url: "/shortlinks",
params: {
code: randomCode,
},
});
} catch (err) {
foundCode = true;
if (err.status == 404) {
form.controls.code.value = randomCode;
}
}
}
}
};
/**
* Handle the user submitting the form.
*/
const handleSubmit = async () => {
try {
form.loading(true);
if (isCreating == false) {
await api.put({
url: "/shortlinks/{id}",
params: {
id: route.params.id,
},
body: {
code: form.controls.code.value,
url: form.controls.url.value,
},
});
useToastStore().addToast({
title: "Shortlink Updated",
content: "The shortlink has been updated.",
type: "success",
});
} else {
await api.post({
url: "/shortlinks",
body: {
code: form.controls.code.value,
url: form.controls.url.value,
},
});
useToastStore().addToast({
title: "Shortlink Created",
content: "The shortlink has been created.",
type: "success",
});
}
const urlParams = new URLSearchParams(window.location.search);
const returnUrl = urlParams.get("return");
if (returnUrl) {
router.push(decodeURIComponent(returnUrl));
} else {
router.push({ name: "dashboard-shortlink-list" });
}
} catch (error) {
form.apiErrors(error, (message) => {
useToastStore().addToast({
title: "An error occurred",
content: message,
type: "danger",
});
});
} finally {
form.loading(false);
}
};
const pageHeading = computed(() => {
return route.params.id == null ? "Create Shortlink" : "Edit Shortlink";
});
const saveButtonLabel = computed(() => {
return route.params.id == null ? "Create" : "Update";
});
loadData();
</script>
<style lang="scss">
.page-dashboard-account-details {
h3 {
margin-top: 0;
margin-bottom: 16px;
}
}
</style>

View File

@@ -1,295 +0,0 @@
<template>
<SMPageStatus v-if="!userHasPermission('admin/shortlinks')" :status="403" />
<template v-else>
<SMMastHead
title="Shortlinks"
:back-link="{ name: 'dashboard' }"
back-title="Return to Dashboard" />
<div class="max-w-7xl mx-auto mt-8 px-8">
<div
class="flex flex-col md:flex-row gap-4 items-center flex-justify-between mb-4">
<router-link
role="button"
:to="{ name: 'dashboard-shortlink-create' }"
class="font-medium w-full md:w-auto text-center px-6 py-3.1 rounded-md hover:shadow-md transition bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
>Create Link</router-link
>
<SMInput
v-model="itemSearch"
label="Search"
class="w-full md:max-w-xl"
@keyup.enter="handleSearch">
<template #append>
<button
type="button"
class="font-medium px-4 py-3.1 rounded-r-2 hover:shadow-md transition bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
@click="handleSearch">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-6">
<path
d="M796-121 533-384q-30 26-69.959 40.5T378-329q-108.162 0-183.081-75Q120-479 120-585t75-181q75-75 181.5-75t181 75Q632-691 632-584.85 632-542 618-502q-14 40-42 75l264 262-44 44ZM377-389q81.25 0 138.125-57.5T572-585q0-81-56.875-138.5T377-781q-82.083 0-139.542 57.5Q180-666 180-585t57.458 138.5Q294.917-389 377-389Z"
fill="currentColor" />
</svg>
</button>
</template>
</SMInput>
</div>
<SMLoading v-if="itemsLoading" />
<div
v-else-if="!itemsLoading && items.length == 0"
class="py-12 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-24 text-gray-5">
<path
d="M453-280h60v-240h-60v240Zm26.982-314q14.018 0 23.518-9.2T513-626q0-14.45-9.482-24.225-9.483-9.775-23.5-9.775-14.018 0-23.518 9.775T447-626q0 13.6 9.482 22.8 9.483 9.2 23.5 9.2Zm.284 514q-82.734 0-155.5-31.5t-127.266-86q-54.5-54.5-86-127.341Q80-397.681 80-480.5q0-82.819 31.5-155.659Q143-709 197.5-763t127.341-85.5Q397.681-880 480.5-880q82.819 0 155.659 31.5Q709-817 763-763t85.5 127Q880-563 880-480.266q0 82.734-31.5 155.5T763-197.684q-54 54.316-127 86Q563-80 480.266-80Zm.234-60Q622-140 721-239.5t99-241Q820-622 721.188-721 622.375-820 480-820q-141 0-240.5 98.812Q140-622.375 140-480q0 141 99.5 240.5t241 99.5Zm-.5-340Z"
fill="currentColor" />
</svg>
<p class="text-lg text-gray-5">
{{ "No shortlinks where found" }}
</p>
</div>
<template v-else>
<SMPagination
v-if="items.length < itemsTotal"
class="mb-4"
v-model="itemsPage"
:total="itemsTotal"
:per-page="itemsPerPage" />
<SMTable
v-if="items.length > 0"
class="sm-table-shortlinks"
:headers="headers"
:items="items">
<template #item-actions="item">
<button
type="button"
class="bg-transparent cursor-pointer hover:text-sky-5"
title="Edit"
@click="handleEdit(item)">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-6">
<path
d="M180-180h44l443-443-44-44-443 443v44Zm614-486L666-794l42-42q17-17 42-17t42 17l44 44q17 17 17 42t-17 42l-42 42Zm-42 42L248-120H120v-128l504-504 128 128Zm-107-21-22-22 44 44-22-22Z"
fill="currentColor" />
</svg>
</button>
<button
type="button"
class="bg-transparent cursor-pointer hover:text-sky-5"
title="Copy Shortlink"
@click="handleCopy(item)">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-6">
<path
d="M180-120q-26 0-43-17t-17-43v-600q0-26 17-43t43-17h202q7-35 34.5-57.5T480-920q36 0 63.5 22.5T578-840h202q26 0 43 17t17 43v600q0 26-17 43t-43 17H180Zm0-60h600v-600h-60v90H240v-90h-60v600Zm300-600q17 0 28.5-11.5T520-820q0-17-11.5-28.5T480-860q-17 0-28.5 11.5T440-820q0 17 11.5 28.5T480-780Z"
fill="currentColor" />
</svg>
</button>
<button
type="button"
class="bg-transparent cursor-pointer hover:text-red-7"
title="Delete"
@click="handleDelete(item)">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-6">
<path
d="M261-120q-24.75 0-42.375-17.625T201-180v-570h-41v-60h188v-30h264v30h188v60h-41v570q0 24-18 42t-42 18H261Zm438-630H261v570h438v-570ZM367-266h60v-399h-60v399Zm166 0h60v-399h-60v399ZM261-750v570-570Z"
fill="currentColor" />
</svg>
</button>
</template>
</SMTable>
</template>
</div>
</template>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { openDialog } from "../../components/SMDialog";
import DialogConfirm from "../../components/dialogs/SMDialogConfirm.vue";
import { api, getApiResultData } from "../../helpers/api";
import SMTable from "../../components/SMTable.vue";
import SMMastHead from "../../components/SMMastHead.vue";
import { useToastStore } from "../../store/ToastStore";
import SMInput from "../../components/SMInput.vue";
import { updateRouterParams } from "../../helpers/url";
import { Shortlink, ShortlinkCollection } from "../../helpers/api.types";
import SMLoading from "../../components/SMLoading.vue";
import SMPagination from "../../components/SMPagination.vue";
import { userHasPermission } from "../../helpers/utils";
const route = useRoute();
const router = useRouter();
const items = ref([]);
const itemsLoading = ref(false);
const itemSearch = ref((route.query.search as string) || "");
const itemsTotal = ref(0);
const itemsPerPage = 25;
const itemsPage = ref(parseInt((route.query.page as string) || "1"));
const headers = [
{ text: "Code", value: "code", sortable: true },
{ text: "URL", value: "url", sortable: true },
{ text: "Used", value: "used", sortable: true },
{ text: "Actions", value: "actions" },
];
/**
* Watch if page number changes.
*/
watch(itemsPage, () => {
handleLoad();
});
/**
* Handle searching for item.
*/
const handleSearch = () => {
itemsPage.value = 1;
handleLoad();
};
/**
* Handle user selecting option in action button.
* @param {Shortlink} item The item.
* @param option
*/
const handleActionButton = (item: Shortlink, option: string): void => {
if (option.length == 0) {
handleEdit(item);
} else if (option.toLowerCase() == "copy") {
handleCopy(item);
} else if (option.toLowerCase() == "delete") {
handleDelete(item);
}
};
/**
* Handle loading the page and list
*/
const handleLoad = async () => {
itemsLoading.value = true;
items.value = [];
itemsTotal.value = 0;
updateRouterParams(router, {
search: itemSearch.value,
page: itemsPage.value == 1 ? "" : itemsPage.value.toString(),
});
try {
let params = {
page: itemsPage.value,
limit: itemsPerPage,
};
if (itemSearch.value.length > 0) {
params[
"filter"
] = `code:${itemSearch.value},OR,url:${itemSearch.value}`;
}
let result = await api.get({
url: "/shortlinks",
params: params,
});
const collection = getApiResultData<ShortlinkCollection>(result);
items.value = collection.shortlinks;
itemsTotal.value = collection.total;
} catch (err) {
/* empty */
}
itemsLoading.value = false;
};
const handleEdit = (shortlink: Shortlink) => {
router.push({
name: "dashboard-shortlink-edit",
params: { id: shortlink.id },
query: {
return: encodeURIComponent(
window.location.pathname + window.location.search,
),
},
});
};
const handleCopy = (shortlink: Shortlink) => {
navigator.clipboard
.writeText(`https://stemmech.com.au/${shortlink.code}`)
.then(() => {
useToastStore().addToast({
title: "Copied to Clipboard",
content: "The shortlink URL has been copied to the clipboard.",
type: "success",
});
})
.catch(() => {
useToastStore().addToast({
title: "Copy to Clipboard",
content: "Failed to copy the shortlink URL to the clipboard.",
type: "danger",
});
});
};
const handleDelete = async (shortlink: Shortlink) => {
let result = await openDialog(DialogConfirm, {
title: "Delete User?",
text: `Are you sure you want to delete the user <strong>${shortlink.code}</strong>?`,
cancel: {
type: "secondary",
label: "Cancel",
},
confirm: {
type: "danger",
label: "Delete User",
},
});
if (result == true) {
try {
await api.delete(`shortlinks${shortlink.id}`);
handleLoad();
useToastStore().addToast({
title: "Shortlink Deleted",
content: "Shortlink deleted successfully.",
type: "success",
});
} catch (err) {
useToastStore().addToast({
title: "Server Error",
content:
"Shortlink could not be deleted because an error occurred.",
type: "danger",
});
}
}
};
handleLoad();
</script>
<style lang="scss">
.sm-table-shortlinks {
tbody tr td:last-child {
white-space: nowrap;
}
}
</style>

View File

@@ -1,263 +0,0 @@
<template>
<SMPageStatus
v-if="!userHasPermission('admin/users') && route.params.id"
:status="403" />
<template v-else>
<SMMastHead
:title="pageHeading"
:back-link="
route.params.id || isCreating
? { name: 'dashboard-user-list' }
: { name: 'dashboard' }
"
:back-title="
route.params.id || isCreating
? 'Back to Users'
: 'Back to Dashboard'
" />
<SMLoading v-if="form.loading()" />
<div v-else class="max-w-4xl mx-auto px-4 mt-8">
<SMForm :model-value="form" @submit="handleSubmit">
<SMInput class="mb-4" control="display_name" autofocus />
<SMInput class="mb-4" control="email" type="email" />
<SMInput class="mb-4" control="first_name"
>This field is optional</SMInput
>
<SMInput class="mb-4" control="last_name"
>This field is optional</SMInput
>
<SMInput class="mb-4" control="phone"
>This field is optional</SMInput
>
<template v-if="userStore.permissions.includes('admin/users')">
<h2 class="mt-8">Permissions</h2>
<SMCheckbox
label="Edit Users"
class="mt-4"
v-model="permissions.users" /><SMCheckbox
class="mt-4"
label="Edit Articles"
v-model="permissions.articles" /><SMCheckbox
class="mt-4"
label="Edit Events"
v-model="permissions.events" />
</template>
<div class="flex flex-justify-between mt-8">
<button
type="button"
v-if="!isCreating"
class="font-medium px-6 py-1.5 rounded-md hover:shadow-md transition text-sm bg-white border-1 border-sky-500 text-sky-500 cursor-pointer"
@click="handleChangePassword">
Change Password
</button>
<input
type="submit"
class="font-medium px-6 py-1.5 rounded-md hover:shadow-md transition text-sm bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
:value="computedSubmitLabel" />
</div>
</SMForm>
</div>
<div id="card-container"></div>
</template>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { openDialog } from "../../components/SMDialog";
import SMDialogChangePassword from "../../components/dialogs/SMDialogChangePassword.vue";
import SMForm from "../../components/SMForm.vue";
import SMInput from "../../components/SMInput.vue";
import { api } from "../../helpers/api";
import { UserResponse } from "../../helpers/api.types";
import { Form, FormControl } from "../../helpers/form";
import { And, Custom, Email, Phone, Required } from "../../helpers/validate";
import { useUserStore } from "../../store/UserStore";
import SMMastHead from "../../components/SMMastHead.vue";
import { useToastStore } from "../../store/ToastStore";
import { userHasPermission } from "../../helpers/utils";
import SMLoading from "../../components/SMLoading.vue";
import SMPageStatus from "../../components/SMPageStatus.vue";
import SMCheckbox from "../../components/SMCheckbox.vue";
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const isCreating = route.path.endsWith("/create");
const customRequire = async (value) => {
const control_names = ["display_name", "first_name", "last_name", "phone"];
if (value.length == 0) {
if (
userHasPermission("admin/users") &&
control_names.every((item) => form.controls[item].value.length == 0)
) {
control_names.forEach((item) => {
form.controls[item].clearValidations();
});
return true;
}
return "This field is required.";
}
return true;
};
let form = reactive(
Form({
display_name: FormControl("", Custom(customRequire)),
first_name: FormControl("", Custom(customRequire)),
last_name: FormControl("", Custom(customRequire)),
email: FormControl("", And([Required(), Email()])),
phone: FormControl("", Phone()),
}),
);
const permissions = ref({
users: false,
articles: false,
events: false,
});
/**
* Load the page data.
*/
const loadData = async () => {
if (route.params.id) {
try {
form.loading(true);
const result = await api.get({
url: "/users/{id}",
params: {
id: route.params.id,
},
});
const data = result.data as UserResponse;
if (data && data.user) {
form.controls.first_name.value = data.user.first_name;
form.controls.last_name.value = data.user.last_name;
form.controls.display_name.value = data.user.display_name;
form.controls.phone.value = data.user.phone;
form.controls.email.value = data.user.email;
}
} catch (error) {
form.apiErrors(error, (message) => {
useToastStore().addToast({
title: "An error occurred",
content: message,
type: "danger",
});
});
} finally {
form.loading(false);
}
} else if (isCreating == false) {
form.controls.first_name.value = userStore.firstName;
form.controls.last_name.value = userStore.lastName;
form.controls.display_name.value = userStore.displayName;
form.controls.phone.value = userStore.phone;
form.controls.email.value = userStore.email;
}
};
/**
* Handle the user submitting the form.
* @param enableFormCallBack
*/
const handleSubmit = async (enableFormCallBack) => {
try {
form.loading(true);
const id = route.params.id ? route.params.id : userStore.id;
if (isCreating == false) {
const result = await api.put({
url: "/users/{id}",
params: {
id: id,
},
body: {
first_name: form.controls.first_name.value,
last_name: form.controls.last_name.value,
display_name: form.controls.display_name.value,
email: form.controls.email.value,
phone: form.controls.phone.value,
},
});
const data = result.data as UserResponse;
if (route.params.id && data && data.user) {
userStore.setUserDetails(data.user);
}
useToastStore().addToast({
title: "Details Updated",
content: "The user has been updated.",
type: "success",
});
} else {
await api.post({
url: "/users",
params: {
id: id,
},
body: {
first_name: form.controls.first_name.value,
last_name: form.controls.last_name.value,
display_name: form.controls.display_name.value,
email: form.controls.email.value,
phone: form.controls.phone.value,
},
});
useToastStore().addToast({
title: "User Created",
content: "The user has been created.",
type: "success",
});
}
const urlParams = new URLSearchParams(window.location.search);
const returnUrl = urlParams.get("return");
if (returnUrl) {
router.push(decodeURIComponent(returnUrl));
} else {
router.push({ name: "dashboard-user-list" });
}
} catch (error) {
form.apiErrors(error, (message) => {
useToastStore().addToast({
title: "An error occurred",
content: message,
type: "danger",
});
});
enableFormCallBack();
} finally {
form.loading(false);
}
};
const handleChangePassword = () => {
openDialog(SMDialogChangePassword);
};
const pageHeading = computed(() => {
return route.params.id == null || route.params.id == userStore.id
? "My Details"
: "User Details";
});
const computedSubmitLabel = computed(() => {
return isCreating ? "Create" : "Update";
});
loadData();
// initCard();
</script>

View File

@@ -1,247 +0,0 @@
<template>
<SMPageStatus v-if="!userHasPermission('admin/users')" :status="403" />
<template v-else>
<SMMastHead
title="Users"
:back-link="{ name: 'dashboard' }"
back-title="Return to Dashboard" />
<div class="max-w-7xl mx-auto mt-8 px-4">
<div
class="flex flex-col md:flex-row gap-4 items-center flex-justify-between mb-4">
<router-link
role="button"
:to="{ name: 'dashboard-user-create' }"
class="font-medium w-full md:w-auto text-center px-6 py-3.1 rounded-md hover:shadow-md transition bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
>Create User</router-link
>
<SMInput
v-model="itemSearch"
label="Search"
class="w-full md:max-w-xl"
@keyup.enter="handleSearch">
<template #append>
<button
type="button"
class="font-medium px-4 py-3.1 rounded-r-2 hover:shadow-md transition bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
@click="handleSearch">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-6">
<path
d="M796-121 533-384q-30 26-69.959 40.5T378-329q-108.162 0-183.081-75Q120-479 120-585t75-181q75-75 181.5-75t181 75Q632-691 632-584.85 632-542 618-502q-14 40-42 75l264 262-44 44ZM377-389q81.25 0 138.125-57.5T572-585q0-81-56.875-138.5T377-781q-82.083 0-139.542 57.5Q180-666 180-585t57.458 138.5Q294.917-389 377-389Z"
fill="currentColor" />
</svg>
</button>
</template>
</SMInput>
</div>
<SMLoading v-if="itemsLoading" />
<div
v-else-if="!itemsLoading && items.length == 0"
class="py-12 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-24 text-gray-5">
<path
d="M453-280h60v-240h-60v240Zm26.982-314q14.018 0 23.518-9.2T513-626q0-14.45-9.482-24.225-9.483-9.775-23.5-9.775-14.018 0-23.518 9.775T447-626q0 13.6 9.482 22.8 9.483 9.2 23.5 9.2Zm.284 514q-82.734 0-155.5-31.5t-127.266-86q-54.5-54.5-86-127.341Q80-397.681 80-480.5q0-82.819 31.5-155.659Q143-709 197.5-763t127.341-85.5Q397.681-880 480.5-880q82.819 0 155.659 31.5Q709-817 763-763t85.5 127Q880-563 880-480.266q0 82.734-31.5 155.5T763-197.684q-54 54.316-127 86Q563-80 480.266-80Zm.234-60Q622-140 721-239.5t99-241Q820-622 721.188-721 622.375-820 480-820q-141 0-240.5 98.812Q140-622.375 140-480q0 141 99.5 240.5t241 99.5Zm-.5-340Z"
fill="currentColor" />
</svg>
<p class="text-lg text-gray-5">
{{ "No users where found" }}
</p>
</div>
<template v-else>
<SMPagination
v-if="items.length < itemsTotal"
class="mb-4"
v-model="itemsPage"
:total="itemsTotal"
:per-page="itemsPerPage" />
<SMTable :headers="headers" :items="items">
<template #item-actions="item">
<button
type="button"
class="bg-transparent cursor-pointer hover:text-sky-5"
title="Edit"
@click="handleEdit(item)">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-6">
<path
d="M180-180h44l443-443-44-44-443 443v44Zm614-486L666-794l42-42q17-17 42-17t42 17l44 44q17 17 17 42t-17 42l-42 42Zm-42 42L248-120H120v-128l504-504 128 128Zm-107-21-22-22 44 44-22-22Z"
fill="currentColor" />
</svg>
</button>
<button
type="button"
class="bg-transparent cursor-pointer hover:text-red-7"
title="Delete"
@click="handleDelete(item)">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
class="h-6">
<path
d="M261-120q-24.75 0-42.375-17.625T201-180v-570h-41v-60h188v-30h264v30h188v60h-41v570q0 24-18 42t-42 18H261Zm438-630H261v570h438v-570ZM367-266h60v-399h-60v399Zm166 0h60v-399h-60v399ZM261-750v570-570Z"
fill="currentColor" />
</svg>
</button>
</template>
</SMTable>
</template>
</div>
</template>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { openDialog } from "../../components/SMDialog";
import DialogConfirm from "../../components/dialogs/SMDialogConfirm.vue";
import { api, getApiResultData } from "../../helpers/api";
import { userHasPermission } from "../../helpers/utils";
import { SMDate } from "../../helpers/datetime";
import SMTable from "../../components/SMTable.vue";
import SMMastHead from "../../components/SMMastHead.vue";
import { useToastStore } from "../../store/ToastStore";
import SMInput from "../../components/SMInput.vue";
import { updateRouterParams } from "../../helpers/url";
import { User, UserCollection } from "../../helpers/api.types";
import SMLoading from "../../components/SMLoading.vue";
import SMPagination from "../../components/SMPagination.vue";
import SMPageStatus from "../../components/SMPageStatus.vue";
const route = useRoute();
const router = useRouter();
const items = ref([]);
const itemsLoading = ref(false);
const itemSearch = ref((route.query.search as string) || "");
const itemsTotal = ref(0);
const itemsPerPage = 25;
const itemsPage = ref(parseInt((route.query.page as string) || "1"));
const headers = [
{ text: "Display name", value: "display_name", sortable: true },
{ text: "First name", value: "first_name", sortable: true },
{ text: "Last name", value: "last_name", sortable: true },
{ text: "Email", value: "email", sortable: true },
{ text: "Actions", value: "actions" },
];
/**
* Watch if page number changes.
*/
watch(itemsPage, () => {
handleLoad();
});
/**
* Handle searching for item.
*/
const handleSearch = () => {
itemsPage.value = 1;
handleLoad();
};
/**
* Handle loading the page and list
*/
const handleLoad = async () => {
itemsLoading.value = true;
items.value = [];
itemsTotal.value = 0;
updateRouterParams(router, {
search: itemSearch.value,
page: itemsPage.value == 1 ? "" : itemsPage.value.toString(),
});
try {
let params = {
page: itemsPage.value,
limit: itemsPerPage,
};
if (itemSearch.value.length > 0) {
params[
"filter"
] = `title:${itemSearch.value},OR,content:${itemSearch.value}`;
}
let result = await api.get({
url: "/users",
params: params,
});
const userCollection = getApiResultData<UserCollection>(result);
items.value = userCollection.users;
items.value.forEach((row) => {
if (row.created_at !== "undefined") {
row.created_at = new SMDate(row.created_at, {
format: "yMd",
utc: true,
}).relative();
}
});
itemsTotal.value = userCollection.total;
} catch (err) {
/* empty */
}
itemsLoading.value = false;
};
const handleEdit = (user: User) => {
router.push({
name: "dashboard-user-edit",
params: { id: user.id },
query: {
return: encodeURIComponent(
window.location.pathname + window.location.search,
),
},
});
};
const handleDelete = async (user: User) => {
let result = await openDialog(DialogConfirm, {
title: "Delete User?",
text: `Are you sure you want to delete the user <strong>${user.display_name}</strong>?`,
cancel: {
type: "secondary",
label: "Cancel",
},
confirm: {
type: "danger",
label: "Delete User",
},
});
if (result == true) {
try {
// await api.delete(`users${user.id}`);
// handleLoad();
useToastStore().addToast({
title: "User Deleted",
content: "User deleted successfully.",
type: "success",
});
} catch (err) {
useToastStore().addToast({
title: "Server Error",
content: "User could not be deleted because an error occurred.",
type: "danger",
});
}
}
};
handleLoad();
</script>

View File

@@ -1,8 +0,0 @@
import { reactive } from "vue";
import { Form, FormControl, FormObject } from "../helpers/form";
export const form: FormObject = reactive(
Form({
password: FormControl("", Required()),
}),
);

View File

@@ -1,28 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>STEMMechanics</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="preload" as="image" href="https://www.stemmechanics.com.au/assets/home-hero.webp">
<link
rel="preload"
href="https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,400;0,700;1,400;1,700&display=swap"
as="style"
onload="this.onload=null;this.rel='stylesheet'"
/>
<noscript>
<link
href="https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,400;0,700;1,400;1,700&display=swap"
rel="stylesheet"
type="text/css"
/>
</noscript>
</head>
<body>
<div id="app"></div>
@vite('resources/js/main.js')
</body>
</html>

View File

@@ -1,10 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<style>{!! $css ?? '' !!}</style>
</head>
<body>
{!! $content ?? '' !!}
</body>
</html>

View File

@@ -1,130 +0,0 @@
<!DOCTYPE html>
<html
lang="{{ str_replace('_', '-', app()->getLocale()) }}"
xmlns="http://www.w3.org/1999/xhtml"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"
>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>STEMMechanics - Forgot Password</title>
<link
rel="noopener"
target="_blank"
href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800&display=swap"
rel="stylesheet"
/>
<!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<style>
@import url("https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800&display=swap");
</style>
</head>
<body>
<table
cellspacing="0"
cellpadding="0"
border="0"
role="presentation"
style="
width: 100%;
padding: 2rem;
font-size: 1.1rem;
color: #000000;
font-family: Nunito, Arial, Helvetica, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
"
>
<tr>
<td>
<a href="https://www.stemmechanics.com.au/">
<img
alt="STEMMechanics Logo"
src="{{ $message->embed(public_path('assets').'/logo.webp') }}"
width="400"
height="62"
/>
</a>
</td>
</tr>
<tr>
<td><h2>Hey {{ $user?->display_name }},</h2></td>
</tr>
<tr>
<td>
We just need to confirm that this is your new email address. Click this link <a href="https://www.stemmechanics.com.au/verify-email?code={{ $code }}">stemmechanics.com.au/verify-email</a> and if you are asked, use the confirm code:
</td>
</tr>
<tr>
<td
align="center"
style="
font-size: 200%;
text-align: center;
padding-top: 2rem;
padding-bottom: 2rem;
letter-spacing: 0.5rem;
"
>
<strong>{{ $code }}</strong>
</td>
</tr>
<tr>
<td style="padding-bottom: 2rem">
But if you didn't ask to reset your password, you can delete
this email and your password will remain the same.
</td>
</tr>
<tr>
<td
align="center"
style="
font-size: 90%;
text-align: center;
padding-top: 2rem;
padding-bottom: 2rem;
border-top: 1px solid #ddd;
"
>
Need help or got feedback?
<a href="https://www.stemmechanics.com.au/contact"
>Contact us</a
>
or touch base at
<a href="https://twitter.com/stemmechanics"
>@stemmechanics</a
>.
</td>
</tr>
<tr>
<td
align="center"
style="
font-size: 80%;
text-align: center;
padding-top: 1rem;
padding-bottom: 2rem;
"
>
Sent by STEMMechanics &middot;
<a href="https://www.stemmechanics.com.au/"
>Visit our Website</a
>
&middot;
<a href="https://twitter.com/stemmechanics"
>@stemmechanics</a
><br />PO Box 36, Edmonton, QLD 4869, Australia
</td>
</tr>
</table>
</body>
</html>

View File

@@ -1,16 +0,0 @@
Hey {{ $user?->display_name }},
We just need to confirm that this is your new email address.
Enter the following URL in your browser:
https://www.stemmechanics.com.au/verify-email
and when asked, use the confirm code: {{ $code }}
Need help or got feedback? Contact us at https://www.stemmechanics.com.au/contact or touch base on twitter at @stemmechanics
--
Sent by STEMMechanics
https://www.stemmechanics.com.au/
PO Box 36, Edmonton, QLD 4869, Australia

View File

@@ -1,115 +0,0 @@
<!DOCTYPE html>
<html
lang="{{ str_replace('_', '-', app()->getLocale()) }}"
xmlns="http://www.w3.org/1999/xhtml"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"
>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>STEMMechanics - Forgot Password</title>
<link
rel="noopener"
target="_blank"
href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800&display=swap"
rel="stylesheet"
/>
<!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<style>
@import url("https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800&display=swap");
</style>
</head>
<body>
<table
cellspacing="0"
cellpadding="0"
border="0"
role="presentation"
style="
width: 100%;
padding: 2rem;
font-size: 1.1rem;
color: #000000;
font-family: Nunito, Arial, Helvetica, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
"
>
<tr>
<td>
<a href="https://www.stemmechanics.com.au/">
<img
alt="STEMMechanics Logo"
src="{{ $message->embed(public_path('assets').'/logo.webp') }}"
width="400"
height="62"
/>
</a>
</td>
</tr>
<tr>
<td><h2>Yo {{ $user?->display_name }}</h2></td>
</tr>
<tr>
<td style="padding-bottom: 2rem">
Just a quick word that your email has been changed to {{ $new_email }}.
</td>
</tr>
<tr>
<td style="padding-bottom: 2rem">
If this was not you, please contact us by replying to this email so we can disable your account.
</td>
</tr>
<tr>
<td
align="center"
style="
font-size: 90%;
text-align: center;
padding-top: 2rem;
padding-bottom: 2rem;
border-top: 1px solid #ddd;
"
>
Need help or got feedback?
<a href="https://www.stemmechanics.com.au/contact"
>Contact us</a
>
or touch base at
<a href="https://twitter.com/stemmechanics"
>@stemmechanics</a
>.
</td>
</tr>
<tr>
<td
align="center"
style="
font-size: 80%;
text-align: center;
padding-top: 1rem;
padding-bottom: 2rem;
"
>
Sent by STEMMechanics &middot;
<a href="https://www.stemmechanics.com.au/"
>Visit our Website</a
>
&middot;
<a href="https://twitter.com/stemmechanics"
>@stemmechanics</a
><br />PO Box 36, Edmonton, QLD 4869, Australia
</td>
</tr>
</table>
</body>
</html>

View File

@@ -1,12 +0,0 @@
Yo {{ $user?->display_name }}
Just a quick word that your email has been changed to {{ $new_email }}.
If this was not you, please contact us by replying to this email so we can disable your account.
Need help or got feedback? Contact us at https://www.stemmechanics.com.au/contact or touch base on twitter at @stemmechanics
--
Sent by STEMMechanics
https://www.stemmechanics.com.au/
PO Box 36, Edmonton, QLD 4869, Australia

View File

@@ -1,115 +0,0 @@
<!DOCTYPE html>
<html
lang="{{ str_replace('_', '-', app()->getLocale()) }}"
xmlns="http://www.w3.org/1999/xhtml"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"
>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>STEMMechanics - Forgot Password</title>
<link
rel="noopener"
target="_blank"
href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800&display=swap"
rel="stylesheet"
/>
<!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<style>
@import url("https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800&display=swap");
</style>
</head>
<body>
<table
cellspacing="0"
cellpadding="0"
border="0"
role="presentation"
style="
width: 100%;
padding: 2rem;
font-size: 1.1rem;
color: #000000;
font-family: Nunito, Arial, Helvetica, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
"
>
<tr>
<td>
<a href="https://www.stemmechanics.com.au/">
<img
alt="STEMMechanics Logo"
src="{{ $message->embed(public_path('assets').'/logo.webp') }}"
width="400"
height="62"
/>
</a>
</td>
</tr>
<tr>
<td><h2>Yo {{ $user?->display_name }}</h2></td>
</tr>
<tr>
<td style="padding-bottom: 2rem">
Just a quick word that your password has been changed.
</td>
</tr>
<tr>
<td style="padding-bottom: 2rem">
If this was not you, please contact us by replying to this email so we can disable your account.
</td>
</tr>
<tr>
<td
align="center"
style="
font-size: 90%;
text-align: center;
padding-top: 2rem;
padding-bottom: 2rem;
border-top: 1px solid #ddd;
"
>
Need help or got feedback?
<a href="https://www.stemmechanics.com.au/contact"
>Contact us</a
>
or touch base at
<a href="https://twitter.com/stemmechanics"
>@stemmechanics</a
>.
</td>
</tr>
<tr>
<td
align="center"
style="
font-size: 80%;
text-align: center;
padding-top: 1rem;
padding-bottom: 2rem;
"
>
Sent by STEMMechanics &middot;
<a href="https://www.stemmechanics.com.au/"
>Visit our Website</a
>
&middot;
<a href="https://twitter.com/stemmechanics"
>@stemmechanics</a
><br />PO Box 36, Edmonton, QLD 4869, Australia
</td>
</tr>
</table>
</body>
</html>

View File

@@ -1,12 +0,0 @@
Yo {{ $user?->display_name }}
Just a quick word that your password has been changed.
If this was not you, please contact us by replying to this email so we can disable your account.
Need help or got feedback? Contact us at https://www.stemmechanics.com.au/contact or touch base on twitter at @stemmechanics
--
Sent by STEMMechanics
https://www.stemmechanics.com.au/
PO Box 36, Edmonton, QLD 4869, Australia

View File

@@ -1,115 +0,0 @@
<!DOCTYPE html>
<html
lang="{{ str_replace('_', '-', app()->getLocale()) }}"
xmlns="http://www.w3.org/1999/xhtml"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"
>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>STEMMechanics - Forgot Password</title>
<link
rel="noopener"
target="_blank"
href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800&display=swap"
rel="stylesheet"
/>
<!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<style>
@import url("https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800&display=swap");
</style>
</head>
<body>
<table
cellspacing="0"
cellpadding="0"
border="0"
role="presentation"
style="
width: 100%;
padding: 2rem;
font-size: 1.1rem;
color: #000000;
font-family: Nunito, Arial, Helvetica, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
"
>
<tr>
<td>
<a href="https://www.stemmechanics.com.au/">
<img
alt="STEMMechanics Logo"
src="{{ $message->embed(public_path('assets').'/logo.webp') }}"
width="400"
height="62"
/>
</a>
</td>
</tr>
<tr>
<td><h2>Hi STEMMechanics,</h2></td>
</tr>
<tr>
<td style="padding-bottom: 2rem">
{{ $content }}
</td>
</tr>
<tr>
<td style="padding-bottom: 2rem">
<strong>From:</strong> {{ $name }} - {{ $email }}
</td>
</tr>
<tr>
<td
align="center"
style="
font-size: 90%;
text-align: center;
padding-top: 2rem;
padding-bottom: 2rem;
border-top: 1px solid #ddd;
"
>
Need help or got feedback?
<a href="https://www.stemmechanics.com.au/contact"
>Contact us</a
>
or touch base at
<a href="https://twitter.com/stemmechanics"
>@stemmechanics</a
>.
</td>
</tr>
<tr>
<td
align="center"
style="
font-size: 80%;
text-align: center;
padding-top: 1rem;
padding-bottom: 2rem;
"
>
Sent by STEMMechanics &middot;
<a href="https://www.stemmechanics.com.au/"
>Visit our Website</a
>
&middot;
<a href="https://twitter.com/stemmechanics"
>@stemmechanics</a
><br />PO Box 36, Edmonton, QLD 4869, Australia
</td>
</tr>
</table>
</body>
</html>

View File

@@ -1,8 +0,0 @@
{{ $content }}
From: {{ $name }} - {{ $email }}
--
Sent by STEMMechanics
https://www.stemmechanics.com.au/
PO Box 36, Edmonton, QLD 4869, Australia

View File

@@ -1,135 +0,0 @@
<!DOCTYPE html>
<html
lang="{{ str_replace('_', '-', app()->getLocale()) }}"
xmlns="http://www.w3.org/1999/xhtml"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"
>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>STEMMechanics - Forgot Password</title>
<link
rel="noopener"
target="_blank"
href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800&display=swap"
rel="stylesheet"
/>
<!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<style>
@import url("https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800&display=swap");
</style>
</head>
<body>
<table
cellspacing="0"
cellpadding="0"
border="0"
role="presentation"
style="
width: 100%;
padding: 2rem;
font-size: 1.1rem;
color: #000000;
font-family: Nunito, Arial, Helvetica, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
"
>
<tr>
<td>
<a href="https://www.stemmechanics.com.au/">
<img
alt="STEMMechanics Logo"
src="{{ $message->embed(public_path('assets').'/logo.webp') }}"
width="400"
height="62"
/>
</a>
</td>
</tr>
<tr>
<td><h2>Welcome {{ $user?->display_name }},</h2></td>
</tr>
<tr>
<td>
We've heard you would like to try out our workshops and courses!
</td>
</tr>
<tr>
<td>
Before we can let you loose on our website, we need to make sure you are a real person and not a pesky robot or cat. Click this link <a href="https://www.stemmechanics.com.au/verify-email?code={{ $code }}">stemmechanics.com.au/verify-email</a> and if you are asked, use the confirm code:
</td>
</tr>
<tr>
<td
align="center"
style="
font-size: 200%;
text-align: center;
padding-top: 2rem;
padding-bottom: 2rem;
letter-spacing: 0.5rem;
"
>
<strong>{{ $code }}</strong>
</td>
</tr>
<tr>
<td style="padding-bottom: 2rem">
But if you didn't ask to reset your password, you can delete
this email and your password will remain the same.
</td>
</tr>
<tr>
<td
align="center"
style="
font-size: 90%;
text-align: center;
padding-top: 2rem;
padding-bottom: 2rem;
border-top: 1px solid #ddd;
"
>
Need help or got feedback?
<a href="https://www.stemmechanics.com.au/contact"
>Contact us</a
>
or touch base at
<a href="https://twitter.com/stemmechanics"
>@stemmechanics</a
>.
</td>
</tr>
<tr>
<td
align="center"
style="
font-size: 80%;
text-align: center;
padding-top: 1rem;
padding-bottom: 2rem;
"
>
Sent by STEMMechanics &middot;
<a href="https://www.stemmechanics.com.au/"
>Visit our Website</a
>
&middot;
<a href="https://twitter.com/stemmechanics"
>@stemmechanics</a
><br />PO Box 36, Edmonton, QLD 4869, Australia
</td>
</tr>
</table>
</body>
</html>

View File

@@ -1,17 +0,0 @@
Welcome {{ $user?->display_name }},
We've heard you would like to try out our workshops and courses!
Before we can let you loose on our website, we need to make sure you are a real person and not a pesky robot or cat.
Enter the following URL in your browser:
https://www.stemmechanics.com.au/verify-email
and when asked, use the confirm code: {{ $code }}
Need help or got feedback? Contact us at https://www.stemmechanics.com.au/contact or touch base on twitter at @stemmechanics
--
Sent by STEMMechanics
https://www.stemmechanics.com.au/
PO Box 36, Edmonton, QLD 4869, Australia

View File

@@ -1,136 +0,0 @@
<!DOCTYPE html>
<html
lang="{{ str_replace('_', '-', app()->getLocale()) }}"
xmlns="http://www.w3.org/1999/xhtml"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"
>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>STEMMechanics - Forgot Password</title>
<link
rel="noopener"
target="_blank"
href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800&display=swap"
rel="stylesheet"
/>
<!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<style>
@import url("https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800&display=swap");
</style>
</head>
<body>
<table
cellspacing="0"
cellpadding="0"
border="0"
role="presentation"
style="
width: 100%;
padding: 2rem;
font-size: 1.1rem;
color: #000000;
font-family: Nunito, Arial, Helvetica, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
"
>
<tr>
<td>
<a href="https://www.stemmechanics.com.au/">
<img
alt="STEMMechanics Logo"
src="{{ $message->embed(public_path('assets').'/logo.webp') }}"
width="400"
height="62"
/>
</a>
</td>
</tr>
<tr>
<td><h2>Yo {{ $user?->display_name }}</h2></td>
</tr>
<tr>
<td>
We all forget things sometimes! But you can reset your
password by clicking the link
<a
href="https://www.stemmechanics.com.au/reset-password?code={{ $code }}"
>Reset Password</a
>
and entering the following code:
</td>
</tr>
<tr>
<td
align="center"
style="
font-size: 200%;
text-align: center;
padding-top: 2rem;
padding-bottom: 2rem;
letter-spacing: 0.5rem;
"
>
<strong>{{ $code }}</strong>
</td>
</tr>
<tr>
<td style="padding-bottom: 2rem">
But if you didn't ask to reset your password, you can delete
this email and your password will remain the same.
</td>
</tr>
<tr>
<td
align="center"
style="
font-size: 90%;
text-align: center;
padding-top: 2rem;
padding-bottom: 2rem;
border-top: 1px solid #ddd;
"
>
Need help or got feedback?
<a href="https://www.stemmechanics.com.au/contact"
>Contact us</a
>
or touch base at
<a href="https://twitter.com/stemmechanics"
>@stemmechanics</a
>.
</td>
</tr>
<tr>
<td
align="center"
style="
font-size: 80%;
text-align: center;
padding-top: 1rem;
padding-bottom: 2rem;
"
>
Sent by STEMMechanics &middot;
<a href="https://www.stemmechanics.com.au/"
>Visit our Website</a
>
&middot;
<a href="https://twitter.com/stemmechanics"
>@stemmechanics</a
><br />PO Box 36, Edmonton, QLD 4869, Australia
</td>
</tr>
</table>
</body>
</html>

View File

@@ -1,14 +0,0 @@
Yo {{ $user?->display_name }}
We all forget things sometimes! But you can reset your password typing the following into your browser https://www.stemmechanics.com.au/reset-password and entering the following code:
{{ $code }}
But if you didn't ask to reset your password, you can delete this email and we will keep your current password.
Need help or got feedback? Contact us at https://www.stemmechanics.com.au/contact or touch base on twitter at @stemmechanics
--
Sent by STEMMechanics
https://www.stemmechanics.com.au/
PO Box 36, Edmonton, QLD 4869, Australia

View File

@@ -1,31 +0,0 @@
<!doctype html>
<title>Site Maintenance</title>
<style>
body { text-align: center; padding: 150px; }
h1 { font-size: 50px; }
body { font: 20px Helvetica, sans-serif; color: #333; }
article { display: block; text-align: left; width: 650px; margin: 0 auto; }
img { margin: 0 auto; }
h1 { text-align: center; font-size: 175%; }
a, a:visited { color: #35a5f1; text-decoration-thickness: 0.1em; text-decoration-color: #67bbf4; }
a:hover { filter: brightness(115%); }
p { line-height: 1.25em; }
.d-light { display: block; }
.d-dark { display: none; }
@media (prefers-color-scheme: dark) {
body { background-color: #333; color: #ccc; }
.d-light { display: none; }
.d-dark { display: block; }
}
</style>
<article>
<img class="d-light" src="/assets/logo.webp" width="312" height="48" />
<img class="d-dark" src="/assets/logo-dark.webp" width="312" height="48" />
<h1>We&rsquo;ll be back soon!</h1>
<div>
<p>Sorry for the inconvenience but we&rsquo;re performing some maintenance at the moment. If you need to you can always contact us by <a href="mailto:hello@stemmechanics.com.au">email</a> or over on <a href="https://discord.gg/yNzk4x7mpD">Discord</a>, otherwise we&rsquo;ll be back online shortly!</p>
<p>&mdash; The STEMMechanics Team</p>
</div>
</article>

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@vite('resources/css/app.css')
<title>Laravel</title>
</head>
<body class="antialiased">
<h1 class="text-3xl font-bold underline">
Hello world!
</h1>
</body>
</html>

View File

@@ -1,18 +1,7 @@
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\AnalyticsController;
use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\ContactController;
use App\Http\Controllers\Api\EventController;
use App\Http\Controllers\Api\InfoController;
use App\Http\Controllers\Api\LogController;
use App\Http\Controllers\Api\MediaController;
use App\Http\Controllers\Api\MediaJobController;
use App\Http\Controllers\Api\OCRController;
use App\Http\Controllers\Api\ArticleController;
use App\Http\Controllers\Api\ShortlinkController;
use App\Http\Controllers\Api\UserController;
/*
|--------------------------------------------------------------------------
@@ -20,55 +9,11 @@ use App\Http\Controllers\Api\UserController;
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| is assigned the "api" middleware group. Enjoy building your API!
| routes are loaded by the RouteServiceProvider and all of them will
| be assigned to the "api" middleware group. Make something great!
|
*/
Route::get('/', [InfoController::class, 'index']);
Route::post('/login', [AuthController::class, 'login'])->name('login');
Route::post('/register', [UserController::class, 'register']);
Route::get('/analytics', [AnalyticsController::class, 'index']);
Route::get('/analytics/{session}', [AnalyticsController::class, 'show']);
Route::post('/analytics', [AnalyticsController::class, 'store']);
Route::apiResource('users', UserController::class);
Route::post('/users/forgotPassword', [UserController::class, 'forgotPassword']);
Route::post('/users/resetPassword', [UserController::class, 'resetPassword']);
Route::post('/users/resendVerifyEmailCode', [UserController::class, 'resendVerifyEmailCode']);
Route::post('/users/verifyEmail', [UserController::class, 'verifyEmail']);
Route::get('/users/{user}/events', [UserController::class, 'eventList']);
Route::get('media/jobs', [MediaJobController::class, 'index']);
Route::get('media/jobs/{mediaJob}', [MediaJobController::class, 'show']);
Route::apiResource('media', MediaController::class);
Route::get('media/{media}/download', [MediaController::class, 'download']);
Route::apiResource('articles', ArticleController::class);
// Route::apiAddendumResource('attachments', 'articles', ArticleController::class);
Route::apiResource('events', EventController::class);
Route::apiAddendumResource('attachments', 'events', EventController::class);
Route::get('/events/{event}/users', [EventController::class, 'userList']);
Route::post('/events/{event}/users', [EventController::class, 'userAdd']);
Route::match(['put', 'patch'], '/events/{event}/users', [EventController::class, 'userUpdate']);
Route::delete('/events/{event}/users/{user}', [EventController::class, 'userDelete']);
Route::post('/contact', [ContactController::class, 'send']);
Route::apiResource('/shortlinks', ShortlinkController::class);
Route::get('/logs/{name}', [LogController::class, 'show']);
Route::get('/ocr', [OCRController::class, 'show']);
Route::middleware('auth:sanctum')->group(function () {
Route::post('/logout', [AuthController::class, 'logout']);
Route::get('/me', [AuthController::class, 'me']);
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user();
});
Route::any('{any}', function () {
return response()->json(['message' => 'Resource not found'], 404);
})->where('any', '.*');

View File

@@ -8,11 +8,11 @@ use Illuminate\Support\Facades\Route;
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
| routes are loaded by the RouteServiceProvider and all of them will
| be assigned to the "web" middleware group. Make something great!
|
*/
Route::get('/{any}', function () {
return view('app');
})->where('any', '.*');
Route::get('/', function () {
return view('welcome');
});

View File

@@ -1,13 +0,0 @@
import sys
import urllib.parse
import numpy as np
import keras_ocr
if len(sys.argv) > 1:
url = urllib.parse.unquote(sys.argv[1])
image = keras_ocr.tools.read(url)
pipeline = keras_ocr.pipeline.Pipeline()
prediction = pipeline.recognize([image])
print("----------START----------")
for text, box in prediction [0]:
print(text)

2
storage/logs/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

12
tailwind.config.js Normal file
View File

@@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./resources/**/*.blade.php",
"./resources/**/*.js",
"./resources/**/*.vue",
],
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -9,12 +9,10 @@ trait CreatesApplication
{
/**
* Creates the application.
*
* @return \Illuminate\Foundation\Application
*/
public function createApplication(): Application
{
$app = require __DIR__ . '/../bootstrap/app.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();

View File

@@ -1,162 +0,0 @@
<?php
namespace Tests;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use App\Models\User;
use App\Models\Media;
use App\Models\Article;
use Faker\Factory as FakerFactory;
final class ArticlesApiTest extends TestCase
{
use RefreshDatabase;
/**
* Faker Factory instance.
* @var Faker\Factory
*/
protected $faker;
/**
* {@inheritDoc}
*
* @return void
*/
protected function setUp(): void
{
parent::setUp();
$this->faker = FakerFactory::create();
}
/**
* Tests that any user can view an article if it's published and not in the future.
*
* @return void
*/
public function testAnyUserCanViewArticle(): void
{
// Create an event
$article = Article::factory()->create([
'publish_at' => $this->faker->dateTimeBetween('-2 months', '-1 month'),
]);
// Create a future event
$futureArticle = Article::factory()->create([
'publish_at' => $this->faker->dateTimeBetween('+1 month', '+2 months'),
]);
// Send GET request to the /api/articles endpoint
$response = $this->getJson('/api/articles');
$response->assertStatus(200);
// Assert that the event is in the response data
$response->assertJsonCount(1, 'articles');
$response->assertJsonFragment([
'id' => $article->id,
'title' => $article->title,
'content' => $article->content,
]);
$response->assertJsonMissing([
'id' => $futureArticle->id,
'title' => $futureArticle->title,
'content' => $futureArticle->content,
]);
}
/**
* Tests that an admin can create, update, and delete articles.
*
* @return void
*/
public function testAdminCanCreateUpdateDeleteArticle(): void
{
// Create a user with the admin/events permission
$adminUser = User::factory()->create();
$adminUser->givePermission('admin/articles');
// Create media data
$media = Media::factory()->create(['user_id' => $adminUser->id]);
// Create event data
$articleData = Article::factory()->make([
'user_id' => $adminUser->id,
'hero' => $media->id,
])->toArray();
// Test creating event
$response = $this->actingAs($adminUser)->postJson('/api/articles', $articleData);
$response->assertStatus(201);
$this->assertDatabaseHas('articles', [
'title' => $articleData['title'],
'content' => $articleData['content'],
]);
// Test viewing event
$article = Article::where('title', $articleData['title'])->first();
$response = $this->get("/api/articles/$article->id");
$response->assertStatus(200);
$response->assertJsonStructure([
'article' => [
'id',
'title',
'content',
]
]);
// Test updating event
$articleData['title'] = 'Updated Article';
$response = $this->actingAs($adminUser)->putJson("/api/articles/$article->id", $articleData);
$response->assertStatus(200);
$this->assertDatabaseHas('articles', [
'title' => 'Updated Article',
]);
// Test deleting event
$response = $this->actingAs($adminUser)->delete("/api/articles/$article->id");
$response->assertStatus(204);
$this->assertDatabaseMissing('articles', [
'title' => 'Updated Article',
]);
}
/**
* Tests that a non-admin user cannot create, update, or delete articles.
*
* @return void
*/
public function testNonAdminCannotCreateUpdateDeleteArticle(): void
{
// Create a user without admin/events permission
$user = User::factory()->create();
// Authenticate as the user
$this->actingAs($user);
// Try to create a new article
$media = Media::factory()->create(['user_id' => $user->id]);
$newArticleData = Article::factory()->make(['user_id' => $user->id, 'hero' => $media->id])->toArray();
$response = $this->postJson('/api/articles', $newArticleData);
$response->assertStatus(403);
// Try to update an event
$article = Article::factory()->create();
$updatedArticleData = [
'title' => 'Updated Event',
'content' => 'This is an updated event.',
// Add more fields as needed
];
$response = $this->putJson('/api/articles/' . $article->id, $updatedArticleData);
$response->assertStatus(403);
// Try to delete an event
$article = Article::factory()->create();
$response = $this->deleteJson('/api/articles/' . $article->id);
$response->assertStatus(403);
}
}

View File

@@ -1,70 +0,0 @@
<?php
namespace Tests;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use App\Models\User;
final class AuthApiTest extends TestCase
{
use RefreshDatabase;
/**
* Tests the login, user retrieval, and logout functionality of the Auth API.
*
* This test performs the following steps:
* 1. Creates a new user using a factory.
* 2. Attempts a successful login with the correct credentials,
* checks for a 200 status code, and verifies the structure of the returned token.
* 3. Retrieves the authenticated user's data using the token,
* checks for a 200 status code, and verifies the returned user data.
* 4. Logs out the authenticated user using the token and checks for a 204 status code.
* 5. Attempts a failed login with incorrect credentials and checks for a 422 status code.
*
* @return void
*/
public function testLogin(): void
{
$user = User::factory()->create([
'password' => bcrypt('password'),
]);
// Test successful login
$response = $this->postJson('/api/login', [
'email' => $user->email,
'password' => 'password',
]);
$response->assertStatus(200);
$response->assertJsonStructure([
'token',
]);
$token = $response->json('token');
// Test getting authenticated user
$response = $this->withHeaders([
'Authorization' => "Bearer $token",
])->get('/api/me');
$response->assertStatus(200);
$response->assertJson([
'user' => [
'id' => $user->id,
'email' => $user->email,
]
]);
// Test logout
$response = $this->withHeaders([
'Authorization' => "Bearer $token",
])->postJson('/api/logout');
$response->assertStatus(204);
// Test failed login
$response = $this->postJson('/api/login', [
'email' => $user->email,
'password' => 'wrongpassword',
]);
$response->assertStatus(422);
}
}

View File

@@ -1,43 +0,0 @@
<?php
namespace Tests;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class ContactFormTest extends TestCase
{
use RefreshDatabase;
/**
* Tests the contact form submission API endpoint.
*
* This test performs two POST requests to the '/api/contact' endpoint
* using the `postJson` method. The first request contains valid data and
* should return a 201 status code, indicating a successful creation.
* The second request omits the 'email' field, which should cause a
* validation error and return a 422 status code.
*
* @return void
*/
public function testContactForm(): void
{
$formData = [
'name' => 'John Doe',
'email' => 'johndoe@example.com',
'content' => 'Hello, this is a test message.',
];
$response = $this->postJson('/api/contact', $formData);
$response->assertStatus(201);
$formData = [
'name' => 'John Doe',
'content' => 'Hello, this is a test message.',
];
$response = $this->postJson('/api/contact', $formData);
$response->assertStatus(422);
}
}

View File

@@ -1,204 +0,0 @@
<?php
namespace Tests;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use App\Models\User;
use App\Models\Event;
use App\Models\Media;
use Carbon\Carbon;
use Faker\Factory as FakerFactory;
final class EventsApiTest extends TestCase
{
use RefreshDatabase;
/**
* Faker Factory instance.
* @var Faker\Factory
*/
protected $faker;
/**
* {@inheritDoc}
*
* @return void
*/
protected function setUp(): void
{
parent::setUp();
$this->faker = FakerFactory::create();
}
/**
* Tests that any user can view an event if it's published and not in the future.
*
* @return void
*/
public function testAnyUserCanViewEvent(): void
{
// Create an event
$event = Event::factory()->create([
'publish_at' => Carbon::parse($this->faker->dateTimeBetween('-2 months', '-1 month')),
'status' => 'open',
]);
// Create a future event
$futureEvent = Event::factory()->create([
'publish_at' => Carbon::parse($this->faker->dateTimeBetween('+1 day', '+1 month')),
'status' => 'open',
]);
// Send GET request to the /api/events endpoint
$response = $this->getJson('/api/events');
$response->assertStatus(200);
// Assert that the event is in the response data
$response->assertJsonCount(1, 'events');
$response->assertJsonFragment([
'id' => $event->id,
'title' => $event->title,
]);
$response->assertJsonMissing([
'id' => $futureEvent->id,
'title' => $futureEvent->title,
]);
}
/**
* Tests that any user cannot see draft events.
*
* @return void
*/
public function testAnyUserCannotSeeDraftEvent(): void
{
// Create a draft event
$draftEvent = Event::factory()->create([
'publish_at' => Carbon::parse($this->faker->dateTimeBetween('-2 months', '-1 month')),
'status' => 'draft',
]);
// Create a open event
$openEvent = Event::factory()->create([
'publish_at' => Carbon::parse($this->faker->dateTimeBetween('-2 months', '-1 month')),
'status' => 'open',
]);
// Create a closed event
$closedEvent = Event::factory()->create([
'publish_at' => Carbon::parse($this->faker->dateTimeBetween('-2 months', '-1 month')),
'status' => 'closed',
]);
// Send GET request to the /api/events endpoint
$response = $this->getJson('/api/events');
$response->assertStatus(200);
// Assert that the event is in the response data
$response->assertJsonCount(2, 'events');
$response->assertJsonMissing([
'id' => $draftEvent->id,
'title' => $draftEvent->title,
]);
}
/**
* Tests that an admin can create, update, and delete events.
*
* @return void
*/
public function testAdminCanCreateUpdateDeleteEvent(): void
{
// Create a user with the admin/events permission
$adminUser = User::factory()->create();
$adminUser->givePermission('admin/events');
// Create media data
$media = Media::factory()->create(['user_id' => $adminUser->id]);
// Create event data
$eventData = Event::factory()->make([
'start_at' => now()->addDays(7),
'end_at' => now()->addDays(7)->addHours(2),
'hero' => $media->id,
])->toArray();
// Test creating event
$response = $this->actingAs($adminUser)->postJson('/api/events', $eventData);
$response->assertStatus(201);
$this->assertDatabaseHas('events', [
'title' => $eventData['title'],
'content' => $eventData['content'],
]);
// Test viewing event
$event = Event::where('title', $eventData['title'])->first();
$response = $this->get("/api/events/$event->id");
$response->assertStatus(200);
$response->assertJsonStructure([
'event' => [
'id',
'title',
'content',
'start_at',
'end_at',
]
]);
// Test updating event
$eventData['title'] = 'Updated Event';
$response = $this->actingAs($adminUser)->putJson("/api/events/$event->id", $eventData);
$response->assertStatus(200);
$this->assertDatabaseHas('events', [
'title' => 'Updated Event',
]);
// Test deleting event
$response = $this->actingAs($adminUser)->delete("/api/events/$event->id");
$response->assertStatus(204);
$this->assertDatabaseMissing('events', [
'title' => 'Updated Event',
]);
}
/**
* Tests that a non-admin user cannot create, update, or delete events.
*
* @return void
*/
public function testNonAdminCannotCreateUpdateDeleteEvent(): void
{
// Create a user without admin/events permission
$user = User::factory()->create();
// Authenticate as the user
$this->actingAs($user);
// Try to create a new event
$media = Media::factory()->create(['user_id' => $user->id]);
$newEventData = Event::factory()->make(['hero' => $media->id])->toArray();
$response = $this->postJson('/api/events', $newEventData);
$response->assertStatus(403);
// Try to update an event
$event = Event::factory()->create();
$updatedEventData = [
'title' => 'Updated Event',
'content' => 'This is an updated event.',
// Add more fields as needed
];
$response = $this->putJson('/api/events/' . $event->id, $updatedEventData);
$response->assertStatus(403);
// Try to delete an event
$event = Event::factory()->create();
$response = $this->deleteJson('/api/events/' . $event->id);
$response->assertStatus(403);
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace Tests\Feature;
// use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*/
public function test_the_application_returns_a_successful_response(): void
{
$response = $this->get('/');
$response->assertStatus(200);
}
}

View File

@@ -1,263 +0,0 @@
<?php
namespace Tests;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;
use App\Models\User;
final class UsersApiTest extends TestCase
{
use RefreshDatabase;
/**
* Tests that non-admin users can only view basic user info.
*
* @return void
*/
public function testNonAdminUsersCanOnlyViewBasicUserInfo(): void
{
// create a non-admin user
$nonAdminUser = User::factory()->create();
$nonAdminUser->revokePermission('admin/users');
// create an admin user
$adminUser = User::factory()->create();
$adminUser->givePermission('admin/users');
// ensure the non-admin user can access the endpoint and see basic user info only
$response = $this->actingAs($nonAdminUser)->get('/api/users');
$response->assertStatus(200);
$response->assertJsonStructure([
'users' => [
'*' => [
'id',
'display_name'
]
],
'total'
]);
$response->assertJsonMissing([
'users' => [
'*' => [
'email',
'password'
]
],
]);
// ensure the admin user can access the endpoint and see additional user info
$response = $this->actingAs($adminUser)->get('/api/users');
$response->assertStatus(200);
$response->assertJsonStructure([
'users' => [
'*' => [
'id',
'email'
]
],
'total'
]);
$response->assertJsonMissing([
'users' => [
'*' => [
'password'
]
]
]);
$response->assertJsonFragment([
'id' => $nonAdminUser->id,
'email' => $nonAdminUser->email
]);
}
/**
* Tests that guests cannot create a user via the API.
*
* @return void
*/
public function testGuestCannotCreateUser(): void
{
$userData = [
'email' => 'johndoe@example.com',
'password' => 'password',
];
$response = $this->postJson('/api/users', $userData);
$response->assertStatus(401);
$this->assertDatabaseMissing('users', [
'email' => $userData['email'],
]);
}
/**
* Tests that guests can register a user via the API.
*
* @return void
*/
public function testGuestCanRegisterUser(): void
{
$userData = [
'first_name' => 'John',
'last_name' => 'Doe',
'display_name' => 'jackdoe',
'email' => 'johndoe@example.com',
'password' => 'password',
];
$response = $this->postJson('/api/register', $userData);
$response->assertStatus(200);
$this->assertDatabaseHas('users', [
'email' => $userData['email'],
]);
}
/**
* Tests that duplicate email or display name entries cannot be created.
*
* @return void
*/
public function testCannotCreateDuplicateEmailOrDisplayName(): void
{
$userData = [
'display_name' => 'JackDoe',
'first_name' => 'Jack',
'last_name' => 'Doe',
'email' => 'jackdoe@example.com',
'password' => 'password',
];
// Test creating user
$response = $this->postJson('/api/register', $userData);
$response->assertStatus(200);
$this->assertDatabaseHas('users', [
'email' => 'jackdoe@example.com',
]);
// Test creating duplicate user
$response = $this->postJson('/api/register', $userData);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['display_name', 'email']);
}
/**
* Tests that a user can only update their own user info.
*
* @return void
*/
public function testUserCanOnlyUpdateOwnUser(): void
{
$user = User::factory()->create();
$userData = [
'email' => 'raffi@example.com',
'password' => 'password',
];
// Test updating own user
$response = $this->actingAs($user)->putJson('/api/users/' . $user->id, $userData);
$response->assertStatus(200);
$this->assertDatabaseHas('users', [
'id' => $user->id,
'email' => 'raffi@example.com',
]);
// Test updating another user
$otherUser = User::factory()->create();
$otherUserData = [
'email' => 'otherraffi@example.com',
'password' => 'password',
];
$response = $this->actingAs($user)->putJson('/api/users/' . $otherUser->id, $otherUserData);
$response->assertStatus(403);
}
/**
* Tests that a user cannot delete users via the API.
*
* @return void
*/
public function testUserCannotDeleteUsers(): void
{
$user = User::factory()->create();
// Test deleting own user
$response = $this->actingAs($user)->deleteJson('/api/users/' . $user->id);
$response->assertStatus(403);
$this->assertDatabaseHas('users', ['id' => $user->id]);
// Test deleting another user
$otherUser = User::factory()->create();
$response = $this->actingAs($user)->deleteJson('/api/users/' . $otherUser->id);
$response->assertStatus(403);
$this->assertDatabaseHas('users', ['id' => $otherUser->id]);
}
/**
* Tests that an admin can update any user's info.
*
* @return void
*/
public function testAdminCanUpdateAnyUser(): void
{
$admin = User::factory()->create();
$admin->givePermission('admin/users');
$user = User::factory()->create();
$userData = [
'email' => 'todddoe@example.com',
'password' => 'password',
];
// Test updating own user
$response = $this->actingAs($admin)->putJson('/api/users/' . $user->id, $userData);
$response->assertStatus(200);
$this->assertDatabaseHas('users', [
'id' => $user->id,
'email' => 'todddoe@example.com'
]);
// Test updating another user
$otherUser = User::factory()->create();
$otherUserData = [
'email' => 'kimdoe@example.com',
'password' => 'password',
];
$response = $this->actingAs($admin)->putJson('/api/users/' . $otherUser->id, $otherUserData);
$response->assertStatus(200);
$this->assertDatabaseHas('users', [
'id' => $otherUser->id,
'email' => 'kimdoe@example.com',
]);
}
/**
* Tests that an admin can delete any user via the API.
*
* @return void
*/
public function testAdminCanDeleteAnyUser(): void
{
$admin = User::factory()->create();
$admin->givePermission('admin/users');
$user = User::factory()->create();
// Test deleting own user
$response = $this->actingAs($admin)->deleteJson('/api/users/' . $user->id);
$response->assertStatus(204);
$this->assertDatabaseMissing('users', ['id' => $user->id]);
// Test deleting another user
$otherUser = User::factory()->create();
$response = $this->actingAs($admin)->deleteJson('/api/users/' . $otherUser->id);
$response->assertStatus(204);
$this->assertDatabaseMissing('users', ['id' => $otherUser->id]);
}
}

View File

@@ -7,17 +7,4 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
use CreatesApplication;
/**
* {@inheritDoc}
*
* @return void
*/
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
}
}

View File

@@ -4,12 +4,10 @@ namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
final class ExampleTest extends TestCase
class ExampleTest extends TestCase
{
/**
* A basic test example.
*
* @return void
*/
public function test_that_true_is_true(): void
{

View File

@@ -1,13 +0,0 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
// "strict": true,
"moduleResolution": "node",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["resources/**/*"],
"exclude": ["node_modules", "dist"]
}

11
vite.config.js Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
export default defineConfig({
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.js'],
refresh: true,
}),
],
});

View File

@@ -1,52 +0,0 @@
import vue from "@vitejs/plugin-vue";
import laravel from "laravel-vite-plugin";
import analyzer from "rollup-plugin-analyzer";
import { compression } from "vite-plugin-compression2";
import { defineConfig } from "vite";
import Unocss from "unocss/vite";
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => ["ion-icon"].includes(tag),
},
transformAssetUrls: {
base: null,
includeAbsolute: false,
},
},
}),
Unocss({}),
laravel({
input: ["resources/css/app.scss", "resources/js/main.js"],
refresh: true,
}),
analyzer({ summaryOnly: true }),
compression({
include: [/\.(js)$/, /\.(css)$/],
// deleteOriginalAssets: true,
}),
],
css: {
preprocessorOptions: {
scss: {
// additionalData: `@import "./resources/css/variables.scss";`,
},
},
},
envPrefix: ["VITE_", "APP_URL"],
resolve: {
alias: {
vue: "vue/dist/vue.esm-bundler.js",
},
},
build: {
chunkSizeWarningLimit: 500,
rollupOptions: {
output: {},
},
},
base: "",
});