Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 108 additions & 86 deletions app/components/DragAndDrop.vue
Original file line number Diff line number Diff line change
@@ -1,116 +1,138 @@
<script setup>
import GlassCard from "@ogw_front/components/GlassCard";
import { onMounted, onUnmounted, ref } from "vue";
import DragAndDropInline from "./DragAndDrop/DragAndDropInline.vue";
import DragAndDropOverlay from "./DragAndDrop/DragAndDropOverlay.vue";

const { multiple, accept, loading, showExtensions } = defineProps({
const {
multiple,
accept,
loading,
showExtensions,
fullscreen,
inline,
showOverlay,
texts = {
idle: "Click or drag and drop",
drop: "Drop files here",
loading: "Loading...",
},
} = defineProps({
multiple: { type: Boolean, default: false },
accept: { type: String, default: "" },
loading: { type: Boolean, default: false },
showExtensions: { type: Boolean, default: true },
fullscreen: { type: Boolean, default: false },
inline: { type: Boolean, default: true },
showOverlay: { type: Boolean, default: true },
texts: { type: Object, default: undefined },
});

const emit = defineEmits(["files-selected"]);

const isDragging = ref(false);
const isInternalDrag = ref(false);
const dragCounter = ref(0);
const fileInput = ref(undefined);

function triggerFileDialog() {
fileInput.value?.click();
}

function handleDrop(event) {
function onDragEnter(event) {
if (!isInternalDrag.value && event.dataTransfer.types.includes("Files")) {
dragCounter.value += 1;
isDragging.value = true;
}
}

function onDragLeave() {
dragCounter.value -= 1;
if (dragCounter.value <= 0) {
isDragging.value = false;
dragCounter.value = 0;
}
}

function onDragOver(event) {
if (!isInternalDrag.value && event.dataTransfer.types.includes("Files")) {
event.preventDefault();
}
}

function onDrop(event) {
event.preventDefault();
dragCounter.value = 0;
isDragging.value = false;
const files = [...event.dataTransfer.files];
emit("files-selected", files);
if (files.length > 0) {
emit("files-selected", files);
}
}

function onKeyDown(event) {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
isDragging.value = false;
dragCounter.value = 0;
}
}

function handleFileSelect(event) {
const files = [...event.target.files];
emit("files-selected", files);
if (files.length > 0) {
emit("files-selected", files);
}
event.target.value = "";
}

function onInternalDragStart() {
isInternalDrag.value = true;
}

function onInternalDragEnd() {
isInternalDrag.value = false;
}

onMounted(() => {
globalThis.addEventListener("dragstart", onInternalDragStart);
globalThis.addEventListener("dragend", onInternalDragEnd);
globalThis.addEventListener("dragenter", onDragEnter);
globalThis.addEventListener("dragover", onDragOver);
globalThis.addEventListener("dragleave", onDragLeave);
globalThis.addEventListener("drop", onDrop);
globalThis.addEventListener("keydown", onKeyDown);
});

onUnmounted(() => {
globalThis.removeEventListener("dragstart", onInternalDragStart);
globalThis.removeEventListener("dragend", onInternalDragEnd);
globalThis.removeEventListener("dragenter", onDragEnter);
globalThis.removeEventListener("dragover", onDragOver);
globalThis.removeEventListener("dragleave", onDragLeave);
globalThis.removeEventListener("drop", onDrop);
globalThis.removeEventListener("keydown", onKeyDown);
});

defineExpose({ triggerFileDialog });
</script>

<template>
<v-hover v-slot="{ isHovering, props: hoverProps }">
<GlassCard
v-bind="hoverProps"
class="text-center cursor-pointer overflow-hidden border-opacity-10 border-white"
:class="{
'elevation-4': isHovering || isDragging,
'elevation-0': !(isHovering || isDragging),
}"
:style="{
position: 'relative',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
background:
isHovering || isDragging
? 'rgba(255, 255, 255, 0.08) !important'
: 'rgba(255, 255, 255, 0.03) !important',
transform: isHovering || isDragging ? 'translateY(-2px)' : 'none',
pointerEvents: loading ? 'none' : 'auto',
opacity: loading ? 0.6 : 1,
}"
variant="panel"
padding="pa-0"
@click="triggerFileDialog"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop"
>
<v-card-text class="pa-8">
<v-sheet
class="mx-auto mb-6 d-flex align-center justify-center"
:color="isHovering || isDragging ? 'white' : 'rgba(255, 255, 255, 0.1)'"
rounded="circle"
width="80"
height="80"
style="transition: all 0.3s ease"
>
<v-icon
:icon="loading ? 'mdi-loading' : 'mdi-cloud-upload'"
size="40"
:color="isHovering || isDragging ? 'primary' : 'white'"
:class="{ rotating: loading }"
/>
</v-sheet>

