feat: implement global drop zone for file uploads across the application

- Introduced a new GlobalDropZone component to handle file drag-and-drop uploads, enhancing user experience.
- Updated dashboard and files pages to utilize the GlobalDropZone, allowing users to easily upload files by dragging them into designated areas.
- Added support for pasting images directly into the application, with success notifications for completed uploads.
- Enhanced localization by adding relevant messages for various languages in the translation files.
This commit is contained in:
Daniel Luiz Alves
2025-07-03 11:06:34 -03:00
parent 961d7b4f45
commit 5e82e8c709
20 changed files with 574 additions and 101 deletions

View File

@@ -1442,7 +1442,12 @@
"warning": "إذا أغلقت الآن، سيتم إلغاء الرفع وسيفقد أي تقدم.", "warning": "إذا أغلقت الآن، سيتم إلغاء الرفع وسيفقد أي تقدم.",
"continue": "مواصلة الرفع", "continue": "مواصلة الرفع",
"cancel": "إلغاء الرفع" "cancel": "إلغاء الرفع"
} },
"globalDrop": {
"title": "إفلات الملفات للرفع",
"description": "حرر للرفع ملفاتك"
},
"pasteSuccess": "{count, plural, =1 {تم لصق الصورة ورفعها بنجاح} other {تم لصق # صور ورفعها بنجاح}}"
}, },
"users": { "users": {
"modes": { "modes": {
@@ -1528,4 +1533,4 @@
"nameRequired": "الاسم مطلوب", "nameRequired": "الاسم مطلوب",
"required": "هذا الحقل مطلوب" "required": "هذا الحقل مطلوب"
} }
} }

View File

@@ -1440,7 +1440,12 @@
"warning": "Wenn Sie jetzt schließen, werden die Uploads abgebrochen und jeder Fortschritt geht verloren.", "warning": "Wenn Sie jetzt schließen, werden die Uploads abgebrochen und jeder Fortschritt geht verloren.",
"continue": "Uploads Fortsetzen", "continue": "Uploads Fortsetzen",
"cancel": "Uploads Abbrechen" "cancel": "Uploads Abbrechen"
} },
"globalDrop": {
"title": "Dateien zum Hochladen ablegen",
"description": "Loslassen, um Ihre Dateien hochzuladen"
},
"pasteSuccess": "{count, plural, =1 {Bild erfolgreich eingefügt und hochgeladen} other {# Bilder erfolgreich eingefügt und hochgeladen}}"
}, },
"users": { "users": {
"modes": { "modes": {
@@ -1526,4 +1531,4 @@
"nameRequired": "Name ist erforderlich", "nameRequired": "Name ist erforderlich",
"required": "Dieses Feld ist erforderlich" "required": "Dieses Feld ist erforderlich"
} }
} }

View File

@@ -1433,6 +1433,10 @@
"fileSizeExceeded": "File size exceeds the limit of {maxsizemb}MB.", "fileSizeExceeded": "File size exceeds the limit of {maxsizemb}MB.",
"insufficientStorage": "Insufficient storage space. You have {availablespace}MB available.", "insufficientStorage": "Insufficient storage space. You have {availablespace}MB available.",
"unauthorized": "Unauthorized: a valid token is required to access this resource.", "unauthorized": "Unauthorized: a valid token is required to access this resource.",
"globalDrop": {
"title": "Drop files to upload",
"description": "Release to upload your files"
},
"confirmCancel": { "confirmCancel": {
"title": "Cancel Uploads", "title": "Cancel Uploads",
"messageSingle": "There is one upload in progress.", "messageSingle": "There is one upload in progress.",
@@ -1440,7 +1444,8 @@
"warning": "If you close now, uploads will be cancelled and any progress will be lost.", "warning": "If you close now, uploads will be cancelled and any progress will be lost.",
"continue": "Continue Uploads", "continue": "Continue Uploads",
"cancel": "Cancel Uploads" "cancel": "Cancel Uploads"
} },
"pasteSuccess": "{count, plural, =1 {Image pasted and uploaded successfully} other {# images pasted and uploaded successfully}}"
}, },
"users": { "users": {
"modes": { "modes": {
@@ -1526,4 +1531,4 @@
"nameRequired": "Name is required", "nameRequired": "Name is required",
"required": "This field is required" "required": "This field is required"
} }
} }

View File

