This commit is contained in:
2023-04-21 15:46:12 +10:00
parent 3dfe96fa89
commit 84bfd3cda2
7 changed files with 276 additions and 113 deletions

View File

@@ -9,24 +9,66 @@
<label class="control-label" v-bind="{ for: id }">{{ <label class="control-label" v-bind="{ for: id }">{{
label label
}}</label> }}</label>
<ion-icon <template v-if="props.type == 'static'">
class="invalid-icon" <div class="static-input-control" v-bind="{ id: id }">
name="alert-circle-outline"></ion-icon> {{ value }}
<ion-icon </div>
v-if="props.showClear && value?.length > 0 && !feedbackInvalid" </template>
class="clear-icon" <template v-else-if="props.type == 'file'">
name="close-outline" <input
@click.stop="handleClear"></ion-icon> :id="id"
<input type="file"
:type="props.type" class="file-input-control"
class="input-control" :accept="props.accept"
:disabled="disabled" @change="handleChange" />
v-bind="{ id: id, autofocus: props.autofocus }" <div class="file-input-control-value">
v-model="value" {{ value?.name ? value.name : value }}
@focus="handleFocus" </div>
@blur="handleBlur" <label
@input="handleInput" class="button primary file-input-control-button"
@keyup="handleKeyup" /> :for="id"
>Select file</label
>
</template>
<template v-else-if="props.type == 'textarea'">
<ion-icon
class="invalid-icon"
name="alert-circle-outline"></ion-icon>
<textarea
:type="props.type"
class="input-control"
:disabled="disabled"
v-bind="{ id: id, autofocus: props.autofocus }"
v-model="value"
rows="5"
@focus="handleFocus"
@blur="handleBlur"
@input="handleInput"
@keyup="handleKeyup"></textarea>
</template>
<template v-else>
<ion-icon
class="invalid-icon"
name="alert-circle-outline"></ion-icon>
<ion-icon
v-if="
props.showClear && value?.length > 0 && !feedbackInvalid
"
class="clear-icon"
name="close-outline"
@click.stop="handleClear"></ion-icon>
<input
:type="props.type"
class="input-control"
:disabled="disabled"
v-bind="{ id: id, autofocus: props.autofocus }"
v-model="value"
@focus="handleFocus"
@blur="handleBlur"
@input="handleInput"
@keyup="handleKeyup" />
</template>
</div> </div>
<div v-if="slots.append" class="input-control-append"> <div v-if="slots.append" class="input-control-append">
<slot name="append"></slot> <slot name="append"></slot>
@@ -37,7 +79,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { inject, watch, ref, useSlots } from "vue"; import { inject, watch, ref, useSlots } from "vue";
import { isEmpty } from "../helpers/utils"; import { isEmpty, generateRandomElementId } from "../helpers/utils";
import { toTitleCase } from "../helpers/string"; import { toTitleCase } from "../helpers/string";
import SMControl from "./SMControl.vue"; import SMControl from "./SMControl.vue";
@@ -97,6 +139,11 @@ const props = defineProps({
default: false, default: false,
required: false, required: false,
}, },
accept: {
type: String,
default: "",
required: false,
},
}); });
const slots = useSlots(); const slots = useSlots();
@@ -131,7 +178,7 @@ const id = ref(
? props.id ? props.id
: typeof props.control == "string" : typeof props.control == "string"
? props.control ? props.control
: "" : generateRandomElementId()
); );
const feedbackInvalid = ref(props.feedbackInvalid); const feedbackInvalid = ref(props.feedbackInvalid);
const active = ref(value.value?.length ?? 0 > 0); const active = ref(value.value?.length ?? 0 > 0);
@@ -141,10 +188,22 @@ const disabled = ref(props.disabled);
watch( watch(
() => value.value, () => value.value,
(newValue) => { (newValue) => {
active.value = newValue.length > 0 || focused.value == true; active.value =
newValue.length > 0 ||
newValue instanceof File ||
focused.value == true;
} }
); );
if (props.modelValue != undefined) {
watch(
() => props.modelValue,
(newValue) => {
value.value = newValue;
}
);
}
watch( watch(
() => props.feedbackInvalid, () => props.feedbackInvalid,
(newValue) => { (newValue) => {
@@ -215,7 +274,13 @@ const handleKeyup = (event: Event) => {
const handleClear = () => { const handleClear = () => {
value.value = ""; value.value = "";
emits("update:modelValue", ""); emits("update:modelValue", "");
// emits("change"); };
const handleChange = (event) => {
if (control) {
control.value = event.target.files[0];
feedbackInvalid.value = "";
}
}; };
</script> </script>
@@ -326,6 +391,40 @@ const handleClear = () => {
cursor: not-allowed; cursor: not-allowed;
} }
} }
.static-input-control {
width: 100%;
padding: 22px 16px 8px 16px;
border: 1px solid var(--base-color-darker);
border-radius: 8px;
background-color: var(--base-color);
height: 52px;
}
.file-input-control {
opacity: 0;
width: 0.1px;
height: 0.1px;
position: absolute;
margin-left: -9999px;
}
.file-input-control-value {
width: 100%;
padding: 22px 16px 8px 16px;
border: 1px solid var(--base-color-darker);
border-radius: 8px 0 0 8px;
background-color: var(--base-color);
height: 52px;
}
.file-input-control-button {
border-width: 1px 1px 1px 0;
border-style: solid;
border-color: var(--base-color-darker);
border-radius: 0 8px 8px 0;
padding: 15px 30px;
}
} }
} }