<v-card-title
class="text-h6 font-weight-bold justify-center pa-0 mb-1 text-white"
style="transition: color 0.3s ease"
>
{{
loading ? "Uploading..." : isDragging ? "Drop to upload" : "Click or Drag & Drop files"
}}
</v-card-title>

<v-card-subtitle v-if="showExtensions" class="text-body-2 pa-0">
{{ accept ? `(${accept} files)` : "All files allowed" }}
</v-card-subtitle>
</v-card-text>

<input
ref="fileInput"
type="file"
class="d-none"
:multiple="multiple"
:accept="accept"
@change="handleFileSelect"
/>
</GlassCard>
</v-hover>
</template>
<DragAndDropInline
v-if="inline"
v-bind="$props"
:is-dragging="isDragging"
@click="triggerFileDialog"
/>

<style scoped>
.rotating {
animation: rotate 1s linear infinite;
}
<DragAndDropOverlay v-bind="$props" :is-dragging="isDragging" />

@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
<input
ref="fileInput"
type="file"
class="d-none"
:multiple="multiple"
:accept="accept"
@change="handleFileSelect"
/>
</template>
86 changes: 86 additions & 0 deletions app/components/DragAndDrop/DragAndDropInline.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<script setup>
import GlassCard from "@ogw_front/components/GlassCard";

const { isDragging, loading, texts, accept, showExtensions } = defineProps({
isDragging: { type: Boolean, required: true },
loading: { type: Boolean, required: true },
texts: {
type: Object,
default: () => ({
idle: "Click or drag and drop",
drop: "Drop files here",
loading: "Loading...",
}),
},
accept: { type: String, default: "" },
showExtensions: { type: Boolean, required: true },
});

const emit = defineEmits(["click"]);
</script>

<template>
<v-hover v-slot="{ isHovering, props: hoverProps }">
<GlassCard
v-bind="hoverProps"
class="drag-card-inline text-center cursor-pointer transition-swing"
:class="{ 'dragging-active': isDragging, 'elevation-8': isHovering }"
variant="ui"
@click="emit('click')"
>
<v-sheet
class="mx-auto mb-4 d-flex align-center justify-center transition-swing"
:color="isHovering || isDragging ? 'primary' : 'rgba(255,255,255,0.05)'"
rounded="circle"
width="64"
height="64"
>
<v-icon
:icon="loading ? 'mdi-loading' : 'mdi-cloud-upload'"
size="32"
:color="isHovering || isDragging ? 'white' : 'primary'"
:class="{ rotating: loading }"
/>
</v-sheet>

<v-card-text class="pa-0">
<v-sheet class="text-h6 font-weight-bold text-white d-block mb-1 bg-transparent">
{{ loading ? texts.loading : isDragging ? texts.drop : texts.idle }}
</v-sheet>
<v-sheet
v-if="accept && showExtensions"
class="text-body-2 text-white opacity-60 bg-transparent"
>
{{ accept }}
</v-sheet>
</v-card-text>
</GlassCard>
</v-hover>
</template>

<style scoped>
.drag-card-inline {
border: 2px dashed rgba(255, 255, 255, 0.1) !important;
background: rgba(255, 255, 255, 0.03) !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}

.drag-card-inline:hover,
.drag-card-inline.dragging-active {
border-color: rgba(var(--v-theme-primary), 0.4) !important;
background: rgba(var(--v-theme-primary), 0.05) !important;
}

.rotating {
animation: rotate 1s linear infinite;
}

@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
Loading
Loading