@@ -1440,7 +1440,12 @@
"warning": "Si cierra ahora, las subidas serán canceladas y se perderá cualquier progreso.", "warning": "Si cierra ahora, las subidas serán canceladas y se perderá cualquier progreso.",
"continue": "Continuar Subidas", "continue": "Continuar Subidas",
"cancel": "Cancelar Subidas" "cancel": "Cancelar Subidas"
} },
"globalDrop": {
"title": "Suelta archivos para subir",
"description": "Suelta para subir tus archivos"
},
"pasteSuccess": "{count, plural, =1 {Imagen pegada y subida exitosamente} other {# imágenes pegadas y subidas exitosamente}}"
}, },
"users": { "users": {
"modes": { "modes": {
@@ -1526,4 +1531,4 @@
"nameRequired": "El nombre es obligatorio", "nameRequired": "El nombre es obligatorio",
"required": "Este campo es obligatorio" "required": "Este campo es obligatorio"
} }
} }

View File

@@ -1440,7 +1440,12 @@
"warning": "Si vous fermez maintenant, les téléchargements seront annulés et tout progrès sera perdu.", "warning": "Si vous fermez maintenant, les téléchargements seront annulés et tout progrès sera perdu.",
"continue": "Continuer les Téléchargements", "continue": "Continuer les Téléchargements",
"cancel": "Annuler les Téléchargements" "cancel": "Annuler les Téléchargements"
} },
"globalDrop": {
"title": "Déposer des fichiers pour télécharger",
"description": "Relâchez pour télécharger vos fichiers"
},
"pasteSuccess": "{count, plural, =1 {Image collée et téléchargée avec succès} other {# images collées et téléchargées avec succès}}"
}, },
"users": { "users": {
"modes": { "modes": {
@@ -1526,4 +1531,4 @@
"nameRequired": "Nome é obrigatório", "nameRequired": "Nome é obrigatório",
"required": "Este campo é obrigatório" "required": "Este campo é obrigatório"
} }
} }

View File

@@ -1440,7 +1440,12 @@
"warning": "यदि आप अभी बंद करते हैं, तो अपलोड रद्द हो जाएंगे और कोई भी प्रगति खो जाएगी।", "warning": "यदि आप अभी बंद करते हैं, तो अपलोड रद्द हो जाएंगे और कोई भी प्रगति खो जाएगी।",
"continue": "अपलोड जारी रखें", "continue": "अपलोड जारी रखें",
"cancel": "अपलोड रद्द करें" "cancel": "अपलोड रद्द करें"
} },
"globalDrop": {
"title": "अपलोड करने के लिए फ़ाइलें छोड़ें",
"description": "अपनी फ़ाइलें अपलोड करने के लिए छोड़ें"
},
"pasteSuccess": "{count, plural, =1 {छवि सफलतापूर्वक चिपकाई और अपलोड की गई} other {# छवियाँ सफलतापूर्वक चिपकाई और अपलोड की गईं}}"
}, },
"users": { "users": {
"modes": { "modes": {
@@ -1526,4 +1531,4 @@
"nameRequired": "नाम आवश्यक है", "nameRequired": "नाम आवश्यक है",
"required": "यह फ़ील्ड आवश्यक है" "required": "यह फ़ील्ड आवश्यक है"
} }
} }

View File

@@ -1440,7 +1440,12 @@
"warning": "Se chiudi ora, i caricamenti saranno annullati e qualsiasi progresso sarà perso.", "warning": "Se chiudi ora, i caricamenti saranno annullati e qualsiasi progresso sarà perso.",
"continue": "Continua Caricamenti", "continue": "Continua Caricamenti",
"cancel": "Annulla Caricamenti" "cancel": "Annulla Caricamenti"
} },
"globalDrop": {
"title": "Rilascia i file per caricarli",
"description": "Rilascia per caricare i tuoi file"
},
"pasteSuccess": "{count, plural, =1 {Immagine incollata e caricata con successo} other {# immagini incollate e caricate con successo}}"
}, },
"users": { "users": {
"modes": { "modes": {
@@ -1526,4 +1531,4 @@
"nameRequired": "Il nome è obbligatorio", "nameRequired": "Il nome è obbligatorio",
"required": "Questo campo è obbligatorio" "required": "Questo campo è obbligatorio"
} }
} }

View File

