Files
Website/resources/js/components/dialogs/SMDialogMedia.vue
2023-07-11 16:37:04 +10:00

838 lines
30 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div
v-if="showFileDrop"
class="fixed flex flex-items-center flex-justify-center justify-center top-0 left-0 w-full h-full z-4 bg-sky-800 bg-op-95 text-white"
@dragenter.prevent="handleDragEnter"
@dragover.prevent="handleDragOver"
@drop.prevent="handleDrop"
@dragleave.prevent="handleDragLeave">
<h2 class="pointer-events-none">Drop files to upload</h2>
</div>
<div
class="fixed top-0 left-0 w-full h-full z-2 bg-black bg-op-20 backdrop-blur"></div>
<div
class="fixed top-0 left-0 right-0 bottom-0 flex-justify-center flex z-3">
<div
class="flex flex-col m-4 border-1 bg-white rounded-xl text-gray-5 px-4 md:px-12 py-4 md:py-8 w-full overflow-hidden">
<SMLoading v-if="progressText" overlay :text="progressText" />
<h2 class="mb-4">Select or Upload Media</h2>
<SMTabGroup v-model="selectedTab" class="flex flex-col flex-1">
<SMTab
id="tab-upload"
label="Upload"
:hide="!props.allowUpload"
class="flex flex-1 flex-col flex-items-center flex-justify-center"
@dragenter.prevent="handleDragEnter"
@dragover.prevent="handleDragOver">
<h2>Drop files to upload</h2>
<p class="text-sm my-2">or</p>
<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"
@click="handleClickSelectFile">
Select Files
</button>
<p class="text-sm mt-4">
{{ max_upload_size }}
</p>
<input
v-if="props.allowUpload"
id="file"
ref="refUploadInput"
type="file"
style="display: none"
:accept="computedAccepts"
@change="handleChangeSelectFile" />
</SMTab>
<SMTab
id="tab-browser"
label="Media Browser"
class="flex flex-1 !p-0">
<div class="relative h-full w-full">
<div
ref="refMediaList"
class="absolute top-0 left-0 bottom-0 md:right-60 p-4 overflow-auto">
<SMInput
v-model="itemSearch"
label="Search"
class="toolbar-search mb-4"
small
@keyup.enter="handleSearch"
@blur="handleSearch">
<template #append>
<button
type="button"
class="font-medium px-3 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-5">
<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
class="flex flex-col flex-justify-center flex-items-center overflow-auto">
<div
v-if="
!mediaLoading && mediaItems.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 found
</p>
</div>
<ul
v-if="mediaItems.length > 0"
:class="[
'flex',
'flex-1',
'p-2',
'gap-4',
'overflow-auto',
'flex-justify-center',
'flex-row',
'flex-wrap',
]">
<li
v-for="item in mediaItems"
:key="item.id"
:class="[
'flex',
'text-center',
'border-3',
'p-1px',
'flex-items-center',
'flex-col',
selected != null &&
item.id == selected.id
? 'selected-checked'
: 'border-white',
]"
@click="handleClickItem(item.id)"
@dblclick="handleDblClickItem(item.id)">
<div
:class="[
'flex',
'flex-items-center',
'flex-justify-center',
'h-30',
'w-40',
'bg-contain',
'bg-center',
'bg-no-repeat',
]"
:style="{
backgroundImage:
item.status === 'OK'
? `url('${mediaGetVariantUrl(
item,
'small',
)}')`
: 'none',
backgroundColor:
item.status === 'OK'
? 'initial'
: 'rgba(220,220,220,1)',
}">
<SMLoading
v-if="item.status !== 'OK'"
small />
</div>
</li>
</ul>
<SMLoading v-if="mediaLoading" />
<div
v-if="
!mediaLoading && mediaItems.length > 0
"
class="mt-4 text-center">
<p class="text-xs text-black mb-4">
Showing {{ mediaItems.length }} of
{{ totalItems }} media items
</p>
<button
v-if="mediaItems.length < totalItems"
class="font-medium px-6 py-1.5 rounded-md hover:shadow-md transition text-xs border-1 border-sky-600 text-sky-600 bg-white hover:bg-sky-500 hover:text-white cursor-pointer"
@click.prevent="handleLoadMore">
Load more
</button>
</div>
</div>
</div>
<div
class="absolute top-0 right-0 bottom-0 w-60 p-4 border-l border-gray-3 bg-gray-1 rounded-r-2 overflow-auto hidden md:block">
<div
v-if="uploadFileList"
class="flex flex-col text-xs border-b border-gray-3 pb-4 mb-4">
<h3 class="text-xs mb-2">Uploading</h3>
<div
class="w-full bg-gray-3 h-3 mb-2 rounded-2">
<div
class="bg-sky-600 h-3 rounded-2"
:style="{
width: `${
(100 / uploadFileList.length) *
(currentUploadFileNum - 1)
}%`,
}"></div>
</div>
<p class="m-0">
{{ currentUploadFileNum }} /
{{
uploadFileList && uploadFileList.length
}}
-
{{
uploadFileList &&
uploadFileList.length >=
currentUploadFileNum
? uploadFileList[
currentUploadFileNum - 1
].name
: ""
}}
</p>
</div>
<div v-if="selected != null">
<div
class="flex flex-col text-xs border-b border-gray-3 pb-4">
<p class="m-0 text-bold">
{{ selected.title }}
</p>
<p class="m-0">
{{
new SMDate(
selected.created_at,
).format("MMM dd, yyyy")
}}
</p>
<p class="m-0">
{{ bytesReadable(selected.size, 0) }}
</p>
</div>
<div class="py-2">
<SMInput
class="mb-2"
label="Title"
v-model:modelValue="selected.title"
:small="true" />
<SMInput
class="mb-2"
label="Description"
textarea
v-model:modelValue="
selected.description
"
:small="true" />
</div>
</div>
</div>
</div>
</SMTab>
</SMTabGroup>
<div class="flex flex-justify-end">
<button
type="button"
class="mr-4 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"
@click="handleClickCancel">
Cancel
</button>
<button
type="button"
:disabled="computedSelectDisabled"
: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',
'disabled-text-white',
'hover-disabled-bg-gray',
'disabled-cursor-not-allowed',
],
]"
@click="handleClickSelect">
Select
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
computed,
onMounted,
onUnmounted,
reactive,
ref,
Ref,
watch,
} from "vue";
import { closeDialog } from "../SMDialog";
import { api } from "../../helpers/api";
import {
ApiInfo,
Media,
MediaCollection,
MediaResponse,
} from "../../helpers/api.types";
import { useApplicationStore } from "../../store/ApplicationStore";
import { mediaGetVariantUrl } from "../../helpers/media";
import SMInput from "../SMInput.vue";
import SMLoading from "../SMLoading.vue";
import SMTabGroup from "../SMTabGroup.vue";
import SMTab from "../SMTab.vue";
import { Form, FormControl } from "../../helpers/form";
import { And, Min, Required } from "../../helpers/validate";
import { convertFileNameToTitle } from "../../helpers/utils";
import { bytesReadable } from "../../helpers/types";
import { SMDate } from "../../helpers/datetime";
import { isUUID } from "../../helpers/uuid";
const props = defineProps({
mime: {
type: String,
default: "image/",
required: false,
},
accepts: {
type: String,
default: "image/*",
required: false,
},
allowUpload: {
type: Boolean,
default: false,
required: false,
},
});
/**
* Reference to the File Upload Input element.
*/
const refUploadInput = ref<HTMLInputElement | null>(null);
const refMediaList = ref<HTMLUListElement | null>(null);
/**
* The selected tab
*/
const selectedTab = ref("tab-browser");
/**
* Max upload size
*/
const max_upload_size = ref(" ");
/**
* Upload form
*/
let uploadForm = reactive(
Form({
title: FormControl("", And([Required(), Min(4)])),
description: FormControl(""),
}),
);
/**
* Is the media loading/busy
*/
const mediaLoading = ref(true);
/**
* Current page.
*/
const page = ref(1);
/**
* Total media items expressed by API.
*/
const totalItems = ref(0);
/**
* List of current media items.
*/
const mediaItems: Ref<Media[]> = ref([]);
/**
* Selected media item id.
*/
const selected: Ref<Media | null> = ref(null);
/**
* How many media items are we showing per page.
*/
const perPage = ref(24);
const showFileDrop = ref(false);
const applicationStore = useApplicationStore();
const progressText = ref("");
const currentUploadFileNum = ref(0);
const uploadFileList: Ref<File[]> = ref(null);
/**
* Returns the file types accepted.
*/
const computedAccepts = computed(() => {
if (props.accepts.length > 0) {
return props.accepts;
}
if (props.mime.endsWith("/")) {
return `${props.mime}*`;
}
return props.mime;
});
/**
* Get the media item by id.
* @param {string} item_id The media item id.
* @returns {Media | null} The media object or null.
*/
const getMediaItem = (item_id: string): Media | null => {
let found: Media | null = null;
mediaItems.value.every((item) => {
if (item.id == item_id) {
found = item;
return false;
}
return true;
});
return found;
};
/**
* Handle user clicking the cancel/close button.
*/
const handleClickCancel = () => {
closeDialog(false);
};
/**
* Handle user clicking the select button.
*/
const handleClickSelect = async () => {
if (selectedTab.value == "tab-browser") {
if (selected.value != null) {
closeDialog(selected.value);
return;
}
}
};
/**
* Handle user clicking a media item (selecting).
* @param {string} item_id The media id.
*/
const handleClickItem = (item_id: string): void => {
if (isUUID(item_id)) {
selected.value = getMediaItem(item_id);
} else {
selected.value = null;
}
};
/**
* Handle user double clicking a media item.
* @param item_id The media id.
*/
const handleDblClickItem = (item_id: string): void => {
if (isUUID(item_id)) {
const mediaItem = getMediaItem(item_id);
if (mediaItem != null) {
closeDialog(mediaItem);
return;
}
closeDialog(false);
}
};
/**
* When the user clicks the upload button
*/
const handleClickSelectFile = async () => {
if (refUploadInput.value != null) {
refUploadInput.value.click();
}
};
/**
* Upload the file to the server.
*/
const handleChangeSelectFile = async () => {
uploadForm._message = "";
if (refUploadInput.value != null && refUploadInput.value.files != null) {
handleFilesUpload(refUploadInput.value.files);
showFileBrowserTab();
}
};
const handleFilesUpload = (files: FileList) => {
Array.from(files).forEach((file, index) => {
console.log(index);
mediaItems.value.unshift({
id: (currentUploadFileNum.value + index + 1).toString(),
user_id: "",
title: "",
name: file.name,
mime_type: "",
permission: "",
size: 0,
status: "",
storage: "",
url: "",
description: "",
dimensions: "",
variants: {},
created_at: "",
updated_at: "",
});
});
if (uploadFileList.value != null) {
uploadFileList.value.push(...Array.from(files));
} else {
uploadFileList.value = Array.from(files);
}
startFilesUpload();
};
const startFilesUpload = () => {
if (uploadFileList.value != null) {
if (currentUploadFileNum.value < 1) {
currentUploadFileNum.value = 1;
while (currentUploadFileNum.value <= uploadFileList.value.length) {
let submitFormData = new FormData();
submitFormData.append("file", file);
submitFormData.append("title", convertFileNameToTitle(file.name));
submitFormData.append("description", "");
try {
let result = await api.post({
url: "/media",
body: submitFormData,
headers: {
"Content-Type": "multipart/form-data",
}
});
if(result.adata)
}
catch ((error) => {
/* empty */
});
}
}
}
};
// const firstFile: File | undefined = refUploadInput.value.files[0];
// if (firstFile != null) {
// if (uploadForm.controls.title.value.length == 0) {
// uploadForm.controls.title.value = convertFileNameToTitle(
// firstFile.name,
// );
// }
// const reader = new FileReader();
// reader.onload = (event) => {
// const imgSrc = event.target.result;
// uploadPreview.value = imgSrc as string;
// };
// reader.readAsDataURL(firstFile);
// }
// } else if (selectedTab.value == "tab-upload") {
// if (
// refUploadInput.value != null &&
// refUploadInput.value.files != null
// ) {
// const firstFile: File | undefined = refUploadInput.value.files[0];
// if (firstFile != null) {
// let submitFormData = new FormData();
// submitFormData.append("file", firstFile);
// submitFormData.append("title", uploadForm.controls.title.value);
// submitFormData.append(
// "description",
// uploadForm.controls.description.value,
// );
// try {
// let result = await api.post({
// url: "/media",
// body: submitFormData,
// headers: {
// "Content-Type": "multipart/form-data",
// },
// progress: (progressData) =>
// (progressText.value = `Uploading File: ${Math.floor(
// (progressData.loaded / progressData.total) *
// 100,
// )}%`),
// });
// if (result.data) {
// const data = result.data as MediaResponse;
// if (
// data.medium.status != "OK" &&
// data.medium.status.startsWith("Failed") == false
// ) {
// progressText.value = `${data.medium.status}...`;
// let mediaProcessed = false;
// let timeout = 0;
// while (mediaProcessed == false) {
// timeout++;
// if (timeout >= 60) {
// mediaProcessed = true;
// uploadForm._message =
// "The server is taking longer then expected to process the file.\nOnce the file has been processed, select it from the media browser.";
// } else {
// await new Promise((resolve) =>
// setTimeout(resolve, 500),
// );
// try {
// let updateResult = await api.get({
// url: "/media/{id}",
// params: {
// id: data.medium.id,
// },
// });
// if (updateResult.data) {
// const updateData =
// updateResult.data as MediaResponse;
// if (
// updateData.medium.status == "OK"
// ) {
// data.medium = updateData.medium;
// mediaProcessed = true;
// } else if (
// updateData.medium.status.startsWith(
// "Failed",
// ) == true
// ) {
// throw "error";
// } else {
// progressText.value = `${updateData.medium.status}...`;
// }
// } else {
// throw "error";
// }
// } catch {
// mediaProcessed = true;
// uploadForm._message =
// "An server error occurred processing the file.";
// }
// }
// }
// if (data.medium.status == "OK") {
// closeDialog(data.medium);
// } else {
// return;
// }
// }
// } else {
// uploadForm._message =
// "An unexpected response was received from the server";
// }
// } catch (error) {
// if (error.status === 413) {
// uploadForm._message =
// "The selected file is larger than the maximum size limit";
// } else {
// uploadForm._message =
// error.response?.data?.message ||
// "An unexpected error occurred";
// }
// } finally {
// progressText.value = "";
// }
// } else {
// uploadForm._message = "No file was selected to upload";
// }
// } else {
// uploadForm._message = "No file was selected to upload";
// }
let prevItemSearch = "";
const itemSearch = ref("");
const handleSearch = () => {
if (prevItemSearch !== itemSearch.value) {
prevItemSearch = itemSearch.value;
mediaItems.value = [];
totalItems.value = 0;
page.value = 1;
handleLoad();
}
};
/**
* Load the data of the dialog
*/
const handleLoad = async () => {
mediaLoading.value = true;
let params = {
page: page.value,
limit: perPage.value,
status: "!Failed",
};
if (itemSearch.value.length > 0) {
let value = itemSearch.value.replace(/"/g, '\\"');
params[
"filter"
] = `(title:"${value}",OR,name:"${value}",OR,description:"${value}")`;
}
api.get({
url: "/media",
params: params,
})
.then((result) => {
if (result.data) {
const data = result.data as MediaCollection;
totalItems.value = data.total;
mediaItems.value.push(...data.media);
}
})
.catch((error) => {
uploadForm._message =
error?.data?.message || "An unexpected error occurred";
})
.finally(() => {
mediaLoading.value = false;
});
};
/**
* Handle a keyboard event in this component.
* @param {KeyboardEvent} event The keyboard event.
* @returns {boolean} If the event was handled.
*/
const eventKeyPress = (event: KeyboardEvent): boolean => {
if (event.key === "Escape") {
handleClickCancel();
return true;
} else if (event.key === "Enter") {
if (selected.value != null) {
handleClickSelect();
}
return true;
}
return false;
};
onMounted(() => {
applicationStore.addKeyPressListener(eventKeyPress);
});
onUnmounted(() => {
applicationStore.removeKeyPressListener(eventKeyPress);
});
watch(page, () => {
handleLoad();
});
/**
* Determine if the Select button should be disabled
*/
const computedSelectDisabled = computed(() => {
if (selectedTab.value == "tab-browser") {
return selected.value == null;
}
return true;
});
const handleDragEnter = () => {
if (!showFileDrop.value) {
showFileDrop.value = true;
}
};
const handleDragOver = () => {
if (!showFileDrop.value) {
showFileDrop.value = true;
}
};
const handleDragLeave = () => {
if (showFileDrop.value) {
showFileDrop.value = false;
}
};
const handleDrop = (event) => {
showFileDrop.value = false;
handleFilesUpload(event.dataTransfer.files);
showFileBrowserTab();
};
const handleLoadMore = () => {
page.value++;
};
const showFileBrowserTab = () => {
selectedTab.value = "tab-browser";
window.setTimeout(() => {
refMediaList.value.scrollTo({
top: 0,
left: 0,
behavior: "smooth",
});
}, 50);
};
// Get max upload size
api.get({
url: "",
})
.then((result) => {
if (result.data) {
const data = result.data as ApiInfo;
max_upload_size.value = `Maximum upload file size: ${bytesReadable(
data.max_upload_size,
)}.`;
}
})
.catch(() => {
/* empty */
});
handleLoad();
</script>