mirror of
https://github.com/kyantech/Palmr.git
synced 2025-11-14 02:46:57 +00:00
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:
@@ -1442,7 +1442,12 @@
|
|||||||
"warning": "إذا أغلقت الآن، سيتم إلغاء الرفع وسيفقد أي تقدم.",
|
"warning": "إذا أغلقت الآن، سيتم إلغاء الرفع وسيفقد أي تقدم.",
|
||||||
"continue": "مواصلة الرفع",
|
"continue": "مواصلة الرفع",
|
||||||
"cancel": "إلغاء الرفع"
|
"cancel": "إلغاء الرفع"
|
||||||
}
|
},
|
||||||
|
"globalDrop": {
|
||||||
|
"title": "إفلات الملفات للرفع",
|
||||||
|
"description": "حرر للرفع ملفاتك"
|
||||||
|
},
|
||||||
|
"pasteSuccess": "{count, plural, =1 {تم لصق الصورة ورفعها بنجاح} other {تم لصق # صور ورفعها بنجاح}}"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"modes": {
|
"modes": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -1440,7 +1440,12 @@
|
|||||||
"warning": "यदि आप अभी बंद करते हैं, तो अपलोड रद्द हो जाएंगे और कोई भी प्रगति खो जाएगी।",
|
"warning": "यदि आप अभी बंद करते हैं, तो अपलोड रद्द हो जाएंगे और कोई भी प्रगति खो जाएगी।",
|
||||||
"continue": "अपलोड जारी रखें",
|
"continue": "अपलोड जारी रखें",
|
||||||
"cancel": "अपलोड रद्द करें"
|
"cancel": "अपलोड रद्द करें"
|
||||||
}
|
},
|
||||||
|
"globalDrop": {
|
||||||
|
"title": "अपलोड करने के लिए फ़ाइलें छोड़ें",
|
||||||
|
"description": "अपनी फ़ाइलें अपलोड करने के लिए छोड़ें"
|
||||||
|
},
|
||||||
|
"pasteSuccess": "{count, plural, =1 {छवि सफलतापूर्वक चिपकाई और अपलोड की गई} other {# छवियाँ सफलतापूर्वक चिपकाई और अपलोड की गईं}}"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"modes": {
|
"modes": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -1440,7 +1440,12 @@
|
|||||||
"warning": "지금 닫으면 업로드가 취소되고 모든 진행 상황이 손실됩니다.",
|
"warning": "지금 닫으면 업로드가 취소되고 모든 진행 상황이 손실됩니다.",
|
||||||
"continue": "업로드 계속",
|
"continue": "업로드 계속",
|
||||||
"cancel": "업로드 취소"
|
"cancel": "업로드 취소"
|
||||||
}
|
},
|
||||||
|
"globalDrop": {
|
||||||
|
"title": "업로드할 파일 드롭",
|
||||||
|
"description": "파일을 업로드하려면 놓으세요"
|
||||||
|
},
|
||||||
|
"pasteSuccess": "{count, plural, =1 {이미지가 성공적으로 붙여넣기 및 업로드되었습니다} other {# 개의 이미지가 성공적으로 붙여넣기 및 업로드되었습니다}}"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"modes": {
|
"modes": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -1440,7 +1440,12 @@
|
|||||||
"warning": "Если вы закроете сейчас, загрузки будут отменены и весь прогресс будет потерян.",
|
"warning": "Если вы закроете сейчас, загрузки будут отменены и весь прогресс будет потерян.",
|
||||||
"continue": "Продолжить Загрузку",
|
"continue": "Продолжить Загрузку",
|
||||||
"cancel": "Отменить Загрузку"
|
"cancel": "Отменить Загрузку"
|
||||||
}
|
},
|
||||||
|
"globalDrop": {
|
||||||
|
"title": "Перетащите файлы для загрузки",
|
||||||
|
"description": "Отпустите, чтобы загрузить файлы"
|
||||||
|
},
|
||||||
|
"pasteSuccess": "{count, plural, =1 {Изображение вставлено и успешно загружено} other {# изображений вставлено и успешно загружено}}"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"modes": {
|
"modes": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -1440,7 +1440,12 @@
|
|||||||
"warning": "如果您现在关闭,上传将被取消,任何进度都将丢失。",
|
"warning": "如果您现在关闭,上传将被取消,任何进度都将丢失。",
|
||||||
"continue": "继续上传",
|
"continue": "继续上传",
|
||||||
"cancel": "取消上传"
|
"cancel": "取消上传"
|
||||||
}
|
},
|
||||||
|
"globalDrop": {
|
||||||
|
"title": "拖放文件以上传",
|
||||||
|
"description": "松开以上传您的文件"
|
||||||
|
},
|
||||||
|
"pasteSuccess": "{count, plural, =1 {图片已成功粘贴并上传} other {# 张图片已成功粘贴并上传}}"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"modes": {
|
"modes": {
|
||||||
|
|||||||
@@ -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,6 +40,7 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
|
<GlobalDropZone onSuccess={loadDashboardData}>
|
||||||
<FileManagerLayout
|
<FileManagerLayout
|
||||||
breadcrumbLabel={t("dashboard.breadcrumb")}
|
breadcrumbLabel={t("dashboard.breadcrumb")}
|
||||||
icon={<IconLayoutDashboardFilled className="text-xl" />}
|
icon={<IconLayoutDashboardFilled className="text-xl" />}
|
||||||
@@ -72,6 +74,7 @@ export default function DashboardPage() {
|
|||||||
onSuccess={loadDashboardData}
|
onSuccess={loadDashboardData}
|
||||||
/>
|
/>
|
||||||
</FileManagerLayout>
|
</FileManagerLayout>
|
||||||
|
</GlobalDropZone>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +24,7 @@ export default function FilesPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
|
<GlobalDropZone onSuccess={loadFiles}>
|
||||||
<FileManagerLayout
|
<FileManagerLayout
|
||||||
breadcrumbLabel={t("files.breadcrumb")}
|
breadcrumbLabel={t("files.breadcrumb")}
|
||||||
icon={<IconFolderOpen size={20} />}
|
icon={<IconFolderOpen size={20} />}
|
||||||
@@ -64,6 +66,7 @@ export default function FilesPage() {
|
|||||||
|
|
||||||
<FilesModals fileManager={fileManager} modals={modals} onSuccess={loadFiles} />
|
<FilesModals fileManager={fileManager} modals={modals} onSuccess={loadFiles} />
|
||||||
</FileManagerLayout>
|
</FileManagerLayout>
|
||||||
|
</GlobalDropZone>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
373
apps/web/src/components/general/global-drop-zone.tsx
Normal file
373
apps/web/src/components/general/global-drop-zone.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/web/src/components/ui/dynamic-toaster.tsx
Normal file
20
apps/web/src/components/ui/dynamic-toaster.tsx
Normal 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"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user