@@ -1440,7 +1440,12 @@
"retry": "再試行", "retry": "再試行",
"allSuccess": "{count, plural, =1 {ファイルがアップロードされました} other {#個のファイルがアップロードされました}}", "allSuccess": "{count, plural, =1 {ファイルがアップロードされました} other {#個のファイルがアップロードされました}}",
"partialSuccess": "{success}個のファイルがアップロードされ、{error}個が失敗しました", "partialSuccess": "{success}個のファイルがアップロードされ、{error}個が失敗しました",
"dragAndDrop": "またはここにファイルをドラッグ&ドロップ" "dragAndDrop": "またはここにファイルをドラッグ&ドロップ",
"globalDrop": {
"title": "アップロードするファイルをドロップ",
"description": "ファイルをアップロードするにはリリースしてください"
},
"pasteSuccess": "{count, plural, =1 {画像が貼り付けられ、正常にアップロードされました} other {#個の画像が貼り付けられ、正常にアップロードされました}}"
}, },
"users": { "users": {
"modes": { "modes": {
@@ -1526,4 +1531,4 @@
"nameRequired": "名前は必須です", "nameRequired": "名前は必須です",
"required": "このフィールドは必須です" "required": "このフィールドは必須です"
} }
} }

View File

@@ -1440,7 +1440,12 @@
"warning": "지금 닫으면 업로드가 취소되고 모든 진행 상황이 손실됩니다.", "warning": "지금 닫으면 업로드가 취소되고 모든 진행 상황이 손실됩니다.",
"continue": "업로드 계속", "continue": "업로드 계속",
"cancel": "업로드 취소" "cancel": "업로드 취소"
} },
"globalDrop": {
"title": "업로드할 파일 드롭",
"description": "파일을 업로드하려면 놓으세요"
},
"pasteSuccess": "{count, plural, =1 {이미지가 성공적으로 붙여넣기 및 업로드되었습니다} other {# 개의 이미지가 성공적으로 붙여넣기 및 업로드되었습니다}}"
}, },
"users": { "users": {
"modes": { "modes": {
@@ -1526,4 +1531,4 @@
"nameRequired": "이름은 필수입니다", "nameRequired": "이름은 필수입니다",
"required": "이 필드는 필수입니다" "required": "이 필드는 필수입니다"
} }
} }

View File

@@ -1440,7 +1440,12 @@
"warning": "Als u nu sluit, worden de uploads geannuleerd en gaat alle voortgang verloren.", "warning": "Als u nu sluit, worden de uploads geannuleerd en gaat alle voortgang verloren.",
"continue": "Uploads Voortzetten", "continue": "Uploads Voortzetten",
"cancel": "Uploads Annuleren" "cancel": "Uploads Annuleren"
} },
"globalDrop": {
"title": "Sleep bestanden om te uploaden",
"description": "Laat los om je bestanden te uploaden"
},
"pasteSuccess": "{count, plural, =1 {Afbeelding geplakt en succesvol geüpload} other {# afbeeldingen geplakt en succesvol geüpload}}"
}, },
"users": { "users": {
"modes": { "modes": {
@@ -1526,4 +1531,4 @@
"nameRequired": "Naam is verplicht", "nameRequired": "Naam is verplicht",
"required": "Dit veld is verplicht" "required": "Dit veld is verplicht"
} }
} }

View File

@@ -1440,7 +1440,12 @@
"warning": "Jeśli teraz zamkniesz, przesyłanie zostanie anulowane, a wszelki postęp zostanie utracony.", "warning": "Jeśli teraz zamkniesz, przesyłanie zostanie anulowane, a wszelki postęp zostanie utracony.",
"continue": "Kontynuuj przesyłanie", "continue": "Kontynuuj przesyłanie",
"cancel": "Anuluj przesyłanie" "cancel": "Anuluj przesyłanie"
} },
"globalDrop": {
"title": "Upuść pliki, aby przesłać",
"description": "Zwolnij, aby przesłać pliki"
},
"pasteSuccess": "{count, plural, =1 {Obraz wklejony i przesłany pomyślnie} other {# obrazy wklejone i przesłane pomyślnie}}"
}, },
"users": { "users": {
"modes": { "modes": {
@@ -1526,4 +1531,4 @@
"nameRequired": "Nazwa jest wymagana", "nameRequired": "Nazwa jest wymagana",
"required": "To pole jest wymagane" "required": "To pole jest wymagane"
} }
} }

View File

