|
@@ -14,7 +14,7 @@
|
|
|
<span v-if="required" class="text-negative q-ml-xs">*</span>
|
|
<span v-if="required" class="text-negative q-ml-xs">*</span>
|
|
|
</div>
|
|
</div>
|
|
|
<q-field
|
|
<q-field
|
|
|
- v-model="model"
|
|
|
|
|
|
|
+ :model-value="preview"
|
|
|
v-bind="inputAttrs"
|
|
v-bind="inputAttrs"
|
|
|
borderless
|
|
borderless
|
|
|
hide-bottom-space
|
|
hide-bottom-space
|
|
@@ -24,7 +24,7 @@
|
|
|
class="image-preview-container"
|
|
class="image-preview-container"
|
|
|
>
|
|
>
|
|
|
<div
|
|
<div
|
|
|
- class=""
|
|
|
|
|
|
|
+ class="file-picker-inner"
|
|
|
:class="{
|
|
:class="{
|
|
|
'has-image': preview,
|
|
'has-image': preview,
|
|
|
'is-dragging': isDragging,
|
|
'is-dragging': isDragging,
|
|
@@ -43,51 +43,35 @@
|
|
|
color="grey-6"
|
|
color="grey-6"
|
|
|
class="absolute-center"
|
|
class="absolute-center"
|
|
|
/>
|
|
/>
|
|
|
- <div
|
|
|
|
|
- class="text-caption text-grey-6 text-center absolute-bottom q-pb-sm q-px-md"
|
|
|
|
|
- >
|
|
|
|
|
|
|
+ <div class="text-caption text-grey-6 text-center absolute-bottom q-pb-sm q-px-md">
|
|
|
{{
|
|
{{
|
|
|
isDragging
|
|
isDragging
|
|
|
? $t("common.ui.file.drag_and_drop")
|
|
? $t("common.ui.file.drag_and_drop")
|
|
|
- : type == "image"
|
|
|
|
|
|
|
+ : type === "image"
|
|
|
? $t("common.ui.file.click_select_image")
|
|
? $t("common.ui.file.click_select_image")
|
|
|
: $t("common.ui.file.click_select")
|
|
: $t("common.ui.file.click_select")
|
|
|
}}
|
|
}}
|
|
|
</div>
|
|
</div>
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
- <q-img
|
|
|
|
|
|
|
+ <div
|
|
|
v-else-if="type === 'image'"
|
|
v-else-if="type === 'image'"
|
|
|
- :src="preview"
|
|
|
|
|
- fit="cover"
|
|
|
|
|
- class="full-height"
|
|
|
|
|
|
|
+ class="file-picker-image"
|
|
|
|
|
+ :style="{ backgroundImage: `url('${preview}')` }"
|
|
|
/>
|
|
/>
|
|
|
|
|
|
|
|
- <div v-else class="position-relative column full-height flex-center">
|
|
|
|
|
|
|
+ <div v-else class="column flex-center" style="height: 100%">
|
|
|
<q-icon name="mdi-file-check" size="48px" color="grey-6" />
|
|
<q-icon name="mdi-file-check" size="48px" color="grey-6" />
|
|
|
- <div
|
|
|
|
|
- class="absolute-bottom text-caption text-grey-6 text-center q-mb-sm q-px-md"
|
|
|
|
|
- >
|
|
|
|
|
- {{ model.name }}
|
|
|
|
|
|
|
+ <div class="text-caption text-grey-6 text-center q-mt-sm q-px-md">
|
|
|
|
|
+ {{ internalFile?.name }}
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
-
|
|
|
|
|
- <div v-if="preview" class="absolute-top-right q-ma-xs">
|
|
|
|
|
- <q-btn
|
|
|
|
|
- flat
|
|
|
|
|
- dense
|
|
|
|
|
- round
|
|
|
|
|
- color="negative"
|
|
|
|
|
- icon="mdi-close"
|
|
|
|
|
- @click.stop="clearFile"
|
|
|
|
|
- />
|
|
|
|
|
- </div>
|
|
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<q-file
|
|
<q-file
|
|
|
v-show="false"
|
|
v-show="false"
|
|
|
ref="fileInputRef"
|
|
ref="fileInputRef"
|
|
|
- v-model="model"
|
|
|
|
|
|
|
+ v-model="internalFile"
|
|
|
:accept="accept"
|
|
:accept="accept"
|
|
|
/>
|
|
/>
|
|
|
</q-field>
|
|
</q-field>
|
|
@@ -95,21 +79,13 @@
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
<script setup>
|
|
<script setup>
|
|
|
-import {
|
|
|
|
|
- ref,
|
|
|
|
|
- watch,
|
|
|
|
|
- onUnmounted,
|
|
|
|
|
- useTemplateRef,
|
|
|
|
|
- useAttrs,
|
|
|
|
|
- computed,
|
|
|
|
|
- onBeforeMount,
|
|
|
|
|
-} from "vue";
|
|
|
|
|
|
|
+import { ref, watch, computed, onUnmounted, useTemplateRef, useAttrs } from "vue";
|
|
|
|
|
|
|
|
defineOptions({
|
|
defineOptions({
|
|
|
inheritAttrs: false,
|
|
inheritAttrs: false,
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
-const { label, rules, accept, type, initialImage } = defineProps({
|
|
|
|
|
|
|
+const props = defineProps({
|
|
|
label: {
|
|
label: {
|
|
|
type: String,
|
|
type: String,
|
|
|
default: "Select Image",
|
|
default: "Select Image",
|
|
@@ -147,117 +123,91 @@ const model = defineModel({ type: [File, String, null], default: null });
|
|
|
const base64File = defineModel("base64File", { type: String, default: null });
|
|
const base64File = defineModel("base64File", { type: String, default: null });
|
|
|
|
|
|
|
|
const isDragging = ref(false);
|
|
const isDragging = ref(false);
|
|
|
-const preview = ref(initialImage || null);
|
|
|
|
|
-const required = ref(false);
|
|
|
|
|
|
|
+const internalFile = ref(null);
|
|
|
|
|
+const objectUrl = ref(null);
|
|
|
|
|
|
|
|
-let objectUrl = null;
|
|
|
|
|
|
|
+const required = computed(() => props.rules.some((r) => r?.$id === "required"));
|
|
|
|
|
|
|
|
-const cleanupObjectURL = () => {
|
|
|
|
|
- if (objectUrl) {
|
|
|
|
|
- URL.revokeObjectURL(objectUrl);
|
|
|
|
|
- objectUrl = null;
|
|
|
|
|
|
|
+const preview = computed(() => {
|
|
|
|
|
+ if (internalFile.value instanceof File) {
|
|
|
|
|
+ return props.type === "image" ? objectUrl.value : "file_selected";
|
|
|
}
|
|
}
|
|
|
-};
|
|
|
|
|
|
|
+ return props.initialImage || null;
|
|
|
|
|
+});
|
|
|
|
|
|
|
|
-const generateBase64 = (file) => {
|
|
|
|
|
- if (!file) {
|
|
|
|
|
- base64File.value = null;
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- const reader = new FileReader();
|
|
|
|
|
- reader.onload = (e) => {
|
|
|
|
|
- base64File.value = e.target.result;
|
|
|
|
|
- };
|
|
|
|
|
- reader.onerror = () => {
|
|
|
|
|
- console.error("FileReader failed to read file.");
|
|
|
|
|
- base64File.value = null;
|
|
|
|
|
- };
|
|
|
|
|
- reader.readAsDataURL(file);
|
|
|
|
|
-};
|
|
|
|
|
|
|
+const inputAttrs = computed(() => {
|
|
|
|
|
+ // eslint-disable-next-line
|
|
|
|
|
+ const { class: _, style: __, ...rest } = attrs;
|
|
|
|
|
+ return rest;
|
|
|
|
|
+});
|
|
|
|
|
|
|
|
-const pickFile = () => {
|
|
|
|
|
|
|
+function pickFile() {
|
|
|
fileInputRef.value?.pickFiles();
|
|
fileInputRef.value?.pickFiles();
|
|
|
-};
|
|
|
|
|
-
|
|
|
|
|
-const clearFile = () => {
|
|
|
|
|
- model.value = null;
|
|
|
|
|
-};
|
|
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
-const handleDragOver = (event) => {
|
|
|
|
|
|
|
+function handleDragOver(event) {
|
|
|
event.preventDefault();
|
|
event.preventDefault();
|
|
|
event.dataTransfer.dropEffect = "copy";
|
|
event.dataTransfer.dropEffect = "copy";
|
|
|
isDragging.value = true;
|
|
isDragging.value = true;
|
|
|
-};
|
|
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
-const handleDragLeave = () => {
|
|
|
|
|
|
|
+function handleDragLeave() {
|
|
|
isDragging.value = false;
|
|
isDragging.value = false;
|
|
|
-};
|
|
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
-const handleDrop = (event) => {
|
|
|
|
|
|
|
+function handleDrop(event) {
|
|
|
event.preventDefault();
|
|
event.preventDefault();
|
|
|
isDragging.value = false;
|
|
isDragging.value = false;
|
|
|
-
|
|
|
|
|
const file = event.dataTransfer?.files?.[0];
|
|
const file = event.dataTransfer?.files?.[0];
|
|
|
if (!file) return;
|
|
if (!file) return;
|
|
|
-
|
|
|
|
|
- const acceptedMime = accept;
|
|
|
|
|
-
|
|
|
|
|
|
|
+ const acceptedMime = props.accept;
|
|
|
if (acceptedMime.endsWith("/*")) {
|
|
if (acceptedMime.endsWith("/*")) {
|
|
|
const baseMime = acceptedMime.replace("/*", "");
|
|
const baseMime = acceptedMime.replace("/*", "");
|
|
|
- if (file.type.startsWith(baseMime + "/")) {
|
|
|
|
|
- model.value = file;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if (file.type.startsWith(baseMime + "/")) internalFile.value = file;
|
|
|
} else {
|
|
} else {
|
|
|
- if (
|
|
|
|
|
- acceptedMime
|
|
|
|
|
- .split(",")
|
|
|
|
|
- .map((m) => m.trim())
|
|
|
|
|
- .includes(file.type)
|
|
|
|
|
- ) {
|
|
|
|
|
- model.value = file;
|
|
|
|
|
|
|
+ if (acceptedMime.split(",").map((m) => m.trim()).includes(file.type)) {
|
|
|
|
|
+ internalFile.value = file;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
-};
|
|
|
|
|
-
|
|
|
|
|
-const inputAttrs = computed(() => {
|
|
|
|
|
- // eslint-disable-next-line
|
|
|
|
|
- const { class: _, style: __, ...rest } = attrs;
|
|
|
|
|
- return rest;
|
|
|
|
|
-});
|
|
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
-watch(model, (newFile) => {
|
|
|
|
|
- cleanupObjectURL();
|
|
|
|
|
|
|
+function generateBase64(file) {
|
|
|
|
|
+ if (!file) { base64File.value = null; return; }
|
|
|
|
|
+ const reader = new FileReader();
|
|
|
|
|
+ reader.onload = (e) => { base64File.value = e.target.result; };
|
|
|
|
|
+ reader.onerror = () => { base64File.value = null; };
|
|
|
|
|
+ reader.readAsDataURL(file);
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- if (newFile && newFile instanceof File) {
|
|
|
|
|
- if (type === "image") {
|
|
|
|
|
- objectUrl = URL.createObjectURL(newFile);
|
|
|
|
|
- preview.value = objectUrl;
|
|
|
|
|
- } else {
|
|
|
|
|
- preview.value = "file_selected";
|
|
|
|
|
|
|
+watch(internalFile, (newFile) => {
|
|
|
|
|
+ if (objectUrl.value) {
|
|
|
|
|
+ URL.revokeObjectURL(objectUrl.value);
|
|
|
|
|
+ objectUrl.value = null;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (newFile instanceof File) {
|
|
|
|
|
+ if (props.type === "image") {
|
|
|
|
|
+ objectUrl.value = URL.createObjectURL(newFile);
|
|
|
}
|
|
}
|
|
|
|
|
+ model.value = newFile;
|
|
|
generateBase64(newFile);
|
|
generateBase64(newFile);
|
|
|
} else {
|
|
} else {
|
|
|
- preview.value = initialImage || null;
|
|
|
|
|
|
|
+ model.value = null;
|
|
|
base64File.value = null;
|
|
base64File.value = null;
|
|
|
}
|
|
}
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
-watch(
|
|
|
|
|
- () => rules,
|
|
|
|
|
- (values) => {
|
|
|
|
|
- values.forEach((r) => {
|
|
|
|
|
- if (r?.$id === "required") return (required.value = true);
|
|
|
|
|
- });
|
|
|
|
|
- },
|
|
|
|
|
-);
|
|
|
|
|
-
|
|
|
|
|
-onBeforeMount(() => {
|
|
|
|
|
- rules.forEach((r) => {
|
|
|
|
|
- if (r?.$id === "required") return (required.value = true);
|
|
|
|
|
- });
|
|
|
|
|
|
|
+watch(model, (val) => {
|
|
|
|
|
+ if (!val && internalFile.value) {
|
|
|
|
|
+ internalFile.value = null;
|
|
|
|
|
+ }
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
-onUnmounted(cleanupObjectURL);
|
|
|
|
|
|
|
+onUnmounted(() => {
|
|
|
|
|
+ if (objectUrl.value) {
|
|
|
|
|
+ URL.revokeObjectURL(objectUrl.value);
|
|
|
|
|
+ objectUrl.value = null;
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
</script>
|
|
</script>
|
|
|
|
|
|
|
|
<style lang="scss">
|
|
<style lang="scss">
|
|
@@ -267,46 +217,44 @@ onUnmounted(cleanupObjectURL);
|
|
|
.image-preview-container {
|
|
.image-preview-container {
|
|
|
display: flex;
|
|
display: flex;
|
|
|
justify-content: center;
|
|
justify-content: center;
|
|
|
- align-items: center;
|
|
|
|
|
|
|
+ align-items: stretch;
|
|
|
|
|
|
|
|
.q-field__inner {
|
|
.q-field__inner {
|
|
|
- .body--dark & {
|
|
|
|
|
- --image-bg-color: #{map.get($colors-dark, "surface")};
|
|
|
|
|
- --image-border-color: #{map.get($colors-dark, "primary")};
|
|
|
|
|
- --image-border-hover-color: #{map.get($colors-dark, "primary-dark")};
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
.body--light & {
|
|
.body--light & {
|
|
|
--image-bg-color: #{map.get($colors, "surface")};
|
|
--image-bg-color: #{map.get($colors, "surface")};
|
|
|
- --image-border-color: #{map.get($colors, "primary")};
|
|
|
|
|
- --image-border-hover-color: #{map.get($colors, "primary-dark")};
|
|
|
|
|
|
|
+ --image-border-color: #{map.get($colors, "violet-normal")};
|
|
|
|
|
+ --image-border-hover-color: #{map.get($colors, "violet-normal-hover")};
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ height: 100%;
|
|
|
display: flex;
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
flex-direction: column;
|
|
|
- transition: all 0.3s;
|
|
|
|
|
-
|
|
|
|
|
- &.is-dragging {
|
|
|
|
|
- border-color: var(--image-border-hover-color);
|
|
|
|
|
- background-color: var(--image-bg-color);
|
|
|
|
|
- opacity: 0.8;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- &.has-image {
|
|
|
|
|
- border-style: solid;
|
|
|
|
|
- margin: auto;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ transition: border-color 0.3s;
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
.q-field__control {
|
|
.q-field__control {
|
|
|
height: 100%;
|
|
height: 100%;
|
|
|
min-height: 200px;
|
|
min-height: 200px;
|
|
|
border: 2px dashed var(--image-border-color);
|
|
border: 2px dashed var(--image-border-color);
|
|
|
border-radius: 8px;
|
|
border-radius: 8px;
|
|
|
cursor: pointer;
|
|
cursor: pointer;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+
|
|
|
|
|
+ &:has(.has-image) {
|
|
|
|
|
+ border-style: solid;
|
|
|
|
|
+ border-color: var(--image-border-color);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ &:has(.is-dragging) {
|
|
|
|
|
+ border-color: var(--image-border-hover-color);
|
|
|
|
|
+ opacity: 0.8;
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
.q-field__control-container {
|
|
.q-field__control-container {
|
|
|
- justify-content: center;
|
|
|
|
|
|
|
+ height: 100%;
|
|
|
|
|
+ justify-content: stretch;
|
|
|
|
|
+ align-items: stretch;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
.q-field__append {
|
|
.q-field__append {
|
|
@@ -314,5 +262,21 @@ onUnmounted(cleanupObjectURL);
|
|
|
top: -5px;
|
|
top: -5px;
|
|
|
right: 10px;
|
|
right: 10px;
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ .file-picker-inner {
|
|
|
|
|
+ position: relative;
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ height: 100%;
|
|
|
|
|
+ min-height: 196px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .file-picker-image {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ inset: 0;
|
|
|
|
|
+ background-size: cover;
|
|
|
|
|
+ background-position: center;
|
|
|
|
|
+ background-repeat: no-repeat;
|
|
|
|
|
+ border-radius: 6px;
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
</style>
|
|
</style>
|