View File

@@ -25,7 +25,7 @@ defineProps({
display: flex; display: flex;
flex-direction: row; flex-direction: row;
margin: 8px auto; margin: 8px auto;
align-items: top; align-items: flex-start;
width: 100%; width: 100%;
max-width: 1200px; max-width: 1200px;
} }

View File

@@ -30,12 +30,14 @@ export interface Media {
user_id: string; user_id: string;
title: string; title: string;
name: string; name: string;
mime: string; mime_type: string;
permission: Array<string>; permission: string;
size: number; size: number;
status: string; status: string;
storage: string;
url: string; url: string;
description: string; description: string;
dimensions: string;
variants: { [key: string]: string }; variants: { [key: string]: string };
created_at: string; created_at: string;
updated_at: string; updated_at: string;

View File

@@ -95,3 +95,12 @@ export const updateRouterParams = (router: Router, params: Params): void => {
router.push({ query }); router.push({ query });
}; };
export const extractFileNameFromUrl = (url: string): string => {
const matches = url.match(/\/([^/]+\.[^/]+)$/);
if (!matches) {
return "";
}
const fileName = matches[1];
return fileName;
};

View File

@@ -1,3 +1,5 @@
import { extractFileNameFromUrl } from "./url";
/** /**
* Tests if an object or string is empty. * Tests if an object or string is empty.
* *
@@ -55,7 +57,7 @@ export const getFileIconImagePath = (fileName: string): string => {
* @returns {string} The url to the file preview icon. * @returns {string} The url to the file preview icon.
*/ */
export const getFilePreview = (url: string): string => { export const getFilePreview = (url: string): string => {
const ext = getFileExtension(fileName); const ext = getFileExtension(extractFileNameFromUrl(url));
if (ext.length > 0) { if (ext.length > 0) {
if (/(gif|jpe?g|png)/i.test(ext)) { if (/(gif|jpe?g|png)/i.test(ext)) {
return `${url}?size=thumb`; return `${url}?size=thumb`;
@@ -80,3 +82,19 @@ export const clamp = (n: number, min: number, max: number): number => {
if (n > max) return max; if (n > max) return max;
return n; return n;
}; };
/**
* Generate a random element ID.
*
* @param {string} prefix Any prefix to add to the ID.
* @returns {string} A random string non-existent in the document.
*/
export const generateRandomElementId = (prefix: string = ""): string => {
let randomId = "";
do {
randomId = prefix + Math.random().toString(36).substring(2, 9);
} while (document.getElementById(randomId));
return randomId;
};

View File

@@ -2,7 +2,7 @@ import { bytesReadable } from "../helpers/types";
import { SMDate } from "./datetime"; import { SMDate } from "./datetime";
export interface ValidationObject { export interface ValidationObject {
validate: (value: string) => Promise<ValidationResult>; validate: (value: any) => Promise<ValidationResult>;
} }
export interface ValidationResult { export interface ValidationResult {
@@ -818,7 +818,7 @@ const defaultValidationFileSizeOptions: ValidationFileSizeOptions = {
}; };
/** /**
* Validate field is in a valid Email format * Validate file is equal or less than size.
* *
* @param options options data * @param options options data
* @returns ValidationEmailObject * @returns ValidationEmailObject

View File

@@ -1,115 +1,153 @@
<template> <template>
<SMPage :page-error="pageError" permission="admin/media"> <SMPage :page-error="pageError" permission="admin/media">
<SMRow> <SMMastHead
<SMFormCard> :title="pageHeading"
<h1>{{ page_title }}</h1> :back-link="{ name: 'dashboard-media-list' }"
<SMForm back-title="Back to Media" />
:model-value="form" <SMContainer class="flex-grow-1">
:loading_message="formLoadingMessage" <SMLoading v-if="pageLoading" large />
@submit="handleSubmit"> <SMForm v-else :model-value="form" @submit="handleSubmit">
<SMRow> <SMRow>
<SMColumn> <SMColumn>
<SMInput control="file" type="file" /> <SMInput control="file" type="file" />
</SMColumn> </SMColumn>
</SMRow> </SMRow>
<SMRow> <SMRow>
<SMColumn> <SMColumn>
<SMInput <SMInput control="title" />
contorl="url" </SMColumn>
type="link" <SMColumn>
label="URL" <SMInput control="permission" />
:href="formData.url.value" /> </SMColumn>
</SMColumn> </SMRow>
</SMRow> <SMRow>
<SMRow> <SMColumn>
<SMColumn> <SMInput
<SMInput v-model="computedFileSize"
v-model="computedFileSize" type="static"
type="static" label="File Size" />
label="File Size" /> </SMColumn>
</SMColumn> <SMColumn>
<SMColumn> <SMInput
<SMInput v-model="fileData.mime_type"
v-model="formData.mime.value" type="static"
type="static" label="File Mime Type" />
label="File Mime" /> </SMColumn>
</SMColumn> </SMRow>
</SMRow> <SMRow>
<SMRow> <SMColumn>
<SMColumn> <SMInput
<SMInput v-model="fileData.status"
v-model="formData.permission.value" type="static"
label="Permission" label="Status" />
:error="formData.permission.error" </SMColumn>
@blur="fieldValidate(formData.permission)" /> <SMColumn>
</SMColumn> <SMInput
</SMRow> v-model="fileData.dimensions"
<SMRow> type="static"
<SMColumn> label="Dimensions" />
<SMButton </SMColumn>
type="danger" </SMRow>
label="Delete" <SMRow>
@click="handleDelete" /> <SMColumn>
</SMColumn> <SMInput
<SMColumn class="justify-content-end"> v-model="fileData.url"
<SMButton type="submit" label="Save" /> type="static"
</SMColumn> label="URL" />
</SMRow> </SMColumn>
</SMForm> </SMRow>
</SMFormCard> <SMRow>
</SMRow> <SMColumn>
<SMInput type="textarea" control="description" />
</SMColumn>
</SMRow>
<SMRow class="px-2 justify-content-space-between">
<SMButton
type="danger"
label="Delete"
@click="handleDelete" />
<SMButton type="submit" label="Save" />
</SMRow>
</SMForm>
</SMContainer>
</SMPage> </SMPage>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, ref } from "vue"; import { computed, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import SMButton from "../../components/SMButton.vue";
import SMFormCard from "../../components/SMFormCard.vue";
import SMForm from "../../components/SMForm.vue";
import SMInput from "../../components/SMInput.vue";
import { api } from "../../helpers/api"; import { api } from "../../helpers/api";
import { Form, FormControl } from "../../helpers/form"; import { Form, FormControl } from "../../helpers/form";
import { bytesReadable } from "../../helpers/types"; import { bytesReadable } from "../../helpers/types";
import { And, FileSize, Required } from "../../helpers/validate"; import { And, FileSize, Required } from "../../helpers/validate";
import { Media, MediaResponse } from "../../helpers/api.types";
const router = useRouter(); import { openDialog } from "../../components/SMDialog";
const pageError = ref(200); import DialogConfirm from "../../components/dialogs/SMDialogConfirm.vue";
const formLoadingMessage = ref(""); import SMButton from "../../components/SMButton.vue";
import SMForm from "../../components/SMForm.vue";
import SMInput from "../../components/SMInput.vue";
import SMMastHead from "../../components/SMMastHead.vue";
import SMLoading from "../../components/SMLoading.vue";
import { toTitleCase } from "../../helpers/string";
const route = useRoute(); const route = useRoute();
const page_title = route.params.id ? "Edit Media" : "Upload Media"; const router = useRouter();
let form = reactive( const pageError = ref(200);
const pageLoading = ref(true);
const pageHeading = route.params.id ? "Edit Media" : "Upload Media";
const form = reactive(
Form({ Form({
file: FormControl("", And([Required(), FileSize(5242880)])), file: FormControl("", And([Required(), FileSize({ size: 5242880 })])),
title: FormControl(),
description: FormControl(),
permission: FormControl(), permission: FormControl(),
}) })
); );
const fileData = reactive({ const fileData = reactive({
url: "", url: "",
mime: "", mime_type: "",
size: 0, size: 0,
storage: "",
status: "",
dimensions: "",
user: {},
}); });
const handleLoad = async () => { const handleLoad = async () => {
if (route.params.id) { if (route.params.id) {
try { try {
let res = await api.get(`media/${route.params.id}`); let result = await api.get({
url: "/media/{id}",
params: {
id: route.params.id,
},
});
form.file.value = res.data.media.name; const data = result.data as MediaResponse;
form.permission.value = res.data.media.permission;
fileData.url = res.data.media.url; form.controls.file.value = data.medium.name;
fileData.mime = res.data.media.mime; form.controls.title.value = data.medium.title;
fileData.size = res.data.media.size; form.controls.description.value = data.medium.description;
form.controls.permission.value = data.medium.permission;
fileData.url = data.medium.url;
fileData.mime_type = data.medium.mime_type;
fileData.size = data.medium.size;
fileData.storage = data.medium.storage;
fileData.status =
data.medium.status == ""
? "OK"
: toTitleCase(data.medium.status);
fileData.dimensions = data.medium.dimensions;
} catch (err) { } catch (err) {
form.apiErrors(err); pageError.value = err.status;
} }
} }
form.loading(false); pageLoading.value = false;
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
@@ -155,7 +193,7 @@ const handleSubmit = async () => {
form.loading(false); form.loading(false);
}; };
const handleDelete = async () => { const handleDelete = async (item: Media) => {
let result = await openDialog(DialogConfirm, { let result = await openDialog(DialogConfirm, {
title: "Delete File?", title: "Delete File?",
text: `Are you sure you want to delete the file <strong>${item.title}</strong>?`, text: `Are you sure you want to delete the file <strong>${item.title}</strong>?`,
@@ -173,11 +211,8 @@ const handleDelete = async () => {
try { try {
await api.delete(`media/${item.id}`); await api.delete(`media/${item.id}`);
router.push({ name: "media" }); router.push({ name: "media" });
} catch (err) { } catch (error) {
alert( pageError.value = error.status;
err.response?.data?.message ||
"An unexpected server error occurred"
);
} }
} }
}; };