@@ -1433,6 +1433,10 @@
"fileSizeExceeded": "O tamanho do arquivo excede o limite de {maxsizemb}MB.", "fileSizeExceeded": "O tamanho do arquivo excede o limite de {maxsizemb}MB.",
"insufficientStorage": "Espaço de armazenamento insuficiente. Você tem {availablespace}MB disponíveis.", "insufficientStorage": "Espaço de armazenamento insuficiente. Você tem {availablespace}MB disponíveis.",
"unauthorized": "Não autorizado: um token válido é necessário para acessar este recurso.", "unauthorized": "Não autorizado: um token válido é necessário para acessar este recurso.",
"globalDrop": {
"title": "Solte arquivos para enviar",
"description": "Solte para enviar seus arquivos"
},
"confirmCancel": { "confirmCancel": {
"title": "Cancelar Uploads", "title": "Cancelar Uploads",
"messageSingle": "Há um upload em andamento.", "messageSingle": "Há um upload em andamento.",
@@ -1440,7 +1444,8 @@
"warning": "Se você fechar agora, os uploads serão cancelados e qualquer progresso será perdido.", "warning": "Se você fechar agora, os uploads serão cancelados e qualquer progresso será perdido.",
"continue": "Continuar Uploads", "continue": "Continuar Uploads",
"cancel": "Cancelar Uploads" "cancel": "Cancelar Uploads"
} },
"pasteSuccess": "{count, plural, =1 {Imagem colada e enviada com sucesso} other {# imagens coladas e enviadas com sucesso}}"
}, },
"users": { "users": {
"modes": { "modes": {

View File

@@ -1440,7 +1440,12 @@
"warning": "Если вы закроете сейчас, загрузки будут отменены и весь прогресс будет потерян.", "warning": "Если вы закроете сейчас, загрузки будут отменены и весь прогресс будет потерян.",
"continue": "Продолжить Загрузку", "continue": "Продолжить Загрузку",
"cancel": "Отменить Загрузку" "cancel": "Отменить Загрузку"
} },
"globalDrop": {
"title": "Перетащите файлы для загрузки",
"description": "Отпустите, чтобы загрузить файлы"
},
"pasteSuccess": "{count, plural, =1 {Изображение вставлено и успешно загружено} other {# изображений вставлено и успешно загружено}}"
}, },
"users": { "users": {
"modes": { "modes": {
@@ -1526,4 +1531,4 @@
"nameRequired": "Требуется имя", "nameRequired": "Требуется имя",
"required": "Это поле обязательно" "required": "Это поле обязательно"
} }
} }

View File

@@ -1440,7 +1440,12 @@
"warning": "Şimdi kapatırsanız, yüklemeler iptal olacak ve tüm ilerleme kaybolacak.", "warning": "Şimdi kapatırsanız, yüklemeler iptal olacak ve tüm ilerleme kaybolacak.",
"continue": "Yüklemeleri Sürdür", "continue": "Yüklemeleri Sürdür",
"cancel": "Yüklemeleri İptal Et" "cancel": "Yüklemeleri İptal Et"
} },
"globalDrop": {
"title": "Yüklemek için dosyaları bırakın",
"description": "Dosyalarınızı yüklemek için bırakın"
},
"pasteSuccess": "{count, plural, =1 {Görüntü yapıştırıldı ve başarıyla yüklendi} other {# görüntü yapıştırıldı ve başarıyla yüklendi}}"
}, },
"users": { "users": {
"modes": { "modes": {
@@ -1526,4 +1531,4 @@
"nameRequired": "İsim gereklidir", "nameRequired": "İsim gereklidir",
"required": "Bu alan zorunludur" "required": "Bu alan zorunludur"
} }
} }

View File

@@ -1440,7 +1440,12 @@
"warning": "如果您现在关闭,上传将被取消,任何进度都将丢失。", "warning": "如果您现在关闭,上传将被取消,任何进度都将丢失。",
"continue": "继续上传", "continue": "继续上传",
"cancel": "取消上传" "cancel": "取消上传"
} },
"globalDrop": {
"title": "拖放文件以上传",
"description": "松开以上传您的文件"
},
"pasteSuccess": "{count, plural, =1 {图片已成功粘贴并上传} other {# 张图片已成功粘贴并上传}}"
}, },
"users": { "users": {
"modes": { "modes": {
@@ -1526,4 +1531,4 @@
"nameRequired": "名称为必填项", "nameRequired": "名称为必填项",
"required": "此字段为必填项" "required": "此字段为必填项"
} }
} }

View File

