Progress on user image UI

This commit is contained in:
ThaumRystra
2024-07-28 03:04:27 +02:00
parent b853922749
commit a8310c4817
12 changed files with 524 additions and 210 deletions

View File

@@ -0,0 +1,5 @@
export default async function timeout(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}

View File

@@ -1,9 +1,25 @@
<template>
<outlined-input
:name="label"
class="mb-6 pt-1"
class="smart-image-input mb-3 pt-4"
:data-id="id"
:class="{ dragging }"
@click="openImageInputDialog"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
>
<v-file-input
<img
v-if="value"
class="image"
:src="value"
>
</outlined-input>
</template>
<script lang="js">
/*
<v-file-input
v-cloak
ref="input"
v-bind="$attrs"
@@ -19,11 +35,6 @@
@blur="focused = false"
@keyup="e => $emit('keyup', e)"
/>
</outlined-input>
</template>
<script lang="js">
/*
States to handle:
- Empty
- Image from URL
@@ -43,12 +54,23 @@
*/
import UserImages from '/imports/api/files/userImages/UserImages';
import SmartInput from '/imports/client/ui/components/global/SmartInputMixin';
import OutlinedInput from '/imports/client/ui/properties/viewers/shared/OutlinedInput.vue';
export default {
components: {
OutlinedInput,
},
mixins: [SmartInput],
props: {
label: {
type: String,
default: '',
},
},
data() {
return {
url: '',
id: Random.id(),
dragging: false,
};
},
watch: {
@@ -100,6 +122,15 @@ export default {
}
},
methods: {
openImageInputDialog() {
this.$store.commit('pushDialogStack', {
component: 'image-input-dialog',
elementId: this.id,
data: {
href: this.value,
},
});
},
handleUrlChange() {
this.$emit('change', this.url);
},
@@ -108,11 +139,17 @@ export default {
this.uploadFile(file);
},
handleDragOver(event) {
// TODO
event.preventDefault();
this.dragging = true;
},
handleDragLeave() {
this.dragging = false;
},
handleDrop(event) {
// TODO
console.log(event);
event.preventDefault();
const file = event.dataTransfer.files[0];
this.dragging = false;
this.uploadFile(file);
},
uploadFile(file) {
@@ -120,4 +157,24 @@ export default {
},
},
};
</script>
</script>
<style scoped>
.smart-image-input {
min-height: 120px;
cursor: pointer;
}
.image {
max-height: 400px;
max-width: 100%;
}
.dragging {
border-style: dashed;
}
.outlined-input.dragging.theme--dark:not(.no-hover) {
border-color: #fff;
}
.outlined-input.dragging.theme--light:not(.no-hover) {
border-color: rgba(0,0,0,.86);
}
</style>

View File

@@ -1,9 +1,8 @@
// Load commonly used dialogs immediately
import ActionDialog from '/imports/client/ui/creature/actions/ActionDialog.vue';
import InsertPropertyDialog from '/imports/client/ui/properties/InsertPropertyDialog.vue';
import CastSpellWithSlotDialog from '/imports/client/ui/properties/components/spells/CastSpellWithSlotDialog.vue';
import CharacterCreationDialog from '/imports/client/ui/creature/character/CharacterCreationDialog.vue';
import CharacterSheetDialog from '/imports/client/ui/tabletop/CharacterSheetDialog.vue';
import CastSpellWithSlotDialog from '/imports/client/ui/properties/components/spells/CastSpellWithSlotDialog.vue';
import CreatureFormDialog from '/imports/client/ui/creature/CreatureFormDialog.vue';
import CreaturePropertyDialog from '/imports/client/ui/creature/creatureProperties/CreaturePropertyDialog.vue';
import CreaturePropertyFromLibraryDialog from '/imports/client/ui/creature/creatureProperties/CreaturePropertyFromLibraryDialog.vue';
@@ -12,18 +11,21 @@ import DeleteConfirmationDialog from '/imports/client/ui/dialogStack/DeleteConfi
import ExperienceInsertDialog from '/imports/client/ui/creature/experiences/ExperienceInsertDialog.vue';
import ExperienceListDialog from '/imports/client/ui/creature/experiences/ExperienceListDialog.vue';
import HelpDialog from '/imports/client/ui/dialogStack/HelpDialog.vue';
import ImagePreviewDialog from '/imports/client/ui/files/userImages/ImagePreviewDialog.vue';
import InsertPropertyDialog from '/imports/client/ui/properties/InsertPropertyDialog.vue';
import LevelUpDialog from '/imports/client/ui/creature/slots/LevelUpDialog.vue';
import LibraryBrowserDialog from '/imports/client/ui/library/LibraryBrowserDialog.vue';
import SelectLibraryNodeDialog from '/imports/client/ui/library/SelectLibraryNodeDialog.vue';
import SlotFillDialog from '/imports/client/ui/creature/slots/SlotFillDialog.vue';
import TierTooLowDialog from '/imports/client/ui/user/TierTooLowDialog.vue';
import TransferOwnershipDialog from '/imports/client/ui/sharing/TransferOwnershipDialog.vue';
import LibraryBrowserDialog from '/imports/client/ui/library/LibraryBrowserDialog.vue';
// Lazily load less common dialogs
const ArchiveDialog = () => import('/imports/client/ui/creature/archive/ArchiveDialog.vue');
const CreatureFromLibraryDialog = () => import('/imports/client/ui/tabletop/CreatureFromLibraryDialog.vue');
const DeleteUserAccountDialog = () => import('/imports/client/ui/user/DeleteUserAccountDialog.vue');
const DependencyGraphDialog = () => import('/imports/client/ui/creature/dependencyGraph/DependencyGraphDialog.vue');
const ImageInputDialog = () => import('../files/userImages/ImageInputDialog.vue');
const InviteDialog = () => import('/imports/client/ui/user/InviteDialog.vue');
const LibraryCollectionCreationDialog = () => import('/imports/client/ui/library/LibraryCollectionCreationDialog.vue');
const LibraryCollectionEditDialog = () => import('/imports/client/ui/library/LibraryCollectionEditDialog.vue');
@@ -38,7 +40,6 @@ const UsernameDialog = () => import('/imports/client/ui/user/UsernameDialog.vue'
export default {
ActionDialog,
InsertPropertyDialog,
ArchiveDialog,
CastSpellWithSlotDialog,
CharacterCreationDialog,
@@ -54,6 +55,9 @@ export default {
ExperienceInsertDialog,
ExperienceListDialog,
HelpDialog,
ImageInputDialog,
ImagePreviewDialog,
InsertPropertyDialog,
InviteDialog,
LevelUpDialog,
LibraryBrowserDialog,
@@ -67,8 +71,8 @@ export default {
SelectLibraryNodeDialog,
ShareDialog,
SlotFillDialog,
TabletopDialog,
TierTooLowDialog,
TransferOwnershipDialog,
TabletopDialog,
UsernameDialog,
};

View File

@@ -1,8 +1,6 @@
<template>
<v-layout
<div
class="dialog-stack"
align-center
justify-center
>
<transition name="backdrop-fade">
<div
@@ -13,42 +11,61 @@
</transition>
<transition-group
name="dialog-list"
class="dialog-sizer"
class="dialog-transition-group"
:class="{shake}"
tag="div"
@enter="enter"
@leave="leave"
>
<v-card
v-for="(dialog, index) in dialogs"
:key="dialog._id"
:ref="index"
class="dialog"
:data-element-id="dialog.elementId"
:data-index="index"
:style="getDialogStyle(index)"
:elevation="6"
>
<transition name="slide">
<component
:is="dialog.component"
v-bind="dialog.data"
class="dialog-component"
@pop="popDialogStack($event)"
/>
</transition>
</v-card>
<template v-for="(dialog, index) in dialogs">
<component
:is="dialog.component"
v-if="isUnsizedDialog(dialog.component)"
:key="dialog._id"
:ref="index"
v-bind="dialog.data"
class="unsized-dialog dialog-component"
:data-element-id="dialog.elementId"
:data-index="index"
:style="getDialogStyle(index)"
:elevation="6"
@pop="popDialogStack($event)"
/>
<v-card
v-else
:key="dialog._id"
:ref="index"
class="dialog"
:data-element-id="dialog.elementId"
:data-index="index"
:style="getDialogStyle(index)"
:elevation="6"
>
<transition name="slide">
<component
:is="dialog.component"
v-bind="dialog.data"
class="sized-dialog dialog-component"
@pop="popDialogStack($event)"
/>
</transition>
</v-card>
</template>
</transition-group>
</v-layout>
</div>
</template>
<script lang="js">
import '/imports/client/ui/dialogStack/dialogStackWindowEvents';
import mockElement from '/imports/client/ui/dialogStack/mockElement';
import DialogComponentIndex from '/imports/client/ui/dialogStack/DialogComponentIndex';
import timeout from '/imports/api/utility/timeout';
const OFFSET = 16;
const MOCK_DURATION = 400; // Keep in sync with css transition of .dialog
// Use in combination with browser's animation speed override to do slow-mod debugging
const animationSpeed = 1;
const unsizedDialogs = new Set(['image-preview-dialog']);
export default {
components: {
@@ -64,16 +81,17 @@
},
},
watch: {
dialogs(newDialogs) {
async dialogs(newDialogs) {
const el = document.documentElement;
if (newDialogs.length) {
this.top = el.scrollTop;
if (el.scrollHeight > el.clientHeight){
el.style.overflowY = 'hidden';
el.scrollTop = this.top;
el.classList.add('lock-scroll');
}
} else {
el.style.overflowY = null;
await timeout(400 / animationSpeed);
el.classList.remove('lock-scroll');
el.scrollTop = this.top;
}
}
@@ -82,6 +100,9 @@
popDialogStack(result){
this.$store.dispatch('popDialogStack', result);
},
isUnsizedDialog(component) {
return unsizedDialogs.has(component);
},
backdropClicked(event) {
// If the target was not the backdrop, ignore
if (event.target !== event.currentTarget) return;
@@ -107,8 +128,8 @@
if (index >= length) return;
const num = length - 1;
const left = (num - index) * -OFFSET;
const top = (num - index) * -OFFSET;
return `left:${left}px; top:${top}px;`;
const top = (num - index) * -OFFSET;
return `left: calc(${left}px + 50%); top: calc(${top}px + 50%)`;
},
getTopElementByDataId(elementId, offset = 0){
let stackLength = this.$store.state.dialogStack.dialogs.length - offset;
@@ -122,7 +143,7 @@
document.querySelector(`[data-id='${elementId}']`);
}
},
enter(target, done){
async enter(target, done){
if (!target || !target.attributes['data-element-id']){
done();
return;
@@ -143,34 +164,35 @@
sourceTransition: source.style.transition,
}
// Instantly mock the source
target.style.transition = 'none';
mockElement({ source, target });
// Wait one frame before hiding the source so we know our mock is in place
await new Promise(requestAnimationFrame);
// hide the source
source.style.transition = 'none';
source.style.opacity = '0';
this.hiddenElements.push(source);
// Instantly mock the source
target.style.transition = 'none';
mockElement({source, target});
// on the next animation frame, repair the styles
requestAnimationFrame(() => {
target.style.transform = originalStyle.transform;
target.style.backgroundColor = originalStyle.backgroundColor;
target.style.borderRadius = originalStyle.borderRadius;
target.style.transition = originalStyle.transition;
target.style.boxShadow = originalStyle.boxShadow;
source.style.transition = originalStyle.sourceTransition;
setTimeout(done, MOCK_DURATION);
});
// repair the styles so that our mock is undone revealing the dialog
target.style.transform = originalStyle.transform;
target.style.backgroundColor = originalStyle.backgroundColor;
target.style.borderRadius = originalStyle.borderRadius;
target.style.transition = originalStyle.transition;
target.style.boxShadow = originalStyle.boxShadow;
source.style.transition = originalStyle.sourceTransition;
setTimeout(done, 300 / animationSpeed);
},
leave(target, done){
// Give minimongo time to update documents we might need to animate to
setTimeout(() => this.doLeave(target, done));
},
doLeave(target, done){
async doLeave(target, done){
let elementId;
let hiddenElement = this.hiddenElements.pop();
let returnElementId = this.$store.state.dialogStack.currentReturnElement;
let returnElementId = await this.$store.state.dialogStack.currentReturnElement;
if (returnElementId) {
elementId = returnElementId;
} else {
@@ -183,37 +205,50 @@
let source = this.getTopElementByDataId(elementId);
if (!source){
console.warn(`Can't find source for ${elementId}`);
if (hiddenElement) hiddenElement.style.opacity = null;
if (hiddenElement) hiddenElement.style.opacity = '';
else console.warn('No hidden element to reveal', hiddenElement);
done();
return;
}
let index = target.attributes['data-index'].value;
// Disable clicking the dialog while it's animating
target.style.pointerEvents = 'none';
// Make the dialog mock the source
if (index != 0){
// If we aren't the only dialog, we'll need compensate for offset
mockElement({source, target, offset: {x: OFFSET, y: OFFSET}})
} else {
mockElement({source, target});
}
// If the source and the hidden Element are different
// hide the source and reveal the hidden element
let originalSourceTransition = source.style.transition;
if (hiddenElement !== source){
source.style.transition = 'none';
source.style.opacity = '0';
hiddenElement.style.opacity = null;
if (hiddenElement) hiddenElement.style.opacity = '';
// wait a frame for these to apply without transitions
await new Promise(requestAnimationFrame);
}
setTimeout(() => {
source.style.opacity = null;
source.style.transition = 'none';
target.style.transition = `opacity ${MOCK_DURATION / 4}ms, pointer-events 0s`
requestAnimationFrame(() => {
source.style.transition = originalSourceTransition;
target.style.opacity = '0';
target.style.pointerEvents = 'none';
target.style.setProperty('box-shadow', 'none', 'important');
setTimeout(done, MOCK_DURATION / 4);
});
}, MOCK_DURATION);
// Wait for the mock to finish
await timeout(300 / animationSpeed);
// reveal the source immediately
source.style.opacity = '';
source.style.transition = 'none';
// Wait for the opacity swap to finish
await timeout(100 / animationSpeed);
// Fix the transition of the source
source.style.transition = originalSourceTransition;
// Done
done();
},
noScroll(e){
e.preventDefault();
@@ -224,7 +259,7 @@
<style scoped>
.backdrop {
position: fixed;
position: absolute;
top: 0;
left: 0;
right: 0;
@@ -232,6 +267,7 @@
background-color: rgba(0, 0, 0, 0.4);
z-index: 6;
pointer-events: initial;
opacity: 1;
}
.backdrop-fade-enter-active, .backdrop-fade-leave-active {
transition: opacity 0.3s;
@@ -248,19 +284,8 @@
pointer-events: none;
z-index: 6;
}
.dialog-sizer {
position: relative;
width: 80%;
width: calc(100% - 64px);
max-width: 1000px;
height: 80%;
height: calc(100% - 64px);
max-height: 800px;
z-index: 7;
flex: initial;
}
.dialog-sizer.shake {
.shake {
animation: shake 0.2s;
}
@@ -270,44 +295,69 @@
100% { transform: scale(1); }
}
/* sm */
@media only screen and (max-width: 960px) and (min-width: 601px){
.dialog-sizer {
width: calc(100% - 32px);
height: calc(100% - 32px);
}
}
/* xs */
@media only screen and (max-width: 600px) {
.dialog-sizer {
width: 100%;
height: 100%;
}
}
.dialog-list-enter .dialog-component, .dialog-list-leave-to .dialog-component {
opacity: 0;
}
.dialog-list-enter-active .dialog-component {
transition: opacity 0.3s;
}
.dialog-list-leave-active .dialog-component {
transition: opacity 0.3s 0.1s;
}
.dialog-list-enter-active {
transition: all 0.4s, box-shadow 0.1s;
}
.dialog-list-leave-active {
transition: all 0.4s, box-shadow 0.1s 0.3s, opacity 0.1s, pointer-events 0s;
}
.dialog {
transform-origin: top left;
position: absolute;
.dialog-transition-group {
position: relative;
z-index: 7;
height: 100%;
width: 100%;
}
/*
Fade in and out the dialog contents as it is animating
*/
.dialog-list-enter .sized-dialog, .dialog-list-leave-to .sized-dialog {
opacity: 0;
}
.dialog-list-enter-active .sized-dialog, .dialog-list-leave-active .sized-dialog {
transition: opacity 0.3s;
}
/*
Enter and leave with no shadow
*/
.dialog-list-enter, .dialog-list-leave-to {
box-shadow: none;
}
/*
Leave to no opacity
*/
.dialog-list-leave-to {
opacity: 0;
}
.dialog.dialog-list-enter-active, .unsized-dialog.dialog-list-enter-active {
transition: all 0.3s, box-shadow 0.1s, opacity 0s, pointer-events 0s;
}
.dialog.dialog-list-leave-active, .unsized-dialog.dialog-list-leave-active {
transition: all 0.3s, box-shadow 0.1s 0.3s, opacity 0.1s 0.3s, pointer-events 0s;
}
/**
Only the top dialog should be clickable
*/
.dialog:last-child, .unsized-dialog:last-child {
pointer-events: initial;
}
.dialog {
height: 100%;
width: 100%;
max-height: 800px;
max-width: 1000px;
}
.dialog, .unsized-dialog {
transform-origin: center;
position: absolute;
z-index: 1;
overflow: hidden;
transition: all .3s ease;
transition: all 0.3s ease;
transform: translate(-50%, -50%) scale(1);
}
@media only screen and (min-width: 601px){
.dialog-stack {
padding: 32px;
}
}
.dialog > * {
height: 100%;

View File

@@ -40,13 +40,23 @@ export default function mockElement({ source, target, offset = { x: 0, y: 0 } })
let sourceRect = source.getBoundingClientRect();
let targetRect = target.getBoundingClientRect();
// The dialogs are transformed from their centers, so we need to find the center of each
const sourceCenter = {
x: sourceRect.x + sourceRect.width / 2,
y: sourceRect.y + sourceRect.height / 2
}
const targetCenter = {
x: targetRect.x + targetRect.width / 2,
y: targetRect.y + targetRect.height / 2
}
// Get how must the target change to become the source
const deltaWidth = sourceRect.width / targetRect.width;
const deltaHeight = sourceRect.height / targetRect.height;
const deltaLeft = sourceRect.left - targetRect.left + offset.x;
const deltaTop = sourceRect.top - targetRect.top + offset.y;
const deltaLeft = sourceCenter.x - targetCenter.x + offset.x;
const deltaTop = sourceCenter.y - targetCenter.y + offset.y;
// Mock the source
target.style.transform = `translate(${deltaLeft}px, ${deltaTop}px) ` +
target.style.transform = `translate(calc(-50% + ${deltaLeft}px), calc(-50% + ${deltaTop}px)) ` +
`scale(${deltaWidth}, ${deltaHeight})`;
// Mock the background color unless it's completely transparent
let backgroundColor = getComputedStyle(source).backgroundColor

View File

@@ -1,65 +0,0 @@
<template lang="html">
<dialog-base
v-if="model"
:color="model.color"
>
<template slot="toolbar">
<v-tabs
v-model="tab"
grow
>
<v-tab>User Files</v-tab>
<v-tab>From URL</v-tab>
</v-tabs>
</template>
<div>
<v-tabs-items v-model="tab">
<v-tab-item>
<user-images-list v-model="url" />
</v-tab-item>
<v-tab-item>
<v-card>
<v-card-text>
<v-text-field
v-model="url"
label="URL"
/>
</v-card-text>
</v-card>
</v-tab-item>
</v-tabs-items>
</div>
<v-spacer slot="actions" />
<v-btn
slot="actions"
text
@click="$store.dispatch('popDialogStack')"
>
Done
</v-btn>
</dialog-base>
</template>
<script lang="js">
import DialogBase from '/imports/client/ui/dialogStack/DialogBase.vue';
export default {
components: {
DialogBase,
},
props: {
},
data() {
return {
tab: 0,
file: undefined,
progress: 0,
url: '',
};
},
};
</script>
<style lang="css" scoped>
</style>

View File

@@ -0,0 +1,135 @@
<template lang="html">
<dialog-base>
<template slot="toolbar">
<v-tabs
v-model="tab"
grow
>
<v-tab>User Files</v-tab>
<v-tab>From URL</v-tab>
</v-tabs>
</template>
<v-tabs-items
slot="unwrapped-content"
v-model="tab"
class="fill-height"
>
<v-tab-item
class="fill-height"
style="overflow: auto;"
>
<v-card-text
class="user-image-list d-flex flex-wrap"
>
<image-field
v-for="file in userImages"
:key="file._id"
class="image-field"
:name="file.name"
:href="file.href"
:aspect-ratio="0.2"
/>
<image-field
name="Example image 3"
href="https://picsum.photos/1000/1000"
/>
<image-field
name="Example image 2"
href="https://picsum.photos/500/600"
/>
<image-field
name="Example image 3"
href="https://picsum.photos/850/700"
/>
<v-col
class="mb-3"
v-bind="{cols: 12, sm: 6, md: 4}"
>
<v-btn
outlined
style="height: 100%; width: 100%; min-height: 120px;"
class="archive-button"
:color="fileUploadError ? 'error' : undefined"
:disabled="fileUploadInProgress"
@click="$refs.archiveFileInput.click()"
>
<v-icon left>
mdi-file-upload-outline
</v-icon>
<template v-if="fileUploadError">
{{ fileUploadError }}
</template>
<template v-else>
Upload Image
</template>
<v-progress-linear
v-if="fileUploadInProgress"
:value="fileUploadProgress"
:indeterminate="fileUploadIndeterminate"
/>
</v-btn>
</v-col>
</v-card-text>
</v-tab-item>
<v-tab-item
class="fill-height"
>
<v-card-text class="fill-height d-flex align-center">
<text-field
label="Direct link to image"
:value="href"
/>
</v-card-text>
</v-tab-item>
</v-tabs-items>
<v-spacer slot="actions" />
<v-btn
slot="actions"
text
@click="$store.dispatch('popDialogStack')"
>
Done
</v-btn>
</dialog-base>
</template>
<script lang="js">
import DialogBase from '/imports/client/ui/dialogStack/DialogBase.vue';
import UserImages from '/imports/api/files/userImages/UserImages';
import ImageField from '/imports/client/ui/properties/viewers/shared/ImageField.vue';
export default {
components: {
DialogBase,
ImageField,
},
props: {
currentHref: {
type: String,
default: undefined,
},
},
data() {
return {
tab: 0,
progress: 0,
href: this.currentHref,
};
},
meteor: {
userImages() {
return UserImages.find({});
},
},
};
</script>
<style lang="css" scoped>
.user-image-list {
}
.image-field > fieldset {
}
</style>

View File

@@ -0,0 +1,31 @@
<template lang="html">
<img
class="preview-image v-sheet v-card elevation-6"
:src="href"
@click="back"
>
</template>
<script lang="js">
export default {
props: {
href: {
type: String,
required: true,
},
},
methods: {
back() {
this.$store.dispatch('popDialogStack');
},
}
};
</script>
<style lang="css" scoped>
.preview-image {
max-height: 100%;
max-width: 100%;
cursor: zoom-out;
}
</style>

View File

@@ -64,6 +64,19 @@
</v-btn>
</v-col>
</v-row>
<v-row>
<v-col
sm="12"
md="6"
lg="4"
>
<smart-image-input
label="Image input"
:value="inputImageHref"
@change="(val, ack) => {inputImageHref = val; ack()}"
/>
</v-col>
</v-row>
<!--
<v-row dense>
<v-col cols="12">
@@ -109,6 +122,8 @@ import UserImageCard from '/imports/client/ui/files/UserImageCard.vue';
import { snackbar } from '/imports/client/ui/components/snackbars/SnackbarQueue';
import { archiveSchema } from '/imports/api/creature/archive/ArchiveCreatureFiles';
import migrateArchive from '/imports/migrations/archive/migrateArchive';
import ImageField from '/imports/client/ui/properties/viewers/shared/ImageField.vue';
import SmartImageInput from '/imports/client/ui/components/global/SmartImageInput.vue';
// TODO Mark files that don't have versions.${version}.meta.pipePath set as broken links
@@ -116,8 +131,7 @@ export default {
components: {
ArchiveFileCard,
FileStorageStats,
ImageUploadInput,
UserImageCard,
SmartImageInput,
},
data(){ return {
updateStorageUsedLoading: false,
@@ -126,6 +140,7 @@ export default {
archiveUploadInProgress: false,
archiveUploadProgress: 0,
archiveUploadIndeterminate: true,
inputImageHref: 'https://picsum.photos/2000/500',
}},
meteor: {
$subscribe: {

View File

@@ -0,0 +1,81 @@
<template lang="html">
<v-col
class="mb-3"
v-bind="cols"
>
<fieldset
:class="theme.isDark? 'theme--dark' :'theme--light'"
class="d-flex rounded v-sheet--outlined pt-4 layout column align-center justify-center fill-height"
style="overflow: hidden"
@click="$emit('click', $event)"
>
<legend
v-if="name"
class="text-caption px-1 name"
style="line-height: 0;"
>
{{ name }}
</legend>
<img
:src="href"
class="image"
:data-id="`image-${href}`"
@click="previewImage"
>
</fieldset>
</v-col>
</template>
<script lang="js">
export default {
inject: {
theme: {
default: {
isDark: false,
},
},
},
props: {
name: {
type: String,
default: undefined,
},
href: {
type: String,
default: undefined,
},
aspectRatio: {
type: Number,
default: 1,
},
cols: {
type: Object,
default: () => ({cols: 12, sm: 6, md: 4}),
},
},
methods: {
previewImage() {
this.$store.commit('pushDialogStack', {
component: 'image-preview-dialog',
elementId: `image-${this.href}`,
data: {
href: this.href,
aspectRatio: this.aspectRatio,
},
});
},
}
}
</script>
<style lang="css" scoped>
.image {
cursor: zoom-in;
cursor: -webkit-zoom-in;
cursor: -moz-zoom-in;
max-height: 400px;
max-width: 100%;
}
</style>

View File

@@ -3,6 +3,9 @@
:class="theme.isDark? 'theme--dark' :'theme--light'"
class="outlined-input rounded v-sheet--outlined"
@click="$emit('click', $event)"
@dragover="$emit('dragover', $event)"
@drop="$emit('drop', $event)"
@dragleave="$emit('dragleave', $event)"
>
<legend
v-if="name"

View File

@@ -1,6 +1,6 @@
html {
--scrollbarBG: #f0f0f0;
--thumbBG: #cdcdcd;
--thumbBG: #bbb;
scrollbar-gutter: stable;
background-color: var(--scrollbarBG);
}
@@ -15,23 +15,11 @@ html:has(#app.theme--dark) {
--thumbBG: #404040;
}
html.lock-scroll {
overflow-y: hidden;
}
* {
scrollbar-width: thin;
scrollbar-color: var(--thumbBG) var(--scrollbarBG);
}
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background-color: var(--scrollbarBG);
}
::-webkit-scrollbar-thumb {
background-color: var(--thumbBG);
}
::-webkit-scrollbar-corner {
background-color: rgba(0, 0, 0, 0);
}