Skip to content

Commit a83c4c4

Browse files
authored
Merge pull request #2366 from rommapp/upload-dropzone
Use dropzone and add button in upload modal
2 parents afcb1fb + c649660 commit a83c4c4

16 files changed

Lines changed: 320 additions & 138 deletions

File tree

frontend/src/components/common/Game/Dialog/UploadRom.vue

Lines changed: 184 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<script setup lang="ts">
2+
import { useDropZone } from "@vueuse/core";
23
import type { Emitter } from "mitt";
34
import { inject, ref } from "vue";
45
import { useI18n } from "vue-i18n";
@@ -16,14 +17,16 @@ import type { Events } from "@/types/emitter";
1617
import { formatBytes } from "@/utils";
1718
1819
const { t } = useI18n();
19-
const { xs, mdAndUp, smAndUp } = useDisplay();
20+
const { mdAndUp, smAndUp } = useDisplay();
2021
const show = ref(false);
2122
const filesToUpload = ref<File[]>([]);
2223
const scanningStore = storeScanning();
2324
const selectedPlatform = ref<Platform | null>(null);
2425
const supportedPlatforms = ref<Platform[]>();
2526
const heartbeat = storeHeartbeat();
2627
const uploadStore = storeUpload();
28+
const dropZoneRef = ref<HTMLDivElement>();
29+
2730
const HEADERS = [
2831
{
2932
title: "Name",
@@ -33,6 +36,7 @@ const HEADERS = [
3336
},
3437
{ title: "", align: "end", key: "actions", sortable: false },
3538
] as const;
39+
3640
const emitter = inject<Emitter<Events>>("emitter");
3741
emitter?.on("showUploadRomDialog", (platformWhereUpload) => {
3842
if (platformWhereUpload) {
@@ -147,6 +151,23 @@ function triggerFileInput() {
147151
fileInput?.click();
148152
}
149153
154+
function handleFileInputChange(event: Event) {
155+
const target = event.target as HTMLInputElement;
156+
if (target.files && target.files.length > 0) {
157+
const newFiles = Array.from(target.files);
158+
// Add new files to existing list, avoiding duplicates
159+
const uniqueNewFiles = newFiles.filter(
160+
(newFile) =>
161+
!filesToUpload.value.some(
162+
(existingFile) => existingFile.name === newFile.name,
163+
),
164+
);
165+
filesToUpload.value = [...filesToUpload.value, ...uniqueNewFiles];
166+
}
167+
// Clear the input so the same files can be selected again if needed
168+
target.value = "";
169+
}
170+
150171
function removeRomFromList(romName: string) {
151172
filesToUpload.value = filesToUpload.value.filter(
152173
(rom) => rom.name !== romName,
@@ -158,19 +179,38 @@ function closeDialog() {
158179
filesToUpload.value = [];
159180
selectedPlatform.value = null;
160181
}
182+
183+
function onDrop(files: File[] | null) {
184+
if (files && files.length > 0) {
185+
// Add new files to existing list, avoiding duplicates
186+
const newFiles = files.filter(
187+
(newFile) =>
188+
!filesToUpload.value.some(
189+
(existingFile) => existingFile.name === newFile.name,
190+
),
191+
);
192+
filesToUpload.value = [...filesToUpload.value, ...newFiles];
193+
}
194+
}
195+
196+
const { isOverDropZone } = useDropZone(dropZoneRef, {
197+
onDrop,
198+
multiple: true,
199+
preventDefaultForUnhandled: true,
200+
});
161201
</script>
162202

163203
<template>
164204
<r-dialog
165205
@close="closeDialog"
166206
v-model="show"
167207
icon="mdi-cloud-upload-outline"
168-
:width="mdAndUp ? '50vw' : '95vw'"
208+
:width="mdAndUp ? '40vw' : '95vw'"
169209
scroll-content
170210
>
171211
<template #toolbar>
172212
<v-row class="align-center" no-gutters>
173-
<v-col cols="10" sm="8" lg="9">
213+
<v-col>
174214
<v-autocomplete
175215
v-model="selectedPlatform"
176216
:label="t('common.platform')"
@@ -213,66 +253,116 @@ function closeDialog() {
213253
</template>
214254
</v-autocomplete>
215255
</v-col>
216-
<v-col>
217-
<v-btn
218-
block
219-
icon=""
220-
class="text-primary bg-toplayer"
221-
variant="text"
222-
rounded="0"
223-
@click="triggerFileInput"
224-
>
225-
<v-icon :class="{ 'mr-2': !xs }"> mdi-plus </v-icon
226-
><span v-if="!xs">{{ t("common.add") }}</span>
227-
</v-btn>
228-
<v-file-input
229-
id="file-input"
230-
v-model="filesToUpload"
231-
class="file-input"
232-
multiple
233-
required
234-
/>
235-
</v-col>
236256
</v-row>
237257
</template>
238258
<template #content>
239-
<v-data-table-virtual
240-
v-if="filesToUpload.length > 0"
241-
:item-value="(item) => item.name"
242-
:items="filesToUpload"
243-
:width="mdAndUp ? '60vw' : '95vw'"
244-
:headers="HEADERS"
245-
hide-default-header
259+
<!-- Dropzone Area -->
260+
<div
261+
ref="dropZoneRef"
262+
class="dropzone-container min-h-[200px] rounded-lg transition-all duration-300 ease-in-out ma-3"
263+
:class="{
264+
'dropzone-active': isOverDropZone,
265+
'dropzone-has-files': filesToUpload.length > 0,
266+
}"
246267
>
247-
<template #item.name="{ item }">
248-
<v-list-item class="px-0">
249-
<v-row no-gutters>
250-
<v-col>
251-
{{ item.name }}
252-
</v-col>
253-
</v-row>
254-
<v-row no-gutters v-if="!smAndUp">
255-
<v-col>
256-
<v-chip size="x-small" label>{{
257-
formatBytes(item.size)
258-
}}</v-chip>
259-
</v-col>
260-
</v-row>
261-
<template #append>
262-
<v-chip v-if="smAndUp" class="ml-2" size="x-small" label>{{
263-
formatBytes(item.size)
264-
}}</v-chip>
265-
</template>
266-
</v-list-item>
267-
</template>
268-
<template #item.actions="{ item }">
269-
<v-btn-group divided density="compact">
270-
<v-btn @click="removeRomFromList(item.name)">
271-
<v-icon class="text-romm-red"> mdi-close </v-icon>
268+
<!-- Dropzone Visual Feedback -->
269+
<div
270+
v-if="filesToUpload.length === 0"
271+
class="flex flex-col items-center justify-center h-full min-h-[250px] p-8 text-center transition-all duration-300 ease-in-out"
272+
>
273+
<v-icon
274+
:class="{ 'animate-pulse-glow': isOverDropZone }"
275+
size="48"
276+
color="primary"
277+
class="transition-all duration-300 ease-in-out"
278+
>
279+
{{
280+
isOverDropZone ? "mdi-cloud-upload" : "mdi-cloud-upload-outline"
281+
}}
282+
</v-icon>
283+
<h3 class="text-h6 mt-4 mb-2">
284+
{{
285+
isOverDropZone
286+
? t("common.dropzone-drag-over")
287+
: t("common.dropzone-title")
288+
}}
289+
</h3>
290+
<p class="text-body-2 text-medium-emphasis mb-4">
291+
{{ t("common.dropzone-description") }}
292+
</p>
293+
<v-btn color="primary" variant="outlined" @click="triggerFileInput">
294+
<v-icon start>mdi-plus</v-icon>
295+
{{ t("common.add") }}
296+
</v-btn>
297+
</div>
298+
299+
<!-- Files List -->
300+
<div v-if="filesToUpload.length > 0" class="p-4">
301+
<div class="d-flex align-center justify-space-between mb-3">
302+
<h4 class="text-h6">
303+
{{
304+
t("common.upload-files-selected", {
305+
count: filesToUpload.length,
306+
})
307+
}}
308+
</h4>
309+
<v-btn
310+
color="primary"
311+
variant="outlined"
312+
size="small"
313+
@click="triggerFileInput"
314+
>
315+
<v-icon start>mdi-plus</v-icon>
316+
{{ t("common.add") }}
272317
</v-btn>
273-
</v-btn-group>
274-
</template>
275-
</v-data-table-virtual>
318+
</div>
319+
320+
<v-data-table-virtual
321+
:item-value="(item) => item.name"
322+
:items="filesToUpload"
323+
:headers="HEADERS"
324+
hide-default-header
325+
class="elevation-1"
326+
>
327+
<template #item.name="{ item }">
328+
<v-list-item class="pa-0">
329+
<v-row no-gutters>
330+
<v-col>
331+
{{ item.name }}
332+
</v-col>
333+
</v-row>
334+
<v-row no-gutters v-if="!smAndUp">
335+
<v-col>
336+
<v-chip size="x-small" label>{{
337+
formatBytes(item.size)
338+
}}</v-chip>
339+
</v-col>
340+
</v-row>
341+
<template #append>
342+
<v-chip v-if="smAndUp" class="ml-2" size="x-small" label>{{
343+
formatBytes(item.size)
344+
}}</v-chip>
345+
</template>
346+
</v-list-item>
347+
</template>
348+
<template #item.actions="{ item }">
349+
<v-btn @click="removeRomFromList(item.name)">
350+
<v-icon class="text-romm-red"> mdi-close </v-icon>
351+
</v-btn>
352+
</template>
353+
</v-data-table-virtual>
354+
</div>
355+
356+
<!-- Hidden file input -->
357+
<input
358+
id="file-input"
359+
type="file"
360+
multiple
361+
class="opacity-0 pointer-events-none"
362+
style="display: none"
363+
@change="handleFileInputChange"
364+
/>
365+
</div>
276366
</template>
277367
<template #append>
278368
<v-divider />
@@ -298,3 +388,39 @@ function closeDialog() {
298388
</template>
299389
</r-dialog>
300390
</template>
391+
392+
<style scoped>
393+
.dropzone-container {
394+
border: 2px dashed rgba(var(--v-theme-primary), 0.3);
395+
}
396+
397+
.dropzone-container.dropzone-active {
398+
border: 2px dashed rgba(var(--v-theme-primary));
399+
background-color: rgba(var(--v-theme-primary), 0.05);
400+
}
401+
402+
.dropzone-container.dropzone-has-files {
403+
border: none;
404+
background-color: rgba(var(--v-theme-surface), 0.5);
405+
}
406+
407+
.animate-pulse-glow {
408+
animation: pulse-glow 1.5s ease-in-out infinite;
409+
}
410+
411+
@keyframes pulse-glow {
412+
0% {
413+
transform: scale(1);
414+
filter: brightness(1) drop-shadow(0 0 0 rgba(var(--v-theme-primary), 0));
415+
}
416+
50% {
417+
transform: scale(1.1);
418+
filter: brightness(1.2)
419+
drop-shadow(0 0 20px rgba(var(--v-theme-primary), 0.6));
420+
}
421+
100% {
422+
transform: scale(1);
423+
filter: brightness(1) drop-shadow(0 0 0 rgba(var(--v-theme-primary), 0));
424+
}
425+
}
426+
</style>

frontend/src/components/common/RDialog.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ onMounted(() => {
8787
</v-toolbar>
8888
<v-divider />
8989

90-
<v-card-text v-if="hasPrependSlot" class="pa-1">
90+
<v-card-text v-if="hasPrependSlot" class="pa-0">
9191
<slot name="prepend"></slot>
9292
</v-card-text>
9393

@@ -125,7 +125,7 @@ onMounted(() => {
125125
name="content"
126126
></slot>
127127
</v-card-text>
128-
<v-card-text v-if="hasAppendSlot" class="pa-1">
128+
<v-card-text v-if="hasAppendSlot" class="pa-0">
129129
<slot name="append"></slot>
130130
</v-card-text>
131131

frontend/src/locales/de_DE/common.json

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,16 @@
1010
"confirm": "Bestätigen",
1111
"core": "Core",
1212
"create": "Erstellen",
13+
"dropzone-description": "Ziehen Sie Ihre ROM-Dateien hierher oder klicken Sie zum Durchsuchen",
14+
"dropzone-drag-over": "Loslassen zum Hochladen",
15+
"dropzone-title": "Dateien hier ablegen",
1316
"edit": "Bearbeiten",
1417
"exclude-on-delete": "Beim Löschen ausschließen",
1518
"filter": "Filter",
1619
"firmware": "Firmware",
1720
"games-n": "{n} Spiel | {n} Spiele",
21+
"invalid-email": "Ungültige E-Mail",
22+
"invalid-name": "Ungültiger Name",
1823
"library-management": "Bibliothek verwalten",
1924
"logout": "Ausloggen",
2025
"name": "Name",
@@ -25,8 +30,6 @@
2530
"profile": "Profil",
2631
"random": "Zufällig",
2732
"required": "Erforderlich",
28-
"invalid-name": "Ungültiger Name",
29-
"invalid-email": "Ungültige E-Mail",
3033
"save": "Speichern",
3134
"saves": "Speicherstände",
3235
"saves-n": "{n} Speichern | {n} Speicherstände",
@@ -35,15 +38,16 @@
3538
"server-stats": "Serverstatistiken",
3639
"size-on-disk": "Größe auf Festplatte",
3740
"slug": "Slug",
41+
"smart-collection": "Intelligente Sammlung",
42+
"smart-collections": "Intelligente Sammlungen",
3843
"state": "Spielstand",
3944
"states": "Spielstände",
4045
"states-n": "{n} Spielstand | {n} Spielstände",
46+
"sync": "Synchronisieren",
4147
"update": "Update",
4248
"upload": "Hochladen",
49+
"upload-files-selected": "{count} Datei ausgewählt | {count} Dateien ausgewählt",
4350
"user-interface": "Benutzeroberfläche",
4451
"virtual-collection": "Autogenerierte Sammlung",
45-
"virtual-collections": "Autogenerierte Sammlungen",
46-
"smart-collection": "Intelligente Sammlung",
47-
"smart-collections": "Intelligente Sammlungen",
48-
"sync": "Synchronisieren"
52+
"virtual-collections": "Autogenerierte Sammlungen"
4953
}

0 commit comments

Comments
 (0)