11<script setup lang="ts">
2+ import { useDropZone } from " @vueuse/core" ;
23import type { Emitter } from " mitt" ;
34import { inject , ref } from " vue" ;
45import { useI18n } from " vue-i18n" ;
@@ -16,14 +17,16 @@ import type { Events } from "@/types/emitter";
1617import { formatBytes } from " @/utils" ;
1718
1819const { t } = useI18n ();
19- const { xs, mdAndUp, smAndUp } = useDisplay ();
20+ const { mdAndUp, smAndUp } = useDisplay ();
2021const show = ref (false );
2122const filesToUpload = ref <File []>([]);
2223const scanningStore = storeScanning ();
2324const selectedPlatform = ref <Platform | null >(null );
2425const supportedPlatforms = ref <Platform []>();
2526const heartbeat = storeHeartbeat ();
2627const uploadStore = storeUpload ();
28+ const dropZoneRef = ref <HTMLDivElement >();
29+
2730const HEADERS = [
2831 {
2932 title: " Name" ,
@@ -33,6 +36,7 @@ const HEADERS = [
3336 },
3437 { title: " " , align: " end" , key: " actions" , sortable: false },
3538] as const ;
39+
3640const emitter = inject <Emitter <Events >>(" emitter" );
3741emitter ?.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+
150171function 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 >
0 commit comments