@@ -4,6 +4,7 @@ import { IconLayoutDashboardFilled } from "@tabler/icons-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { ProtectedRoute } from "@/components/auth/protected-route"; import { ProtectedRoute } from "@/components/auth/protected-route";
import { GlobalDropZone } from "@/components/general/global-drop-zone";
import { FileManagerLayout } from "@/components/layout/file-manager-layout"; import { FileManagerLayout } from "@/components/layout/file-manager-layout";
import { LoadingScreen } from "@/components/layout/loading-screen"; import { LoadingScreen } from "@/components/layout/loading-screen";
import { QuickAccessCards } from "./components/quick-access-cards"; import { QuickAccessCards } from "./components/quick-access-cards";
@@ -39,39 +40,41 @@ export default function DashboardPage() {
return ( return (
<ProtectedRoute> <ProtectedRoute>
<FileManagerLayout <GlobalDropZone onSuccess={loadDashboardData}>
breadcrumbLabel={t("dashboard.breadcrumb")} <FileManagerLayout
icon={<IconLayoutDashboardFilled className="text-xl" />} breadcrumbLabel={t("dashboard.breadcrumb")}
showBreadcrumb={false} icon={<IconLayoutDashboardFilled className="text-xl" />}
title={t("dashboard.pageTitle")} showBreadcrumb={false}
> title={t("dashboard.pageTitle")}
<StorageUsage diskSpace={diskSpace} diskSpaceError={diskSpaceError} onRetry={handleRetryDiskSpace} /> >
<QuickAccessCards /> <StorageUsage diskSpace={diskSpace} diskSpaceError={diskSpaceError} onRetry={handleRetryDiskSpace} />
<QuickAccessCards />
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<RecentFiles <RecentFiles
fileManager={fileManager}
files={recentFiles}
isUploadModalOpen={modals.isUploadModalOpen}
onOpenUploadModal={modals.onOpenUploadModal}
/>
<RecentShares
isCreateModalOpen={modals.isCreateModalOpen}
shareManager={shareManager}
shares={recentShares}
onCopyLink={handleCopyLink}
onOpenCreateModal={modals.onOpenCreateModal}
/>
</div>
<DashboardModals
fileManager={fileManager} fileManager={fileManager}
files={recentFiles} modals={modals}
isUploadModalOpen={modals.isUploadModalOpen}
onOpenUploadModal={modals.onOpenUploadModal}
/>
<RecentShares
isCreateModalOpen={modals.isCreateModalOpen}
shareManager={shareManager} shareManager={shareManager}
shares={recentShares} onSuccess={loadDashboardData}
onCopyLink={handleCopyLink}
onOpenCreateModal={modals.onOpenCreateModal}
/> />
</div> </FileManagerLayout>
</GlobalDropZone>
<DashboardModals
fileManager={fileManager}
modals={modals}
shareManager={shareManager}
onSuccess={loadDashboardData}
/>
</FileManagerLayout>
</ProtectedRoute> </ProtectedRoute>
); );
} }

View File

@@ -4,6 +4,7 @@ import { IconFolderOpen } from "@tabler/icons-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { ProtectedRoute } from "@/components/auth/protected-route"; import { ProtectedRoute } from "@/components/auth/protected-route";
import { GlobalDropZone } from "@/components/general/global-drop-zone";
import { FileManagerLayout } from "@/components/layout/file-manager-layout"; import { FileManagerLayout } from "@/components/layout/file-manager-layout";
import { LoadingScreen } from "@/components/layout/loading-screen"; import { LoadingScreen } from "@/components/layout/loading-screen";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
@@ -23,47 +24,49 @@ export default function FilesPage() {
return ( return (
<ProtectedRoute> <ProtectedRoute>
<FileManagerLayout <GlobalDropZone onSuccess={loadFiles}>
breadcrumbLabel={t("files.breadcrumb")} <FileManagerLayout
icon={<IconFolderOpen size={20} />} breadcrumbLabel={t("files.breadcrumb")}
title={t("files.pageTitle")} icon={<IconFolderOpen size={20} />}
> title={t("files.pageTitle")}
<Card> >
<CardContent> <Card>
<div className="flex flex-col gap-6"> <CardContent>
<Header onUpload={modals.onOpenUploadModal} /> <div className="flex flex-col gap-6">
<FilesViewManager <Header onUpload={modals.onOpenUploadModal} />
files={filteredFiles} <FilesViewManager
searchQuery={searchQuery} files={filteredFiles}
onSearch={handleSearch} searchQuery={searchQuery}
onDelete={fileManager.setFileToDelete} onSearch={handleSearch}
onDownload={fileManager.handleDownload} onDelete={fileManager.setFileToDelete}
onPreview={fileManager.setPreviewFile} onDownload={fileManager.handleDownload}
onRename={fileManager.setFileToRename} onPreview={fileManager.setPreviewFile}
onShare={fileManager.setFileToShare} onRename={fileManager.setFileToRename}
onBulkDelete={fileManager.handleBulkDelete} onShare={fileManager.setFileToShare}
onBulkShare={fileManager.handleBulkShare} onBulkDelete={fileManager.handleBulkDelete}
onBulkDownload={fileManager.handleBulkDownload} onBulkShare={fileManager.handleBulkShare}
setClearSelectionCallback={fileManager.setClearSelectionCallback} onBulkDownload={fileManager.handleBulkDownload}
onUpdateName={(fileId, newName) => { setClearSelectionCallback={fileManager.setClearSelectionCallback}
const file = filteredFiles.find((f) => f.id === fileId); onUpdateName={(fileId, newName) => {
if (file) { const file = filteredFiles.find((f) => f.id === fileId);
fileManager.handleRename(fileId, newName, file.description); if (file) {
} fileManager.handleRename(fileId, newName, file.description);
}} }
onUpdateDescription={(fileId, newDescription) => { }}
const file = filteredFiles.find((f) => f.id === fileId); onUpdateDescription={(fileId, newDescription) => {
if (file) { const file = filteredFiles.find((f) => f.id === fileId);
fileManager.handleRename(fileId, file.name, newDescription); if (file) {
} fileManager.handleRename(fileId, file.name, newDescription);
}} }
/> }}
</div> />
</CardContent> </div>
</Card> </CardContent>
</Card>
<FilesModals fileManager={fileManager} modals={modals} onSuccess={loadFiles} /> <FilesModals fileManager={fileManager} modals={modals} onSuccess={loadFiles} />
</FileManagerLayout> </FileManagerLayout>
</GlobalDropZone>
</ProtectedRoute> </ProtectedRoute>
); );
} }

