From 61cf88b41f7c1e78a1e02e654d83e6a2fcd7d2f8 Mon Sep 17 00:00:00 2001 From: Daniel Luiz Alves Date: Wed, 9 Apr 2025 15:48:00 -0300 Subject: [PATCH] feat(dashboard): add dashboard components and utilities This commit introduces new components and utilities for the dashboard, including storage usage, quick access cards, recent files, and recent shares. It also adds file and share management hooks, along with new UI components like progress bars, separators, and avatars. The changes enhance the dashboard's functionality and improve user experience by providing quick access to essential features and better visual feedback. The commit includes: - New components for storage usage, quick access, recent files, and shares - File and share management hooks for CRUD operations - Utility functions for file size formatting and file icons - New UI components like progress bars, separators, and avatars - Updated translations and styles for consistency --- apps/app/messages/ar-SA.json | 8 +- apps/app/messages/de-DE.json | 8 +- apps/app/messages/en-US.json | 8 +- apps/app/messages/es-ES.json | 8 +- apps/app/messages/fr-FR.json | 8 +- apps/app/messages/hi-IN.json | 8 +- apps/app/messages/ja-JP.json | 8 +- apps/app/messages/ko-KR.json | 8 +- apps/app/messages/pt-BR.json | 8 +- apps/app/messages/ru-RU.json | 8 +- apps/app/messages/tr-TR.json | 8 +- apps/app/messages/zh-CN.json | 8 +- apps/app/package.json | 5 + apps/app/pnpm-lock.yaml | 199 ++++++++++++++++++ apps/app/src/app/(home)/components/navbar.tsx | 2 +- .../dashboard/components/empty-file-state.tsx | 18 ++ .../components/empty-shares-state.tsx | 20 ++ .../components/quick-access-cards.tsx | 50 +++++ .../app/dashboard/components/recent-files.tsx | 61 ++++++ .../dashboard/components/recent-shares.tsx | 67 ++++++ .../dashboard/components/storage-usage.tsx | 35 +++ .../src/app/dashboard/hooks/use-dashboard.ts | 102 +++++++++ .../app/dashboard/modals/dashboard-modals.tsx | 62 ++++++ apps/app/src/app/dashboard/page.tsx | 72 ++++++- apps/app/src/app/dashboard/types/index.ts | 40 ++++ .../dashboard/utils/format-storage-size.ts | 21 ++ apps/app/src/app/globals.css | 11 +- .../components/general/language-switcher.tsx | 2 + .../components/{ => general}/mode-toggle.tsx | 0 .../components/layout/file-manager-layout.tsx | 65 ++++++ apps/app/src/components/layout/navbar.tsx | 106 ++++++++++ .../app/src/components/tables/files-table.tsx | 146 +++++++++++++ .../src/components/tables/shares-table.tsx | 192 +++++++++++++++++ apps/app/src/components/ui/avatar.tsx | 53 +++++ apps/app/src/components/ui/badge.tsx | 46 ++++ apps/app/src/components/ui/card.tsx | 92 ++++++++ apps/app/src/components/ui/progress.tsx | 31 +++ apps/app/src/components/ui/separator.tsx | 28 +++ apps/app/src/components/ui/table.tsx | 116 ++++++++++ apps/app/src/contexts/share-context.tsx | 41 ++++ apps/app/src/hooks/use-file-manager.ts | 101 +++++++++ apps/app/src/hooks/use-share-manager.ts | 135 ++++++++++++ apps/app/src/utils/file-icons.tsx | 81 +++++++ apps/app/src/utils/format-file-size.ts | 13 ++ 44 files changed, 2047 insertions(+), 62 deletions(-) create mode 100644 apps/app/src/app/dashboard/components/empty-file-state.tsx create mode 100644 apps/app/src/app/dashboard/components/empty-shares-state.tsx create mode 100644 apps/app/src/app/dashboard/components/quick-access-cards.tsx create mode 100644 apps/app/src/app/dashboard/components/recent-files.tsx create mode 100644 apps/app/src/app/dashboard/components/recent-shares.tsx create mode 100644 apps/app/src/app/dashboard/components/storage-usage.tsx create mode 100644 apps/app/src/app/dashboard/hooks/use-dashboard.ts create mode 100644 apps/app/src/app/dashboard/modals/dashboard-modals.tsx create mode 100644 apps/app/src/app/dashboard/types/index.ts create mode 100644 apps/app/src/app/dashboard/utils/format-storage-size.ts rename apps/app/src/components/{ => general}/mode-toggle.tsx (100%) create mode 100644 apps/app/src/components/layout/file-manager-layout.tsx create mode 100644 apps/app/src/components/layout/navbar.tsx create mode 100644 apps/app/src/components/tables/files-table.tsx create mode 100644 apps/app/src/components/tables/shares-table.tsx create mode 100644 apps/app/src/components/ui/avatar.tsx create mode 100644 apps/app/src/components/ui/badge.tsx create mode 100644 apps/app/src/components/ui/card.tsx create mode 100644 apps/app/src/components/ui/progress.tsx create mode 100644 apps/app/src/components/ui/separator.tsx create mode 100644 apps/app/src/components/ui/table.tsx create mode 100644 apps/app/src/contexts/share-context.tsx create mode 100644 apps/app/src/hooks/use-file-manager.ts create mode 100644 apps/app/src/hooks/use-share-manager.ts create mode 100644 apps/app/src/utils/file-icons.tsx create mode 100644 apps/app/src/utils/format-file-size.ts diff --git a/apps/app/messages/ar-SA.json b/apps/app/messages/ar-SA.json index b871a57..3ac63e2 100644 --- a/apps/app/messages/ar-SA.json +++ b/apps/app/messages/ar-SA.json @@ -203,8 +203,8 @@ "protected": "محمي", "public": "عام" }, - "filesCount": "{{count}} ملف", - "recipientsCount": "{{count}} مستلم", + "filesCount": "ملف", + "recipientsCount": "مستلم", "actions": { "menu": "قائمة إجراءات المشاركة", "edit": "تعديل", @@ -270,8 +270,8 @@ "storageUsage": { "title": "استخدام التخزين", "ariaLabel": "شريط تقدم استخدام التخزين", - "used": "المستخدمة: {{size}}", - "available": "المتاحة: {{size}}" + "used": "المستخدمة", + "available": "المتاحة" }, "dashboard": { "loadError": "فشل في تحميل بيانات لوحة التحكم", diff --git a/apps/app/messages/de-DE.json b/apps/app/messages/de-DE.json index 75d397d..f28aba5 100644 --- a/apps/app/messages/de-DE.json +++ b/apps/app/messages/de-DE.json @@ -203,8 +203,8 @@ "protected": "Geschützt", "public": "Öffentlich" }, - "filesCount": "{{count}} Dateien", - "recipientsCount": "{{count}} Empfänger", + "filesCount": "Dateien", + "recipientsCount": "Empfänger", "actions": { "menu": "Freigabeaktionsmenü", "edit": "Bearbeiten", @@ -270,8 +270,8 @@ "storageUsage": { "title": "Speichernutzung", "ariaLabel": "Fortschrittsbalken der Speichernutzung", - "used": "{{size}} genutzt", - "available": "{{size}} verfügbar" + "used": "genutzt", + "available": "verfügbar" }, "dashboard": { "loadError": "Fehler beim Laden der Dashboard-Daten", diff --git a/apps/app/messages/en-US.json b/apps/app/messages/en-US.json index 6385c46..20296a3 100644 --- a/apps/app/messages/en-US.json +++ b/apps/app/messages/en-US.json @@ -203,8 +203,8 @@ "protected": "Protected", "public": "Public" }, - "filesCount": "{{count}} files", - "recipientsCount": "{{count}} recipients", + "filesCount": "files", + "recipientsCount": "recipients", "actions": { "menu": "Share actions menu", "edit": "Edit", @@ -270,8 +270,8 @@ "storageUsage": { "title": "Storage Usage", "ariaLabel": "Storage usage progress bar", - "used": "{{size}} used", - "available": "{{size}} available" + "used": "used", + "available": "available" }, "dashboard": { "loadError": "Failed to load dashboard data", diff --git a/apps/app/messages/es-ES.json b/apps/app/messages/es-ES.json index b41f7a2..c557bde 100644 --- a/apps/app/messages/es-ES.json +++ b/apps/app/messages/es-ES.json @@ -203,8 +203,8 @@ "protected": "Protegida", "public": "Pública" }, - "filesCount": "{{count}} archivos", - "recipientsCount": "{{count}} destinatarios", + "filesCount": "archivos", + "recipientsCount": "destinatarios", "actions": { "menu": "Menú de acciones de la compartición", "edit": "Editar", @@ -270,8 +270,8 @@ "storageUsage": { "title": "Uso de almacenamiento", "ariaLabel": "Barra de progreso del uso de almacenamiento", - "used": "{{size}} usados", - "available": "{{size}} disponibles" + "used": "usados", + "available": "disponibles" }, "dashboard": { "loadError": "Error al cargar los datos del tablero", diff --git a/apps/app/messages/fr-FR.json b/apps/app/messages/fr-FR.json index 75ccce7..aaf9187 100644 --- a/apps/app/messages/fr-FR.json +++ b/apps/app/messages/fr-FR.json @@ -203,8 +203,8 @@ "protected": "Protégé", "public": "Public" }, - "filesCount": "{{count}} fichiers", - "recipientsCount": "{{count}} destinataires", + "filesCount": "fichiers", + "recipientsCount": "destinataires", "actions": { "menu": "Menu d'actions du partage", "edit": "Modifier", @@ -270,8 +270,8 @@ "storageUsage": { "title": "Utilisation du Stockage", "ariaLabel": "Barre de progression de l'utilisation du stockage", - "used": "{{size}} utilisé", - "available": "{{size}} disponible" + "used": "utilisé", + "available": "disponible" }, "dashboard": { "loadError": "Échec du chargement des données du tableau de bord", diff --git a/apps/app/messages/hi-IN.json b/apps/app/messages/hi-IN.json index b07567f..9a39282 100644 --- a/apps/app/messages/hi-IN.json +++ b/apps/app/messages/hi-IN.json @@ -203,8 +203,8 @@ "protected": "संरक्षित", "public": "सार्वजनिक" }, - "filesCount": "{{count}} फाइलें", - "recipientsCount": "{{count}} प्राप्तकर्ता", + "filesCount": "फाइलें", + "recipientsCount": "प्राप्तकर्ता", "actions": { "menu": "साझाकरण क्रिया मेनू", "edit": "संपादित करें", @@ -270,8 +270,8 @@ "storageUsage": { "title": "स्टोरेज उपयोग", "ariaLabel": "स्टोरेज उपयोग प्रगति पट्टी", - "used": "{{size}} उपयोग किया गया", - "available": "{{size}} उपलब्ध" + "used": "उपयोग किया गया", + "available": "उपलब्ध" }, "dashboard": { "loadError": "डैशबोर्ड डेटा लोड करने में त्रुटि", diff --git a/apps/app/messages/ja-JP.json b/apps/app/messages/ja-JP.json index 2f0a7a8..ce0b8ee 100644 --- a/apps/app/messages/ja-JP.json +++ b/apps/app/messages/ja-JP.json @@ -203,8 +203,8 @@ "protected": "保護済み", "public": "公開" }, - "filesCount": "{{count}} ファイル", - "recipientsCount": "{{count}} 受信者", + "filesCount": "ファイル", + "recipientsCount": "受信者", "actions": { "menu": "共有操作メニュー", "edit": "編集", @@ -270,8 +270,8 @@ "storageUsage": { "title": "ストレージ使用量", "ariaLabel": "ストレージ使用状況のプログレスバー", - "used": "{{size}} 使用済み", - "available": "{{size}} 利用可能" + "used": "使用済み", + "available": "利用可能" }, "dashboard": { "loadError": "ダッシュボードデータの読み込みに失敗しました", diff --git a/apps/app/messages/ko-KR.json b/apps/app/messages/ko-KR.json index 8f5012d..8cc0440 100644 --- a/apps/app/messages/ko-KR.json +++ b/apps/app/messages/ko-KR.json @@ -203,8 +203,8 @@ "protected": "보호됨", "public": "공개" }, - "filesCount": "{{count}}개의 파일", - "recipientsCount": "{{count}}명의 수신자", + "filesCount": "개의 파일", + "recipientsCount": "명의 수신자", "actions": { "menu": "공유 작업 메뉴", "edit": "편집", @@ -270,8 +270,8 @@ "storageUsage": { "title": "스토리지 사용량", "ariaLabel": "스토리지 사용량 진행 바", - "used": "{{size}} 사용됨", - "available": "{{size}} 사용 가능" + "used": "사용됨", + "available": "사용 가능" }, "dashboard": { "loadError": "대시보드 데이터를 불러오는데 실패했습니다", diff --git a/apps/app/messages/pt-BR.json b/apps/app/messages/pt-BR.json index e491ecd..7fecdc1 100644 --- a/apps/app/messages/pt-BR.json +++ b/apps/app/messages/pt-BR.json @@ -203,8 +203,8 @@ "protected": "Protegido", "public": "Público" }, - "filesCount": "{{count}} arquivos", - "recipientsCount": "{{count}} destinatários", + "filesCount": "arquivos", + "recipientsCount": "destinatários", "actions": { "menu": "Menu de ações do compartilhamento", "edit": "Editar", @@ -270,8 +270,8 @@ "storageUsage": { "title": "Uso de Armazenamento", "ariaLabel": "Barra de progresso do uso de armazenamento", - "used": "{{size}} usado", - "available": "{{size}} disponível" + "used": "usado", + "available": "disponível" }, "dashboard": { "loadError": "Falha ao carregar dados do painel", diff --git a/apps/app/messages/ru-RU.json b/apps/app/messages/ru-RU.json index 00895d9..079f5c9 100644 --- a/apps/app/messages/ru-RU.json +++ b/apps/app/messages/ru-RU.json @@ -203,8 +203,8 @@ "protected": "Защищено", "public": "Публично" }, - "filesCount": "{{count}} файлов", - "recipientsCount": "{{count}} получателей", + "filesCount": "файлов", + "recipientsCount": "получателей", "actions": { "menu": "Меню действий общего доступа", "edit": "Редактировать", @@ -270,8 +270,8 @@ "storageUsage": { "title": "Использование хранилища", "ariaLabel": "Индикатор использования хранилища", - "used": "Использовано: {{size}}", - "available": "Доступно: {{size}}" + "used": "Использовано", + "available": "Доступно" }, "dashboard": { "loadError": "Ошибка загрузки данных панели управления", diff --git a/apps/app/messages/tr-TR.json b/apps/app/messages/tr-TR.json index b4f81aa..8929bdf 100644 --- a/apps/app/messages/tr-TR.json +++ b/apps/app/messages/tr-TR.json @@ -203,8 +203,8 @@ "protected": "Korunuyor", "public": "Genel" }, - "filesCount": "{{count}} dosya", - "recipientsCount": "{{count}} alıcı", + "filesCount": "dosya", + "recipientsCount": "alıcı", "actions": { "menu": "Paylaşım İşlem Menüsü", "edit": "Düzenle", @@ -270,8 +270,8 @@ "storageUsage": { "title": "Depolama Kullanımı", "ariaLabel": "Depolama kullanım ilerleme çubuğu", - "used": "{{size}} kullanıldı", - "available": "{{size}} kullanılabilir" + "used": "kullanıldı", + "available": "kullanılabilir" }, "dashboard": { "loadError": "Gösterge paneli verileri yüklenemedi", diff --git a/apps/app/messages/zh-CN.json b/apps/app/messages/zh-CN.json index 38e6c5a..63fe8b3 100644 --- a/apps/app/messages/zh-CN.json +++ b/apps/app/messages/zh-CN.json @@ -203,8 +203,8 @@ "protected": "受保护", "public": "公开" }, - "filesCount": "{{count}} 个文件", - "recipientsCount": "{{count}} 个收件人", + "filesCount": "个文件", + "recipientsCount": "个收件人", "actions": { "menu": "共享操作菜单", "edit": "编辑", @@ -270,8 +270,8 @@ "storageUsage": { "title": "存储使用情况", "ariaLabel": "存储使用进度条", - "used": "已使用:{{size}}", - "available": "可用:{{size}}" + "used": "已使用:", + "available": "可用:" }, "dashboard": { "loadError": "加载仪表盘数据失败", diff --git a/apps/app/package.json b/apps/app/package.json index d98cb42..668584e 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -13,14 +13,18 @@ }, "dependencies": { "@hookform/resolvers": "^5.0.1", + "@radix-ui/react-avatar": "^1.1.4", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-progress": "^1.1.3", + "@radix-ui/react-separator": "^1.1.3", "@radix-ui/react-slot": "^1.1.2", "@tabler/icons-react": "^3.31.0", "axios": "^1.8.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "framer-motion": "^12.6.3", "lucide-react": "^0.487.0", "next": "15.2.4", @@ -31,6 +35,7 @@ "react-country-flag": "^3.1.0", "react-dom": "^19.1.0", "react-hook-form": "^7.55.0", + "sonner": "^2.0.3", "tailwind-merge": "^3.1.0", "tw-animate-css": "^1.2.5", "zod": "^3.24.2", diff --git a/apps/app/pnpm-lock.yaml b/apps/app/pnpm-lock.yaml index 74ce7f7..47baaab 100644 --- a/apps/app/pnpm-lock.yaml +++ b/apps/app/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@hookform/resolvers': specifier: ^5.0.1 version: 5.0.1(react-hook-form@7.55.0(react@19.1.0)) + '@radix-ui/react-avatar': + specifier: ^1.1.4 + version: 1.1.4(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-dialog': specifier: ^1.1.6 version: 1.1.6(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -20,6 +23,12 @@ importers: '@radix-ui/react-label': specifier: ^2.1.2 version: 2.1.2(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-progress': + specifier: ^1.1.3 + version: 1.1.3(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-separator': + specifier: ^1.1.3 + version: 1.1.3(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-slot': specifier: ^1.1.2 version: 1.1.2(@types/react@19.1.0)(react@19.1.0) @@ -35,6 +44,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 framer-motion: specifier: ^12.6.3 version: 12.6.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -65,6 +77,9 @@ importers: react-hook-form: specifier: ^7.55.0 version: 7.55.0(react@19.1.0) + sonner: + specifier: ^2.0.3 + version: 2.0.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) tailwind-merge: specifier: ^3.1.0 version: 3.1.0 @@ -503,6 +518,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-avatar@1.1.4': + resolution: {integrity: sha512-+kBesLBzwqyDiYCtYFK+6Ktf+N7+Y6QOTUueLGLIbLZ/YeyFW6bsBGDsN+5HxHpM55C90u5fxsg0ErxzXTcwKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-collection@1.1.2': resolution: {integrity: sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==} peerDependencies: @@ -525,6 +553,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-context@1.1.1': resolution: {integrity: sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==} peerDependencies: @@ -534,6 +571,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dialog@1.1.6': resolution: {integrity: sha512-/IVhJV5AceX620DUJ4uYVMymzsipdKBzo3edo+omeskCKGm9FRHM0ebIdbPnlQVJqyuHbuBltQUOG2mOTq2IYw==} peerDependencies: @@ -691,6 +737,32 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.0.3': + resolution: {integrity: sha512-Pf/t/GkndH7CQ8wE2hbkXA+WyZ83fhQQn5DDmwDiDo6AwN/fhaH8oqZ0jRjMrO2iaMhDi6P1HRx6AZwyMinY1g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-progress@1.1.3': + resolution: {integrity: sha512-F56aZPGTPb4qJQ/vDjnAq63oTu/DRoIG/Asb5XKOWj8rpefNLtUllR969j5QDN2sRrTk9VXIqQDRj5VvAuquaw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-roving-focus@1.1.2': resolution: {integrity: sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==} peerDependencies: @@ -704,6 +776,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-separator@1.1.3': + resolution: {integrity: sha512-2omrWKJvxR0U/tkIXezcc1nFMwtLU0+b/rDK40gnzJqTLWQ/TD/D5IYVefp9sC3QWfeQbpSbEA6op9MQKyaALQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.1.2': resolution: {integrity: sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==} peerDependencies: @@ -713,6 +798,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-slot@1.2.0': + resolution: {integrity: sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-callback-ref@1.1.0': resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==} peerDependencies: @@ -722,6 +816,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-controllable-state@1.1.0': resolution: {integrity: sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==} peerDependencies: @@ -749,6 +852,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-rect@1.1.0': resolution: {integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==} peerDependencies: @@ -1199,6 +1311,9 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -2217,6 +2332,12 @@ packages: simple-swizzle@0.2.2: resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + sonner@2.0.3: + resolution: {integrity: sha512-njQ4Hht92m0sMqqHVDL32V2Oun9W1+PHO9NDv9FHfJjT3JT22IG4Jpo3FPQy+mouRKCXFWO+r67v6MrHX2zeIA==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2775,6 +2896,18 @@ snapshots: '@types/react': 19.1.0 '@types/react-dom': 19.1.1(@types/react@19.1.0) + '@radix-ui/react-avatar@1.1.4(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@19.1.0)(react@19.1.0) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.0)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.0)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.0 + '@types/react-dom': 19.1.1(@types/react@19.1.0) + '@radix-ui/react-collection@1.1.2(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.1.0)(react@19.1.0) @@ -2793,12 +2926,24 @@ snapshots: optionalDependencies: '@types/react': 19.1.0 + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.1.0)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.0 + '@radix-ui/react-context@1.1.1(@types/react@19.1.0)(react@19.1.0)': dependencies: react: 19.1.0 optionalDependencies: '@types/react': 19.1.0 + '@radix-ui/react-context@1.1.2(@types/react@19.1.0)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.0 + '@radix-ui/react-dialog@1.1.6(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.1 @@ -2961,6 +3106,25 @@ snapshots: '@types/react': 19.1.0 '@types/react-dom': 19.1.1(@types/react@19.1.0) + '@radix-ui/react-primitive@2.0.3(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-slot': 1.2.0(@types/react@19.1.0)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.0 + '@types/react-dom': 19.1.1(@types/react@19.1.0) + + '@radix-ui/react-progress@1.1.3(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@19.1.0)(react@19.1.0) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.0 + '@types/react-dom': 19.1.1(@types/react@19.1.0) + '@radix-ui/react-roving-focus@1.1.2(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.1 @@ -2978,6 +3142,15 @@ snapshots: '@types/react': 19.1.0 '@types/react-dom': 19.1.1(@types/react@19.1.0) + '@radix-ui/react-separator@1.1.3(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.0 + '@types/react-dom': 19.1.1(@types/react@19.1.0) + '@radix-ui/react-slot@1.1.2(@types/react@19.1.0)(react@19.1.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.1.0)(react@19.1.0) @@ -2985,12 +3158,25 @@ snapshots: optionalDependencies: '@types/react': 19.1.0 + '@radix-ui/react-slot@1.2.0(@types/react@19.1.0)(react@19.1.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.0)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.0 + '@radix-ui/react-use-callback-ref@1.1.0(@types/react@19.1.0)(react@19.1.0)': dependencies: react: 19.1.0 optionalDependencies: '@types/react': 19.1.0 + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.1.0)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.0 + '@radix-ui/react-use-controllable-state@1.1.0(@types/react@19.1.0)(react@19.1.0)': dependencies: '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.1.0)(react@19.1.0) @@ -3011,6 +3197,12 @@ snapshots: optionalDependencies: '@types/react': 19.1.0 + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.1.0)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.0 + '@radix-ui/react-use-rect@1.1.0(@types/react@19.1.0)(react@19.1.0)': dependencies: '@radix-ui/rect': 1.1.0 @@ -3476,6 +3668,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + date-fns@4.1.0: {} + debug@3.2.7: dependencies: ms: 2.1.3 @@ -4647,6 +4841,11 @@ snapshots: is-arrayish: 0.3.2 optional: true + sonner@2.0.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + source-map-js@1.2.1: {} stable-hash@0.0.5: {} diff --git a/apps/app/src/app/(home)/components/navbar.tsx b/apps/app/src/app/(home)/components/navbar.tsx index 6eba342..b5ccc3b 100644 --- a/apps/app/src/app/(home)/components/navbar.tsx +++ b/apps/app/src/app/(home)/components/navbar.tsx @@ -6,7 +6,7 @@ import Link from "next/link"; import { IconHeart, IconMenu2 } from "@tabler/icons-react"; import { LanguageSwitcher } from "@/components/general/language-switcher"; -import { ModeToggle } from "@/components/mode-toggle"; +import { ModeToggle } from "@/components/general/mode-toggle"; import { Button } from "@/components/ui/button"; import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; import { siteConfig } from "@/config/site"; diff --git a/apps/app/src/app/dashboard/components/empty-file-state.tsx b/apps/app/src/app/dashboard/components/empty-file-state.tsx new file mode 100644 index 0000000..0e404ee --- /dev/null +++ b/apps/app/src/app/dashboard/components/empty-file-state.tsx @@ -0,0 +1,18 @@ +import { Button } from "@/components/ui/button"; +import { useTranslations } from "next-intl"; +import { IconCloudUpload, IconFolderOpen } from "@tabler/icons-react"; + +export function EmptyFilesState({ onUpload }: { onUpload: () => void; }) { + const t = useTranslations(); + + return ( +
+ +

{t("recentFiles.noFiles")}

+ +
+ ); +} diff --git a/apps/app/src/app/dashboard/components/empty-shares-state.tsx b/apps/app/src/app/dashboard/components/empty-shares-state.tsx new file mode 100644 index 0000000..613f936 --- /dev/null +++ b/apps/app/src/app/dashboard/components/empty-shares-state.tsx @@ -0,0 +1,20 @@ +import { Button } from "@/components/ui/button"; +import { useTranslations } from "next-intl"; +import { IconShare, IconPlus } from "@tabler/icons-react"; + +export function EmptySharesState({ onCreate }: { onCreate: () => void; }) { + const t = useTranslations(); + + return ( +
+ +
+

{t("recentShares.noShares")}

+ +
+
+ ); +} diff --git a/apps/app/src/app/dashboard/components/quick-access-cards.tsx b/apps/app/src/app/dashboard/components/quick-access-cards.tsx new file mode 100644 index 0000000..aeb0f97 --- /dev/null +++ b/apps/app/src/app/dashboard/components/quick-access-cards.tsx @@ -0,0 +1,50 @@ +import { Card, CardContent } from "@/components/ui/card"; +import { IconFoldersFilled, IconShare2 } from "@tabler/icons-react"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; + +export function QuickAccessCards() { + const t = useTranslations(); + const router = useRouter(); + + const QUICK_ACCESS_ITEMS = [ + { + title: t("quickAccess.files.title"), + icon: , + description: t("quickAccess.files.description"), + path: "/files", + color: "bg-primary", + }, + { + title: t("quickAccess.shares.title"), + icon: , + description: t("quickAccess.shares.description"), + path: "/shares", + color: "bg-orange-400", + }, + ] as const; + + return ( +
+ {QUICK_ACCESS_ITEMS.map((card) => ( + router.push(card.path)} + > + +
+
+ {card.icon} +
+
+

{card.title}

+

{card.description}

+
+
+
+
+ ))} +
+ ); +} diff --git a/apps/app/src/app/dashboard/components/recent-files.tsx b/apps/app/src/app/dashboard/components/recent-files.tsx new file mode 100644 index 0000000..086fb38 --- /dev/null +++ b/apps/app/src/app/dashboard/components/recent-files.tsx @@ -0,0 +1,61 @@ +import type { RecentFilesProps } from "../types"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { EmptyFilesState } from "./empty-file-state"; +import { IconCloudUpload, IconFolderOpen } from "@tabler/icons-react"; +import { FilesTable } from "@/components/tables/files-table"; + +export function RecentFiles({ files, fileManager, onOpenUploadModal }: RecentFilesProps) { + const t = useTranslations(); + const router = useRouter(); + + return ( + + +
+

+ + {t("recentFiles.title")} +

+ {files.length >= 5 ? ( + + ) : files.length === 0 ? null : ( + + )} +
+ {files.length > 0 ? ( + + ) : ( + + )} +
+
+ ); +} + + diff --git a/apps/app/src/app/dashboard/components/recent-shares.tsx b/apps/app/src/app/dashboard/components/recent-shares.tsx new file mode 100644 index 0000000..f2d8830 --- /dev/null +++ b/apps/app/src/app/dashboard/components/recent-shares.tsx @@ -0,0 +1,67 @@ +import { RecentSharesProps } from "../types"; +import { SharesTable } from "@/components/tables/shares-table"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { useTranslations } from "next-intl"; +import { IconShare, IconPlus } from "@tabler/icons-react"; +import { useRouter } from "next/navigation"; +import { EmptySharesState } from "./empty-shares-state"; + +export function RecentShares({ shares, shareManager, onOpenCreateModal, onCopyLink }: RecentSharesProps) { + const t = useTranslations(); + const router = useRouter(); + + return ( + + +
+
+

+ + {t("recentShares.title")} +

+ {shares.length >= 5 ? ( + + ) : shares.length === 0 ? null : ( + + )} +
+ + {shares.length > 0 ? ( + + ) : ( + + )} +
+
+
+ ); +} + + diff --git a/apps/app/src/app/dashboard/components/storage-usage.tsx b/apps/app/src/app/dashboard/components/storage-usage.tsx new file mode 100644 index 0000000..de6bf4d --- /dev/null +++ b/apps/app/src/app/dashboard/components/storage-usage.tsx @@ -0,0 +1,35 @@ +import type { StorageUsageProps } from "../types"; +import { formatStorageSize } from "../utils/format-storage-size"; +import { Card, CardContent } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { useTranslations } from "next-intl"; +import { IconDatabaseCog } from "@tabler/icons-react"; + +export function StorageUsage({ diskSpace }: StorageUsageProps) { + const t = useTranslations(); + console.log('diskSpace:', diskSpace); + + return ( + + +
+

+ + {t("storageUsage.title")} +

+
+ +
+ {formatStorageSize(diskSpace?.diskUsedGB || 0)} {t("storageUsage.used")} + {formatStorageSize(diskSpace?.diskAvailableGB || 0)} {t("storageUsage.available")} +
+
+
+
+
+ ); +} diff --git a/apps/app/src/app/dashboard/hooks/use-dashboard.ts b/apps/app/src/app/dashboard/hooks/use-dashboard.ts new file mode 100644 index 0000000..b2ee09f --- /dev/null +++ b/apps/app/src/app/dashboard/hooks/use-dashboard.ts @@ -0,0 +1,102 @@ +"use client" + +import { useFileManager } from "@/hooks/use-file-manager"; +import { useShareManager } from "@/hooks/use-share-manager"; +import { getDiskSpace, listFiles, listUserShares, getAllConfigs } from "@/http/endpoints"; +import { ListUserShares200SharesItem } from "@/http/models/listUserShares200SharesItem"; +import { useState, useEffect } from "react"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; + +export function useDashboard() { + const t = useTranslations(); + const [diskSpace, setDiskSpace] = useState<{ + diskSizeGB: number; + diskUsedGB: number; + diskAvailableGB: number; + uploadAllowed: boolean; + } | null>(null); + const [recentFiles, setRecentFiles] = useState([]); + const [recentShares, setRecentShares] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [smtpEnabled, setSmtpEnabled] = useState("false"); + + const [isUploadModalOpen, setIsUploadModalOpen] = useState(false); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + + const onOpenUploadModal = () => setIsUploadModalOpen(true); + const onCloseUploadModal = () => setIsUploadModalOpen(false); + const onOpenCreateModal = () => setIsCreateModalOpen(true); + const onCloseCreateModal = () => setIsCreateModalOpen(false); + + const loadDashboardData = async () => { + try { + const [diskSpaceRes, filesRes, sharesRes, configsRes] = await Promise.all([ + getDiskSpace(), + listFiles(), + listUserShares(), + getAllConfigs(), + ]); + + setDiskSpace(diskSpaceRes.data); + + const allFiles = filesRes.data.files || []; + const sortedFiles = [...allFiles].sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + + setRecentFiles(sortedFiles.slice(0, 5)); + + const allShares = sharesRes.data.shares || []; + const sortedShares = [...allShares].sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + + setRecentShares(sortedShares.slice(0, 5)); + + const smtpConfig = configsRes.data.configs.find((config: any) => config.key === "smtpEnabled"); + + setSmtpEnabled(smtpConfig?.value === "true" ? "true" : "false"); + } catch (error) { + toast.error(t("dashboard.loadError")); + console.error(error); + } finally { + setIsLoading(false); + } + }; + + const fileManager = useFileManager(loadDashboardData); + const shareManager = useShareManager(loadDashboardData); + + const handleCopyLink = (share: ListUserShares200SharesItem) => { + if (!share.alias?.alias) return; + const link = `${window.location.origin}/s/${share.alias.alias}`; + + navigator.clipboard.writeText(link); + toast.success(t("dashboard.linkCopied")); + }; + + useEffect(() => { + loadDashboardData(); + }, []); + + return { + isLoading, + diskSpace, + recentFiles, + recentShares, + modals: { + isUploadModalOpen, + isCreateModalOpen, + onOpenUploadModal, + onCloseUploadModal, + onOpenCreateModal, + onCloseCreateModal, + }, + fileManager, + shareManager, + handleCopyLink, + loadDashboardData, + smtpEnabled, + }; +} diff --git a/apps/app/src/app/dashboard/modals/dashboard-modals.tsx b/apps/app/src/app/dashboard/modals/dashboard-modals.tsx new file mode 100644 index 0000000..6a5fc11 --- /dev/null +++ b/apps/app/src/app/dashboard/modals/dashboard-modals.tsx @@ -0,0 +1,62 @@ +import { DashboardModalsProps } from "../types"; +import { CreateShareModal } from "@/components/modals/create-share-modal"; +import { FileActionsModals } from "@/components/modals/file-actions-modals"; +import { FilePreviewModal } from "@/components/modals/file-preview-modal"; +import { GenerateShareLinkModal } from "@/components/modals/generate-share-link-modal"; +import { ShareActionsModals } from "@/components/modals/share-actions-modals"; +import { ShareDetailsModal } from "@/components/modals/share-details-modal"; +import { UploadFileModal } from "@/components/modals/upload-file-modal"; + +export function DashboardModals({ modals, fileManager, shareManager, onSuccess }: DashboardModalsProps) { + return ( + <> + + + fileManager.setPreviewFile(null)} + /> + + fileManager.setFileToDelete(null)} + onCloseRename={() => fileManager.setFileToRename(null)} + onDelete={fileManager.handleDelete} + onRename={fileManager.handleRename} + /> + + + + shareManager.setShareToDelete(null)} + onCloseEdit={() => shareManager.setShareToEdit(null)} + onCloseManageFiles={() => shareManager.setShareToManageFiles(null)} + onCloseManageRecipients={() => shareManager.setShareToManageRecipients(null)} + onDelete={shareManager.handleDelete} + onEdit={shareManager.handleEdit} + onManageFiles={shareManager.handleManageFiles} + onManageRecipients={shareManager.handleManageRecipients} + onSuccess={onSuccess} + /> + + shareManager.setShareToViewDetails(null)} + /> + + shareManager.setShareToGenerateLink(null)} + onGenerate={shareManager.handleGenerateLink} + onSuccess={onSuccess} + /> + + ); +} diff --git a/apps/app/src/app/dashboard/page.tsx b/apps/app/src/app/dashboard/page.tsx index 9c4bfe8..63dea84 100644 --- a/apps/app/src/app/dashboard/page.tsx +++ b/apps/app/src/app/dashboard/page.tsx @@ -1,16 +1,72 @@ "use client"; -import { ProtectedRoute } from "@/components/auth/protected-route"; +import { IconLayoutDashboardFilled } from "@tabler/icons-react"; +import { useTranslations } from "next-intl"; + +import { ProtectedRoute } from "@/components/auth/protected-route"; +import { FileManagerLayout } from "@/components/layout/file-manager-layout"; +import { LoadingScreen } from "@/components/layout/loading-screen"; +import { QuickAccessCards } from "./components/quick-access-cards"; +import { RecentFiles } from "./components/recent-files"; +import { RecentShares } from "./components/recent-shares"; +import { StorageUsage } from "./components/storage-usage"; +import { useDashboard } from "./hooks/use-dashboard"; +import { DashboardModals } from "./modals/dashboard-modals"; + +export default function DashboardPage() { + const t = useTranslations(); + + const { + isLoading, + diskSpace, + recentFiles, + recentShares, + modals, + fileManager, + shareManager, + handleCopyLink, + loadDashboardData, + } = useDashboard(); + + if (isLoading) { + return ; + } -function DashboardPage() { return ( -
-

Dashboard

- {/* Your dashboard content */} -
+ } + showBreadcrumb={false} + title={t("dashboard.pageTitle")} + > + + + +
+ + + +
+ + {/* */} +
); } - -export default DashboardPage; \ No newline at end of file diff --git a/apps/app/src/app/dashboard/types/index.ts b/apps/app/src/app/dashboard/types/index.ts new file mode 100644 index 0000000..1ea2682 --- /dev/null +++ b/apps/app/src/app/dashboard/types/index.ts @@ -0,0 +1,40 @@ +import { FileManagerHook } from "@/hooks/use-file-manager"; +import { ShareManagerHook } from "@/hooks/use-share-manager"; +import { ListUserShares200SharesItem } from "@/http/models/listUserShares200SharesItem"; + +export interface RecentFilesProps { + files: any[]; + fileManager: FileManagerHook; + isUploadModalOpen: boolean; + onOpenUploadModal: () => void; +} + +export interface RecentSharesProps { + shares: ListUserShares200SharesItem[]; + shareManager: ShareManagerHook; + isCreateModalOpen: boolean; + onOpenCreateModal: () => void; + onCopyLink: (share: ListUserShares200SharesItem) => void; +} + +export interface StorageUsageProps { + diskSpace: { + diskSizeGB: number; + diskUsedGB: number; + diskAvailableGB: number; + uploadAllowed: boolean; + } | null; +} + +export interface DashboardModalsProps { + modals: { + isUploadModalOpen: boolean; + isCreateModalOpen: boolean; + onCloseUploadModal: () => void; + onCloseCreateModal: () => void; + }; + fileManager: FileManagerHook; + shareManager: ShareManagerHook; + onSuccess: () => Promise; + smtpEnabled?: string; +} diff --git a/apps/app/src/app/dashboard/utils/format-storage-size.ts b/apps/app/src/app/dashboard/utils/format-storage-size.ts new file mode 100644 index 0000000..596b416 --- /dev/null +++ b/apps/app/src/app/dashboard/utils/format-storage-size.ts @@ -0,0 +1,21 @@ +export const formatStorageSize = (sizeInGB: number) => { + if (sizeInGB >= 1) { + return `${sizeInGB.toFixed(2)} GB`; + } + + const sizeInMB = sizeInGB * 1024; + + if (sizeInMB >= 1) { + return `${sizeInMB.toFixed(2)} MB`; + } + + const sizeInKB = sizeInMB * 1024; + + if (sizeInKB >= 1) { + return `${sizeInKB.toFixed(2)} KB`; + } + + const sizeInB = sizeInKB * 1024; + + return `${Math.round(sizeInB)} B`; +}; diff --git a/apps/app/src/app/globals.css b/apps/app/src/app/globals.css index 1b20c10..d20733f 100644 --- a/apps/app/src/app/globals.css +++ b/apps/app/src/app/globals.css @@ -79,13 +79,13 @@ } .dark { - --background: oklch(0.141 0.005 285.823); + --background: oklch(0 0 0); --foreground: oklch(0.985 0 0); --card: oklch(0.21 0.006 285.885); --card-foreground: oklch(0.985 0 0); --popover: oklch(0.21 0.006 285.885); --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.696 0.17 162.48); + --primary: oklch(0.723 0.219 149.579); --primary-foreground: oklch(0.393 0.095 152.535); --secondary: oklch(0.274 0.006 286.033); --secondary-foreground: oklch(0.985 0 0); @@ -94,17 +94,17 @@ --accent: oklch(0.274 0.006 286.033); --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); + --border: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%); --ring: oklch(0.527 0.154 150.069); --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); + --chart-2: oklch(0.723 0.219 149.579); --chart-3: oklch(0.769 0.188 70.08); --chart-4: oklch(0.627 0.265 303.9); --chart-5: oklch(0.645 0.246 16.439); --sidebar: oklch(0.21 0.006 285.885); --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.696 0.17 162.48); + --sidebar-primary: oklch(0.723 0.219 149.579); --sidebar-primary-foreground: oklch(0.393 0.095 152.535); --sidebar-accent: oklch(0.274 0.006 286.033); --sidebar-accent-foreground: oklch(0.985 0 0); @@ -120,3 +120,4 @@ @apply bg-background text-foreground; } } + diff --git a/apps/app/src/components/general/language-switcher.tsx b/apps/app/src/components/general/language-switcher.tsx index 8e29db0..2f7b7bd 100644 --- a/apps/app/src/components/general/language-switcher.tsx +++ b/apps/app/src/components/general/language-switcher.tsx @@ -1,3 +1,5 @@ +"use client" + import { useRouter } from "next/navigation"; import { IconLanguage } from "@tabler/icons-react"; import { useLocale } from "next-intl"; diff --git a/apps/app/src/components/mode-toggle.tsx b/apps/app/src/components/general/mode-toggle.tsx similarity index 100% rename from apps/app/src/components/mode-toggle.tsx rename to apps/app/src/components/general/mode-toggle.tsx diff --git a/apps/app/src/components/layout/file-manager-layout.tsx b/apps/app/src/components/layout/file-manager-layout.tsx new file mode 100644 index 0000000..f2571a9 --- /dev/null +++ b/apps/app/src/components/layout/file-manager-layout.tsx @@ -0,0 +1,65 @@ +import { Navbar } from "@/components/layout/navbar"; +import { DefaultFooter } from "@/components/ui/default-footer"; +import { Separator } from "@/components/ui/separator"; +import { IconLayoutDashboard } from "@tabler/icons-react"; +import { useTranslations } from "next-intl"; +import { ReactNode } from "react"; +import Link from "next/link"; + +interface FileManagerLayoutProps { + children: ReactNode; + title: string; + icon: ReactNode; + breadcrumbLabel?: string; + showBreadcrumb?: boolean; +} + +export function FileManagerLayout({ + children, + title, + icon, + breadcrumbLabel, + showBreadcrumb = true, +}: FileManagerLayoutProps) { + const t = useTranslations(); + + return ( +
+ +
+
+
+
+ {icon} +

{title}

+
+ + {showBreadcrumb && breadcrumbLabel && ( + + )} +
+ + {children} +
+
+ +
+ ); +} diff --git a/apps/app/src/components/layout/navbar.tsx b/apps/app/src/components/layout/navbar.tsx new file mode 100644 index 0000000..0ea18d5 --- /dev/null +++ b/apps/app/src/components/layout/navbar.tsx @@ -0,0 +1,106 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { + IconSettings, + IconLogout, + IconUser, + IconUsers, +} from "@tabler/icons-react"; +import { useTranslations } from "next-intl"; + +import { LanguageSwitcher } from "@/components/general/language-switcher"; +import { ModeToggle } from "@/components/general/mode-toggle"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useAppInfo } from "@/contexts/app-info-context"; +import { useAuth } from "@/contexts/auth-context"; +import { logout as logoutAPI } from "@/http/endpoints"; + +export function Navbar() { + const t = useTranslations(); + const router = useRouter(); + const { user, isAdmin, logout } = useAuth(); + const { appName, appLogo } = useAppInfo(); + + const handleLogout = async () => { + try { + await logoutAPI(); + logout(); + router.push("/login"); + } catch (err) { + console.error("Error logging out:", err); + } + }; + + return ( +
+
+
+
+ + {appLogo && {t("navbar.logoAlt")}} +

{appName}

+ +
+ +
+ + + + + + + {user?.firstName?.[0]} + + + +
+

+ {user?.firstName} {user?.lastName} +

+

{user?.email}

+
+ + + + {t("navbar.profile")} + + + {isAdmin && ( + <> + + + + {t("navbar.settings")} + + + + + + {t("navbar.usersManagement")} + + + + )} + + + {t("navbar.logout")} + +
+
+
+
+
+
+ ); +} diff --git a/apps/app/src/components/tables/files-table.tsx b/apps/app/src/components/tables/files-table.tsx new file mode 100644 index 0000000..d5c088b --- /dev/null +++ b/apps/app/src/components/tables/files-table.tsx @@ -0,0 +1,146 @@ +import { getFileIcon } from "@/utils/file-icons"; +import { formatFileSize } from "@/utils/format-file-size"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Table, + TableHeader, + TableRow, + TableHead, + TableBody, + TableCell, +} from "@/components/ui/table"; +import { useTranslations } from "next-intl"; +import { + IconEye, + IconEdit, + IconDownload, + IconTrash, + IconDotsVertical, +} from "@tabler/icons-react"; + +interface File { + id: string; + name: string; + description?: string; + size: number; + objectName: string; + createdAt: string; + updatedAt: string; +} + +interface FilesTableProps { + files: File[]; + onPreview: (file: File) => void; + onRename: (file: File) => void; + onDownload: (objectName: string, fileName: string) => void; + onDelete: (file: File) => void; +} + +export function FilesTable({ files, onPreview, onRename, onDownload, onDelete }: FilesTableProps) { + const t = useTranslations(); + + const formatDateTime = (dateString: string) => { + const date = new Date(dateString); + + return new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(date); + }; + + return ( +
+ + + + + {t("filesTable.columns.name")} + + + {t("filesTable.columns.description")} + + + {t("filesTable.columns.size")} + + + {t("filesTable.columns.createdAt")} + + + {t("filesTable.columns.updatedAt")} + + + {t("filesTable.columns.actions")} + + + + + {files.map((file) => { + const { icon: FileIcon, color } = getFileIcon(file.name); + + return ( + + +
+ + + {file.name} + +
+
+ {file.description || "-"} + {formatFileSize(file.size)} + {formatDateTime(file.createdAt)} + {formatDateTime(file.updatedAt || file.createdAt)} + + + + + + + onPreview(file)}> + + {t("filesTable.actions.preview")} + + onRename(file)}> + + {t("filesTable.actions.edit")} + + onDownload(file.objectName, file.name)}> + + {t("filesTable.actions.download")} + + onDelete(file)} + className="cursor-pointer py-2 text-destructive focus:text-destructive" + > + + {t("filesTable.actions.delete")} + + + + +
+ ); + })} +
+
+
+ ); +} diff --git a/apps/app/src/components/tables/shares-table.tsx b/apps/app/src/components/tables/shares-table.tsx new file mode 100644 index 0000000..89341a5 --- /dev/null +++ b/apps/app/src/components/tables/shares-table.tsx @@ -0,0 +1,192 @@ +import { useShareContext } from "../../contexts/share-context"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Table, + TableHeader, + TableRow, + TableHead, + TableBody, + TableCell, +} from "@/components/ui/table"; +import { format } from "date-fns"; +import { useTranslations } from "next-intl"; + +import { + IconEdit, + IconTrash, + IconUsers, + IconFolder, + IconDotsVertical, + IconLock, + IconLockOpen, + IconEye, + IconCopy, + IconLink, + IconMail, +} from "@tabler/icons-react"; + +export interface SharesTableProps { + shares: any[]; + onDelete: (share: any) => void; + onEdit: (share: any) => void; + onManageFiles: (share: any) => void; + onManageRecipients: (share: any) => void; + onViewDetails: (share: any) => void; + onGenerateLink: (share: any) => void; + onCopyLink: (share: any) => void; + onNotifyRecipients: (share: any) => void; +} + +export function SharesTable({ + shares, + onDelete, + onEdit, + onManageFiles, + onManageRecipients, + onViewDetails, + onGenerateLink, + onCopyLink, + onNotifyRecipients, +}: SharesTableProps) { + const t = useTranslations(); + const { smtpEnabled } = useShareContext(); + + return ( +
+ + + + + {t("sharesTable.columns.name")} + + + {t("sharesTable.columns.createdAt")} + + + {t("sharesTable.columns.expiresAt")} + + + {t("sharesTable.columns.status")} + + + {t("sharesTable.columns.security")} + + + {t("sharesTable.columns.files")} + + + {t("sharesTable.columns.recipients")} + + + {t("sharesTable.columns.actions")} + + + + + {shares.map((share) => ( + + {share.name} + {format(new Date(share.createdAt), "MM/dd/yyyy HH:mm")} + + {share.expiration ? format(new Date(share.expiration), "MM/dd/yyyy HH:mm") : t("sharesTable.never")} + + + new Date() + ? "bg-green-500/20 hover:bg-green-500/30 text-green-500" + : "bg-red-500/20 hover:bg-red-500/30 text-red-500" + }> + {!share.expiration + ? t("sharesTable.status.neverExpires") + : new Date(share.expiration) > new Date() + ? t("sharesTable.status.active") + : t("sharesTable.status.expired")} + + + + + {share.security.hasPassword ? ( + + ) : ( + + )} + {share.security.hasPassword + ? t("sharesTable.security.protected") + : t("sharesTable.security.public")} + + + { share.files?.length || 0 } {t("sharesTable.filesCount")} + { share.recipients?.length || 0 } {t("sharesTable.recipientsCount")} + + + + + + + onEdit(share)}> + + {t("sharesTable.actions.edit")} + + onManageFiles(share)}> + + {t("sharesTable.actions.manageFiles")} + + onManageRecipients(share)}> + + {t("sharesTable.actions.manageRecipients")} + + onViewDetails(share)}> + + {t("sharesTable.actions.viewDetails")} + + onGenerateLink(share)}> + + {share.alias ? t("sharesTable.actions.editLink") : t("sharesTable.actions.generateLink")} + + {share.alias && ( + onCopyLink(share)}> + + {t("sharesTable.actions.copyLink")} + + )} + {share.recipients?.length > 0 && share.alias && smtpEnabled === "true" && ( + onNotifyRecipients(share)}> + + {t("sharesTable.actions.notifyRecipients")} + + )} + onDelete(share)} + className="cursor-pointer py-2 text-destructive focus:text-destructive" + > + + {t("sharesTable.actions.delete")} + + + + + + ))} + +
+
+ ); +} diff --git a/apps/app/src/components/ui/avatar.tsx b/apps/app/src/components/ui/avatar.tsx new file mode 100644 index 0000000..71e428b --- /dev/null +++ b/apps/app/src/components/ui/avatar.tsx @@ -0,0 +1,53 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/apps/app/src/components/ui/badge.tsx b/apps/app/src/components/ui/badge.tsx new file mode 100644 index 0000000..0205413 --- /dev/null +++ b/apps/app/src/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/apps/app/src/components/ui/card.tsx b/apps/app/src/components/ui/card.tsx new file mode 100644 index 0000000..960f5ce --- /dev/null +++ b/apps/app/src/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/apps/app/src/components/ui/progress.tsx b/apps/app/src/components/ui/progress.tsx new file mode 100644 index 0000000..e7a416c --- /dev/null +++ b/apps/app/src/components/ui/progress.tsx @@ -0,0 +1,31 @@ +"use client" + +import * as React from "react" +import * as ProgressPrimitive from "@radix-ui/react-progress" + +import { cn } from "@/lib/utils" + +function Progress({ + className, + value, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { Progress } diff --git a/apps/app/src/components/ui/separator.tsx b/apps/app/src/components/ui/separator.tsx new file mode 100644 index 0000000..67c73e5 --- /dev/null +++ b/apps/app/src/components/ui/separator.tsx @@ -0,0 +1,28 @@ +"use client" + +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@/lib/utils" + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Separator } diff --git a/apps/app/src/components/ui/table.tsx b/apps/app/src/components/ui/table.tsx new file mode 100644 index 0000000..51b74dd --- /dev/null +++ b/apps/app/src/components/ui/table.tsx @@ -0,0 +1,116 @@ +"use client" + +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Table({ className, ...props }: React.ComponentProps<"table">) { + return ( +
+ + + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ( + + ) +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + + ) +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", + className + )} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( +
[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ) +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + [role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ) +} + +function TableCaption({ + className, + ...props +}: React.ComponentProps<"caption">) { + return ( +
+ ) +} + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/apps/app/src/contexts/share-context.tsx b/apps/app/src/contexts/share-context.tsx new file mode 100644 index 0000000..af9650f --- /dev/null +++ b/apps/app/src/contexts/share-context.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { getAllConfigs } from "@/http/endpoints"; +import { createContext, useContext, useState, useEffect } from "react"; + +interface ShareContextType { + smtpEnabled: string; + refreshShareContext: () => Promise; +} + +const ShareContext = createContext({ + smtpEnabled: "false", + refreshShareContext: async () => {}, +}); + +export function ShareProvider({ children }: { children: React.ReactNode }) { + const [smtpEnabled, setSmtpEnabled] = useState("false"); + + const loadConfigs = async () => { + try { + const response = await getAllConfigs(); + const smtpConfig = response.data.configs.find((config: any) => config.key === "smtpEnabled"); + + setSmtpEnabled(smtpConfig?.value || "false"); + } catch (error) { + console.error("Failed to load SMTP config:", error); + } + }; + + const refreshShareContext = async () => { + await loadConfigs(); + }; + + useEffect(() => { + loadConfigs(); + }, []); + + return {children}; +} + +export const useShareContext = () => useContext(ShareContext); diff --git a/apps/app/src/hooks/use-file-manager.ts b/apps/app/src/hooks/use-file-manager.ts new file mode 100644 index 0000000..a716808 --- /dev/null +++ b/apps/app/src/hooks/use-file-manager.ts @@ -0,0 +1,101 @@ +import { getDownloadUrl, updateFile, deleteFile } from "@/http/endpoints"; +import { useState } from "react"; +import { toast } from "sonner"; + +interface FileToRename { + id: string; + name: string; + description?: string; +} + +interface FileToDelete { + id: string; + name: string; +} + +interface PreviewFile { + name: string; + objectName: string; +} + +export interface FileManagerHook { + previewFile: PreviewFile | null; + fileToDelete: any; + fileToRename: any; + setFileToDelete: (file: any) => void; + setFileToRename: (file: any) => void; + setPreviewFile: (file: PreviewFile | null) => void; + handleDelete: (fileId: string) => Promise; + handleDownload: (objectName: string, fileName: string) => Promise; + handleRename: (fileId: string, newName: string, description?: string) => Promise; +} + +export function useFileManager(onRefresh: () => Promise) { + const [previewFile, setPreviewFile] = useState(null); + const [fileToRename, setFileToRename] = useState(null); + const [fileToDelete, setFileToDelete] = useState(null); + + const handleDownload = async (objectName: string, fileName: string) => { + try { + const encodedObjectName = encodeURIComponent(objectName); + const response = await getDownloadUrl(encodedObjectName); + const downloadUrl = response.data.url; + + const fileResponse = await fetch(downloadUrl); + const blob = await fileResponse.blob(); + const url = window.URL.createObjectURL(blob); + + const link = document.createElement("a"); + + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + } catch (error) { + console.error(error); + toast.error("Failed to download file"); + } + }; + + const handleRename = async (fileId: string, newName: string, description?: string) => { + try { + await updateFile(fileId, { + name: newName, + description: description || null, + }); + await onRefresh(); + toast.success("File updated successfully"); + setFileToRename(null); + } catch (error) { + console.error("Failed to update file:", error); + toast.error("Failed to update file"); + } + }; + + const handleDelete = async (fileId: string) => { + try { + await deleteFile(fileId); + await onRefresh(); + toast.success("File deleted successfully"); + setFileToDelete(null); + } catch (error) { + console.error("Failed to delete file:", error); + toast.error("Failed to delete file"); + } + }; + + return { + previewFile, + setPreviewFile, + fileToRename, + setFileToRename, + fileToDelete, + setFileToDelete, + handleDownload, + handleRename, + handleDelete, + }; +} diff --git a/apps/app/src/hooks/use-share-manager.ts b/apps/app/src/hooks/use-share-manager.ts new file mode 100644 index 0000000..e38af42 --- /dev/null +++ b/apps/app/src/hooks/use-share-manager.ts @@ -0,0 +1,135 @@ +import { deleteShare, notifyRecipients, updateShare } from "@/http/endpoints"; +import { addFiles, addRecipients } from "@/http/endpoints"; +import { createShareAlias } from "@/http/endpoints"; +import { ListUserShares200SharesItem } from "@/http/models/listUserShares200SharesItem"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; + +import { toast } from "sonner"; + +export interface ShareManagerHook { + shareToDelete: ListUserShares200SharesItem | null; + shareToEdit: ListUserShares200SharesItem | null; + shareToManageFiles: ListUserShares200SharesItem | null; + shareToManageRecipients: ListUserShares200SharesItem | null; + shareToViewDetails: ListUserShares200SharesItem | null; + shareToGenerateLink: ListUserShares200SharesItem | null; + setShareToDelete: (share: ListUserShares200SharesItem | null) => void; + setShareToEdit: (share: ListUserShares200SharesItem | null) => void; + setShareToManageFiles: (share: ListUserShares200SharesItem | null) => void; + setShareToManageRecipients: (share: ListUserShares200SharesItem | null) => void; + setShareToViewDetails: (share: ListUserShares200SharesItem | null) => void; + setShareToGenerateLink: (share: ListUserShares200SharesItem | null) => void; + handleDelete: (shareId: string) => Promise; + handleEdit: (shareId: string, data: any) => Promise; + handleManageFiles: (shareId: string, files: any[]) => Promise; + handleManageRecipients: (shareId: string, recipients: any[]) => Promise; + handleGenerateLink: (shareId: string, alias: string) => Promise; + handleNotifyRecipients: (share: ListUserShares200SharesItem) => Promise; +} + +export function useShareManager(onSuccess: () => void) { + const t = useTranslations(); + const [shareToDelete, setShareToDelete] = useState(null); + const [shareToEdit, setShareToEdit] = useState(null); + const [shareToManageFiles, setShareToManageFiles] = useState(null); + const [shareToManageRecipients, setShareToManageRecipients] = useState(null); + const [shareToViewDetails, setShareToViewDetails] = useState(null); + const [shareToGenerateLink, setShareToGenerateLink] = useState(null); + + const handleDelete = async (shareId: string) => { + try { + await deleteShare(shareId); + toast.success(t("shareManager.deleteSuccess")); + onSuccess(); + setShareToDelete(null); + } catch (error) { + toast.error(t("shareManager.deleteError")); + console.error(error); + } + }; + + const handleEdit = async (shareId: string, data: any) => { + try { + await updateShare({ id: shareId, ...data }); + toast.success(t("shareManager.updateSuccess")); + onSuccess(); + setShareToEdit(null); + } catch (error) { + toast.error(t("shareManager.updateError")); + console.error(error); + } + }; + + const handleManageFiles = async (shareId: string, files: string[]) => { + try { + await addFiles(shareId, { files }); + toast.success(t("shareManager.filesUpdateSuccess")); + onSuccess(); + setShareToManageFiles(null); + } catch (error) { + toast.error(t("shareManager.filesUpdateError")); + console.error(error); + } + }; + + const handleManageRecipients = async (shareId: string, recipients: string[]) => { + try { + await addRecipients(shareId, { emails: recipients }); + toast.success(t("shareManager.recipientsUpdateSuccess")); + onSuccess(); + setShareToManageRecipients(null); + } catch (error) { + toast.error(t("shareManager.recipientsUpdateError")); + console.error(error); + } + }; + + const handleGenerateLink = async (shareId: string, alias: string) => { + try { + await createShareAlias(shareId, { alias }); + toast.success(t("shareManager.linkGenerateSuccess")); + onSuccess(); + } catch (error) { + console.error(error); + toast.error(t("shareManager.linkGenerateError")); + throw error; + } + }; + + const handleNotifyRecipients = async (share: ListUserShares200SharesItem) => { + const link = `${window.location.origin}/s/${share.alias?.alias}`; + const loadingToast = toast.loading(t("shareManager.notifyLoading")); + + try { + await notifyRecipients(share.id, { shareLink: link }); + toast.dismiss(loadingToast); + toast.success(t("shareManager.notifySuccess")); + } catch (error) { + console.error(error); + toast.dismiss(loadingToast); + toast.error(t("shareManager.notifyError")); + } + }; + + return { + shareToDelete, + shareToEdit, + shareToManageFiles, + shareToManageRecipients, + shareToViewDetails, + shareToGenerateLink, + setShareToDelete, + setShareToEdit, + setShareToManageFiles, + setShareToManageRecipients, + setShareToViewDetails, + setShareToGenerateLink, + handleDelete, + handleEdit, + handleManageFiles, + handleManageRecipients, + handleGenerateLink, + handleNotifyRecipients, + }; +} diff --git a/apps/app/src/utils/file-icons.tsx b/apps/app/src/utils/file-icons.tsx new file mode 100644 index 0000000..c46ee7e --- /dev/null +++ b/apps/app/src/utils/file-icons.tsx @@ -0,0 +1,81 @@ +import { Icon } from "@tabler/icons-react"; +import { + IconFile, + IconPhoto, + IconFileTypePdf, + IconFileText, + IconFileSpreadsheet, + IconPresentation, + IconFileMusic, + IconVideo, + IconFileZip, + IconFileCode, + IconFileDescription, +} from "@tabler/icons-react"; + +interface FileIconMapping { + extensions: string[]; + icon: Icon; + color: string; +} + +const fileIcons: FileIconMapping[] = [ + { + extensions: ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"], + icon: IconPhoto, + color: "text-blue-500", + }, + { + extensions: ["pdf"], + icon: IconFileTypePdf, + color: "text-red-500", + }, + { + extensions: ["doc", "docx"], + icon: IconFileText, + color: "text-blue-600", + }, + { + extensions: ["xls", "xlsx", "csv"], + icon: IconFileSpreadsheet, + color: "text-green-600", + }, + { + extensions: ["ppt", "pptx"], + icon: IconPresentation, + color: "text-orange-500", + }, + { + extensions: ["mp3", "wav", "ogg", "m4a"], + icon: IconFileMusic, + color: "text-purple-500", + }, + { + extensions: ["mp4", "avi", "mov", "wmv", "mkv"], + icon: IconVideo, + color: "text-pink-500", + }, + { + extensions: ["zip", "rar", "7z", "tar", "gz"], + icon: IconFileZip, + color: "text-yellow-600", + }, + { + extensions: ["html", "css", "js", "ts", "jsx", "tsx", "json", "xml"], + icon: IconFileCode, + color: "text-gray-600", + }, + { + extensions: ["txt", "md", "rtf"], + icon: IconFileDescription, + color: "text-gray-500", + }, +]; + +export function getFileIcon(filename: string): { icon: Icon; color: string } { + const extension = filename.split(".").pop()?.toLowerCase() || ""; + + const mapping = fileIcons.find((type) => type.extensions.includes(extension)); + + return mapping || { icon: IconFile, color: "text-gray-400" }; +} diff --git a/apps/app/src/utils/format-file-size.ts b/apps/app/src/utils/format-file-size.ts new file mode 100644 index 0000000..396a462 --- /dev/null +++ b/apps/app/src/utils/format-file-size.ts @@ -0,0 +1,13 @@ +export function formatFileSize(bytes: number): string { + const KB = 1024; + const MB = KB * 1024; + const GB = MB * 1024; + + if (bytes < MB) { + return `${(bytes / KB).toFixed(2)} KB`; + } else if (bytes < GB) { + return `${(bytes / MB).toFixed(2)} MB`; + } else { + return `${(bytes / GB).toFixed(2)} GB`; + } +}