View File

@@ -4,9 +4,8 @@ import { getLocale } from "next-intl/server";
import "./globals.css"; import "./globals.css";
import { Toaster } from "sonner";
import { Favicon } from "@/components/layout/favicon"; import { Favicon } from "@/components/layout/favicon";
import { DynamicToaster } from "@/components/ui/dynamic-toaster";
import { useAppInfo } from "@/contexts/app-info-context"; import { useAppInfo } from "@/contexts/app-info-context";
import { AuthProvider } from "@/contexts/auth-context"; import { AuthProvider } from "@/contexts/auth-context";
import { ShareProvider } from "@/contexts/share-context"; import { ShareProvider } from "@/contexts/share-context";
@@ -42,8 +41,8 @@ export default async function RootLayout({
<AuthProvider> <AuthProvider>
<ShareProvider>{children}</ShareProvider> <ShareProvider>{children}</ShareProvider>
</AuthProvider> </AuthProvider>
<DynamicToaster />
</ThemeProvider> </ThemeProvider>
<Toaster position="bottom-right" expand={false} richColors={false} closeButton={false} />
</NextIntlClientProvider> </NextIntlClientProvider>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,373 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { IconCloudUpload, IconLoader, IconX } from "@tabler/icons-react";
import axios from "axios";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { checkFile, getPresignedUrl, registerFile } from "@/http/endpoints";
import { getFileIcon } from "@/utils/file-icons";
import { generateSafeFileName } from "@/utils/file-utils";
import { formatFileSize } from "@/utils/format-file-size";
import getErrorData from "@/utils/getErrorData";
interface GlobalDropZoneProps {
onSuccess?: () => void;
children: React.ReactNode;
}
enum UploadStatus {
PENDING = "pending",
UPLOADING = "uploading",
SUCCESS = "success",
ERROR = "error",
CANCELLED = "cancelled",
}
interface FileUpload {
id: string;
file: File;
status: UploadStatus;
progress: number;
error?: string;
abortController?: AbortController;
objectName?: string;
}
export function GlobalDropZone({ onSuccess, children }: GlobalDropZoneProps) {
const t = useTranslations();
const [isDragOver, setIsDragOver] = useState(false);
const [fileUploads, setFileUploads] = useState<FileUpload[]>([]);
const [hasShownSuccessToast, setHasShownSuccessToast] = useState(false);
const generateFileId = () => {
return Date.now().toString() + Math.random().toString(36).substr(2, 9);
};
const createFileUpload = (file: File): FileUpload => {
const id = generateFileId();
return {
id,
file,
status: UploadStatus.PENDING,
progress: 0,
};
};
const handleDragOver = useCallback((event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragOver(true);
}, []);
const handleDragLeave = useCallback((event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragOver(false);
}, []);
const uploadFile = useCallback(
async (fileUpload: FileUpload) => {
const { file, id } = fileUpload;
try {
const fileName = file.name;
const extension = fileName.split(".").pop() || "";
const safeObjectName = generateSafeFileName(fileName);
try {
await checkFile({
name: fileName,
objectName: "checkFile",
size: file.size,
extension: extension,
});
} catch (error) {
console.error("File check failed:", error);
const errorData = getErrorData(error);
let errorMessage = t("uploadFile.error");
if (errorData.code === "fileSizeExceeded") {
errorMessage = t(`uploadFile.${errorData.code}`, { maxsizemb: t(`${errorData.details}`) });
} else if (errorData.code === "insufficientStorage") {
errorMessage = t(`uploadFile.${errorData.code}`, { availablespace: t(`${errorData.details}`) });
} else if (errorData.code) {
errorMessage = t(`uploadFile.${errorData.code}`);
}
setFileUploads((prev) =>
prev.map((u) => (u.id === id ? { ...u, status: UploadStatus.ERROR, error: errorMessage } : u))
);
return;
}
setFileUploads((prev) =>
prev.map((u) => (u.id === id ? { ...u, status: UploadStatus.UPLOADING, progress: 0 } : u))
);
const presignedResponse = await getPresignedUrl({
filename: safeObjectName.replace(`.${extension}`, ""),
extension: extension,
});
const { url, objectName } = presignedResponse.data;
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, objectName } : u)));
const abortController = new AbortController();
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, abortController } : u)));
await axios.put(url, file, {
headers: {
"Content-Type": file.type,
},
signal: abortController.signal,
onUploadProgress: (progressEvent: any) => {
const progress = (progressEvent.loaded / (progressEvent.total || file.size)) * 100;
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, progress: Math.round(progress) } : u)));
},
});
await registerFile({
name: fileName,
objectName: objectName,
size: file.size,
extension: extension,
});
setFileUploads((prev) =>
prev.map((u) =>
u.id === id ? { ...u, status: UploadStatus.SUCCESS, progress: 100, abortController: undefined } : u
)
);
} catch (error: any) {
if (error.name === "AbortError" || error.code === "ERR_CANCELED") {
return;
}
console.error("Upload failed:", error);
const errorData = getErrorData(error);
let errorMessage = t("uploadFile.error");
if (errorData.code && errorData.code !== "error") {
errorMessage = t(`uploadFile.${errorData.code}`);
}
setFileUploads((prev) =>
prev.map((u) =>
u.id === id ? { ...u, status: UploadStatus.ERROR, error: errorMessage, abortController: undefined } : u
)
);
}
},
[t]
);
const handleDrop = useCallback(
(event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragOver(false);
const files = event.dataTransfer?.files;
if (!files || files.length === 0) return;
const newUploads = Array.from(files).map(createFileUpload);
setFileUploads((prev) => [...prev, ...newUploads]);
setHasShownSuccessToast(false);
newUploads.forEach((upload) => uploadFile(upload));
},
[uploadFile]
);
const handlePaste = useCallback(
(event: ClipboardEvent) => {
event.preventDefault();
event.stopPropagation();
const items = event.clipboardData?.items;
if (!items) return;
const imageItems = Array.from(items).filter((item) => item.type.startsWith("image/"));
if (imageItems.length === 0) return;
const newUploads: FileUpload[] = [];
imageItems.forEach((item) => {
const file = item.getAsFile();
if (file) {
const timestamp = Date.now();
const extension = file.type.split("/")[1] || "png";
const fileName = `pasted-image-${timestamp}.${extension}`;
const renamedFile = new File([file], fileName, { type: file.type });
newUploads.push(createFileUpload(renamedFile));
}
});
if (newUploads.length > 0) {
setFileUploads((prev) => [...prev, ...newUploads]);
setHasShownSuccessToast(false);
newUploads.forEach((upload) => uploadFile(upload));
toast.success(t("uploadFile.pasteSuccess", { count: newUploads.length }));
}
},
[uploadFile, t]
);
useEffect(() => {
document.addEventListener("dragover", handleDragOver);
document.addEventListener("dragleave", handleDragLeave);
document.addEventListener("drop", handleDrop);
document.addEventListener("paste", handlePaste);
return () => {
document.removeEventListener("dragover", handleDragOver);
document.removeEventListener("dragleave", handleDragLeave);
document.removeEventListener("drop", handleDrop);
document.removeEventListener("paste", handlePaste);
};
}, [handleDragOver, handleDragLeave, handleDrop, handlePaste]);
const removeFile = (fileId: string) => {
setFileUploads((prev) => {
const upload = prev.find((u) => u.id === fileId);
if (upload?.abortController) {
upload.abortController.abort();
}
return prev.filter((u) => u.id !== fileId);
});
};
const retryUpload = (fileId: string) => {
const upload = fileUploads.find((u) => u.id === fileId);
if (upload) {
setFileUploads((prev) =>
prev.map((u) => (u.id === fileId ? { ...u, status: UploadStatus.PENDING, error: undefined, progress: 0 } : u))
);
uploadFile({ ...upload, status: UploadStatus.PENDING, error: undefined, progress: 0 });
}
};
const renderFileIcon = (fileName: string) => {
const { icon: FileIcon, color } = getFileIcon(fileName);
return <FileIcon size={16} className={color} />;
};
const getStatusIcon = (status: UploadStatus) => {
switch (status) {
case UploadStatus.UPLOADING:
return <IconLoader size={14} className="animate-spin text-blue-500" />;
case UploadStatus.SUCCESS:
return <IconCloudUpload size={14} className="text-green-500" />;
case UploadStatus.ERROR:
return <IconX size={14} className="text-red-500" />;
default:
return null;
}
};
useEffect(() => {
if (fileUploads.length > 0) {
const allComplete = fileUploads.every(
(u) => u.status === UploadStatus.SUCCESS || u.status === UploadStatus.ERROR
);
if (allComplete && !hasShownSuccessToast) {
const successCount = fileUploads.filter((u) => u.status === UploadStatus.SUCCESS).length;
const errorCount = fileUploads.filter((u) => u.status === UploadStatus.ERROR).length;
if (successCount > 0) {
toast.success(
errorCount > 0
? t("uploadFile.partialSuccess", { success: successCount, error: errorCount })
: t("uploadFile.allSuccess", { count: successCount })
);
setHasShownSuccessToast(true);
onSuccess?.();
}
setTimeout(() => {
setFileUploads([]);
setHasShownSuccessToast(false);
}, 3000);
}
}
}, [fileUploads, hasShownSuccessToast, onSuccess, t]);
return (
<>
{children}
{isDragOver && (
<div className="fixed inset-0 z-50 dark:bg-black/80 bg-white/90 border-2 border-dashed dark:border-primary/50 border-primary/90 rounded-lg m-1 flex items-center justify-center">
<div className="text-center">
<IconCloudUpload size={64} className="text-primary mx-auto mb-4" />
<h3 className="text-2xl font-bold text-primary mb-2">{t("uploadFile.globalDrop.title")}</h3>
<p className="text-lg dark:text-muted-foreground text-black">{t("uploadFile.globalDrop.description")}</p>
</div>
</div>
)}
{fileUploads.length > 0 && (
<div className="fixed bottom-4 right-4 z-50 max-w-sm w-full space-y-2">
{fileUploads.map((upload) => (
<div key={upload.id} className="bg-background border rounded-lg shadow-lg p-3 flex items-center gap-3">
<div className="flex-shrink-0">{renderFileIcon(upload.file.name)}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">{upload.file.name}</p>
{getStatusIcon(upload.status)}
</div>
<p className="text-xs text-muted-foreground">{formatFileSize(upload.file.size)}</p>
{upload.status === UploadStatus.UPLOADING && (
<div className="mt-1">
<Progress value={upload.progress} className="h-1" />
<p className="text-xs text-muted-foreground mt-1">{upload.progress}%</p>
</div>
)}
{upload.status === UploadStatus.ERROR && upload.error && (
<p className="text-xs text-destructive mt-1">{upload.error}</p>
)}
</div>
<div className="flex-shrink-0">
{upload.status === UploadStatus.ERROR ? (
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => retryUpload(upload.id)}
className="h-6 w-6 p-0"
title={t("uploadFile.retry")}
>
<IconLoader size={12} />
</Button>
<Button variant="ghost" size="sm" onClick={() => removeFile(upload.id)} className="h-6 w-6 p-0">
<IconX size={12} />
</Button>
</div>
) : upload.status === UploadStatus.SUCCESS ? null : (
<Button variant="ghost" size="sm" onClick={() => removeFile(upload.id)} className="h-6 w-6 p-0">
<IconX size={12} />
</Button>
)}
</div>
</div>
))}
</div>
)}
</>
);
}

View File

@@ -0,0 +1,20 @@
"use client";
import { useTheme } from "next-themes";
import { Toaster } from "sonner";
export function DynamicToaster() {
const { theme, resolvedTheme } = useTheme();
const currentTheme = resolvedTheme || theme;
return (
<Toaster
position="top-right"
expand={false}
richColors={theme === "dark" ? true : false}
closeButton={false}
theme={currentTheme as "light" | "dark"}
/>
);
}