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
This commit is contained in:
Daniel Luiz Alves
2025-04-09 15:48:00 -03:00
parent b077154c22
commit 61cf88b41f
44 changed files with 2047 additions and 62 deletions

View File

@@ -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": "فشل في تحميل بيانات لوحة التحكم",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "डैशबोर्ड डेटा लोड करने में त्रुटि",

View File

@@ -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": "ダッシュボードデータの読み込みに失敗しました",

View File

@@ -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": "대시보드 데이터를 불러오는데 실패했습니다",

View File

@@ -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",

View File

@@ -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": "Ошибка загрузки данных панели управления",

View File

@@ -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",

View File

@@ -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": "加载仪表盘数据失败",

View File

@@ -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",

199
apps/app/pnpm-lock.yaml generated
View File

@@ -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: {}

View File

@@ -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";

View File

@@ -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 (
<div className="text-center py-6 flex flex-col items-center gap-2">
<IconFolderOpen className="h-10 w-10 text-gray-500" />
<p className="text-gray-500">{t("recentFiles.noFiles")}</p>
<Button variant="secondary" size="sm" onClick={onUpload}>
<IconCloudUpload className="mr-2 h-4 w-4" />
{t("recentFiles.uploadFile")}
</Button>
</div>
);
}

View File

@@ -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 (
<div className="flex flex-col items-center justify-center py-8 gap-4">
<IconShare className="h-10 w-10 text-gray-500" />
<div className="text-center">
<p className="text-gray-500 mb-4">{t("recentShares.noShares")}</p>
<Button variant="outline" size="sm" onClick={onCreate}>
<IconPlus className="h-4 w-4" />
{t("recentShares.createFirst")}
</Button>
</div>
</div>
);
}

View File

@@ -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: <IconFoldersFilled size={28} />,
description: t("quickAccess.files.description"),
path: "/files",
color: "bg-primary",
},
{
title: t("quickAccess.shares.title"),
icon: <IconShare2 size={28} />,
description: t("quickAccess.shares.description"),
path: "/shares",
color: "bg-orange-400",
},
] as const;
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{QUICK_ACCESS_ITEMS.map((card) => (
<Card
key={card.title}
className="cursor-pointer transform transition-all hover:scale-102"
onClick={() => router.push(card.path)}
>
<CardContent>
<div className="flex flex-col gap-4">
<div className={`${card.color} w-12 h-12 rounded-lg flex items-center justify-center text-white shadow-sm`}>
{card.icon}
</div>
<div>
<h3 className="text-lg font-semibold">{card.title}</h3>
<p className="text-sm text-gray-500">{card.description}</p>
</div>
</div>
</CardContent>
</Card>
))}
</div>
);
}

View File

@@ -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 (
<Card>
<CardContent>
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold flex items-center gap-2">
<IconCloudUpload className="text-xl text-gray-500" />
{t("recentFiles.title")}
</h2>
{files.length >= 5 ? (
<Button
className="font-semibold text-sm cursor-pointer"
variant="outline"
size="default"
onClick={() => router.push("/files")}
>
<IconFolderOpen className="h-4 w-4" />
{t("recentFiles.viewAll")}
</Button>
) : files.length === 0 ? null : (
<Button
className="font-semibold text-sm cursor-pointer"
variant="outline"
size="default"
onClick={onOpenUploadModal}
>
<IconCloudUpload className="h-4 w-4" />
{t("recentFiles.uploadFile")}
</Button>
)}
</div>
{files.length > 0 ? (
<FilesTable
files={files}
onDelete={fileManager.setFileToDelete}
onDownload={fileManager.handleDownload}
onPreview={fileManager.setPreviewFile}
onRename={fileManager.setFileToRename}
/>
) : (
<EmptyFilesState onUpload={onOpenUploadModal} />
)}
</CardContent>
</Card>
);
}

View File

@@ -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 (
<Card>
<CardContent>
<div className="flex flex-col gap-6">
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold flex items-center gap-2">
<IconShare className="text-xl text-gray-500" />
{t("recentShares.title")}
</h2>
{shares.length >= 5 ? (
<Button
className="font-semibold text-sm cursor-pointer"
variant="outline"
size="default"
onClick={() => router.push("/shares")}
>
<IconShare className="h-4 w-4" />
{t("recentShares.viewAll")}
</Button>
) : shares.length === 0 ? null : (
<Button
className="font-semibold text-sm cursor-pointer"
variant="outline"
size="default"
onClick={onOpenCreateModal}
>
<IconPlus className="h-4 w-4" />
{t("recentShares.createShare")}
</Button>
)}
</div>
{shares.length > 0 ? (
<SharesTable
shares={shares}
onCopyLink={onCopyLink}
onDelete={shareManager.setShareToDelete}
onEdit={shareManager.setShareToEdit}
onGenerateLink={shareManager.setShareToGenerateLink}
onManageFiles={shareManager.setShareToManageFiles}
onManageRecipients={shareManager.setShareToManageRecipients}
onNotifyRecipients={shareManager.handleNotifyRecipients}
onViewDetails={shareManager.setShareToViewDetails}
/>
) : (
<EmptySharesState onCreate={onOpenCreateModal} />
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -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 (
<Card className="w-full">
<CardContent className="">
<div className="flex flex-col gap-4">
<h2 className="text-xl font-semibold flex items-center gap-2">
<IconDatabaseCog className="text-gray-500" size={24}/>
{t("storageUsage.title")}
</h2>
<div className="flex flex-col gap-2">
<Progress
aria-label={t("storageUsage.ariaLabel")}
value={((diskSpace?.diskUsedGB || 0) / (diskSpace?.diskSizeGB || 1)) * 100}
className="w-full h-3"
/>
<div className="flex justify-between text-sm">
<span> {formatStorageSize(diskSpace?.diskUsedGB || 0)} {t("storageUsage.used")}</span>
<span> {formatStorageSize(diskSpace?.diskAvailableGB || 0)} {t("storageUsage.available")}</span>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -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<any[]>([]);
const [recentShares, setRecentShares] = useState<any[]>([]);
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,
};
}

View File

@@ -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 (
<>
<UploadFileModal isOpen={modals.isUploadModalOpen} onClose={modals.onCloseUploadModal} onSuccess={onSuccess} />
<FilePreviewModal
file={fileManager.previewFile || { name: "", objectName: "" }}
isOpen={!!fileManager.previewFile}
onClose={() => fileManager.setPreviewFile(null)}
/>
<FileActionsModals
fileToDelete={fileManager.fileToDelete}
fileToRename={fileManager.fileToRename}
onCloseDelete={() => fileManager.setFileToDelete(null)}
onCloseRename={() => fileManager.setFileToRename(null)}
onDelete={fileManager.handleDelete}
onRename={fileManager.handleRename}
/>
<CreateShareModal isOpen={modals.isCreateModalOpen} onClose={modals.onCloseCreateModal} onSuccess={onSuccess} />
<ShareActionsModals
shareToDelete={shareManager.shareToDelete}
shareToEdit={shareManager.shareToEdit}
shareToManageFiles={shareManager.shareToManageFiles}
shareToManageRecipients={shareManager.shareToManageRecipients}
onCloseDelete={() => 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}
/>
<ShareDetailsModal
shareId={shareManager.shareToViewDetails?.id || null}
onClose={() => shareManager.setShareToViewDetails(null)}
/>
<GenerateShareLinkModal
share={shareManager.shareToGenerateLink || null}
shareId={shareManager.shareToGenerateLink?.id || null}
onClose={() => shareManager.setShareToGenerateLink(null)}
onGenerate={shareManager.handleGenerateLink}
onSuccess={onSuccess}
/>
</>
);
}

View File

@@ -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 <LoadingScreen />;
}
function DashboardPage() {
return (
<ProtectedRoute>
<div>
<h1>Dashboard</h1>
{/* Your dashboard content */}
</div>
<FileManagerLayout
breadcrumbLabel={t("dashboard.breadcrumb")}
icon={<IconLayoutDashboardFilled className="text-xl" />}
showBreadcrumb={false}
title={t("dashboard.pageTitle")}
>
<StorageUsage diskSpace={diskSpace} />
<QuickAccessCards />
<div className="flex flex-col gap-6">
<RecentFiles
fileManager={fileManager}
files={recentFiles}
isUploadModalOpen={modals.isUploadModalOpen}
onOpenUploadModal={modals.onOpenUploadModal}
/>
<RecentShares
isCreateModalOpen={modals.isCreateModalOpen}
shareManager={shareManager}
shares={recentShares}
onCopyLink={handleCopyLink}
onOpenCreateModal={modals.onOpenCreateModal}
/>
</div>
{/* <DashboardModals
fileManager={fileManager}
modals={modals}
shareManager={shareManager}
onSuccess={loadDashboardData}
/> */}
</FileManagerLayout>
</ProtectedRoute>
);
}
export default DashboardPage;

View File

@@ -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<void>;
smtpEnabled?: string;
}

View File

@@ -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`;
};

View File

@@ -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;
}
}

View File

@@ -1,3 +1,5 @@
"use client"
import { useRouter } from "next/navigation";
import { IconLanguage } from "@tabler/icons-react";
import { useLocale } from "next-intl";

View File

@@ -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 (
<div className="w-full min-h-screen flex flex-col">
<Navbar />
<div className="flex-1 max-w-7xl mx-auto w-full p-6 py-8">
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4">
<div className="flex flex-row items-center gap-2">
{icon}
<h1 className="text-2xl font-bold">{title}</h1>
</div>
<Separator />
{showBreadcrumb && breadcrumbLabel && (
<nav className="flex" aria-label="Breadcrumb">
<ol className="flex items-center gap-2">
<li className="flex items-center">
<Link
href="/dashboard"
className="flex items-center text-muted-foreground hover:text-foreground transition-colors"
>
<IconLayoutDashboard className="h-4 w-4 mr-1" />
{t("navigation.dashboard")}
</Link>
</li>
<li className="flex items-center before:content-['/'] before:mx-2 before:text-muted-foreground">
<span className="flex items-center">
{icon} {breadcrumbLabel}
</span>
</li>
</ol>
</nav>
)}
</div>
{children}
</div>
</div>
<DefaultFooter />
</div>
);
}

View File

@@ -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 (
<header className="sticky top-0 z-40 w-full border-b border-border/50 bg-background/70 backdrop-blur-sm px-6">
<div className="container flex h-16 max-w-screen-xl items-center mx-auto lg:px-6">
<div className="flex flex-1 items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard" className="flex items-center gap-2 cursor-pointer">
{appLogo && <img alt={t("navbar.logoAlt")} className="h-8 w-8 object-contain" src={appLogo} />}
<p className="font-bold text-2xl">{appName}</p>
</Link>
</div>
<div className="flex items-center gap-2 cursor-pointer">
<LanguageSwitcher />
<ModeToggle />
<DropdownMenu>
<DropdownMenuTrigger className="rounded-full">
<Avatar className="cursor-pointer h-10 w-10 rounded-full" >
<AvatarImage src={user?.image as string | undefined} />
<AvatarFallback>{user?.firstName?.[0]}</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<div className="flex flex-col px-2 py-1.5 gap-0.5">
<p className="font-semibold text-sm">
{user?.firstName} {user?.lastName}
</p>
<p className="font-semibold text-xs text-muted-foreground">{user?.email}</p>
</div>
<DropdownMenuItem asChild>
<Link href="/profile" className="flex items-center gap-2 cursor-pointer">
<IconUser className="h-4 w-4" />
{t("navbar.profile")}
</Link>
</DropdownMenuItem>
{isAdmin && (
<>
<DropdownMenuItem asChild>
<Link href="/settings" className="flex items-center gap-2 cursor-pointer">
<IconSettings className="h-4 w-4" />
{t("navbar.settings")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/admin" className="flex items-center gap-2 cursor-pointer">
<IconUsers className="h-4 w-4" />
{t("navbar.usersManagement")}
</Link>
</DropdownMenuItem>
</>
)}
<DropdownMenuItem
className="text-destructive focus:text-destructive cursor-pointer"
onClick={handleLogout}
>
<IconLogout className="h-4 w-4 mr-2 text-destructive" />
{t("navbar.logout")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</header>
);
}

View File

@@ -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 (
<div className="rounded-lg shadow-sm overflow-hidden border">
<Table>
<TableHeader>
<TableRow className="border-b-0">
<TableHead className="h-10 text-xs font-bold text-muted-foreground bg-muted/50 rounded-tl-lg">
{t("filesTable.columns.name")}
</TableHead>
<TableHead className="h-10 text-xs font-bold text-muted-foreground bg-muted/50">
{t("filesTable.columns.description")}
</TableHead>
<TableHead className="h-10 text-xs font-bold text-muted-foreground bg-muted/50">
{t("filesTable.columns.size")}
</TableHead>
<TableHead className="h-10 text-xs font-bold text-muted-foreground bg-muted/50">
{t("filesTable.columns.createdAt")}
</TableHead>
<TableHead className="h-10 text-xs font-bold text-muted-foreground bg-muted/50">
{t("filesTable.columns.updatedAt")}
</TableHead>
<TableHead className="h-10 w-[70px] text-xs font-bold text-muted-foreground bg-muted/50 rounded-tr-lg">
{t("filesTable.columns.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody >
{files.map((file) => {
const { icon: FileIcon, color } = getFileIcon(file.name);
return (
<TableRow key={file.id} className="hover:bg-muted/50 transition-colors border-0">
<TableCell className="h-12 px-4 border-0">
<div className="flex items-center gap-2">
<FileIcon className={`h-5 w-5 ${color}`} />
<span className="truncate max-w-[250px] font-medium" title={file.name}>
{file.name}
</span>
</div>
</TableCell>
<TableCell className="h-12 px-4">{file.description || "-"}</TableCell>
<TableCell className="h-12 px-4">{formatFileSize(file.size)}</TableCell>
<TableCell className="h-12 px-4">{formatDateTime(file.createdAt)}</TableCell>
<TableCell className="h-12 px-4">{formatDateTime(file.updatedAt || file.createdAt)}</TableCell>
<TableCell className="h-12 px-4 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-muted"
>
<IconDotsVertical className="h-4 w-4" />
<span className="sr-only">{t("filesTable.actions.menu")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuItem className="cursor-pointer py-2" onClick={() => onPreview(file)}>
<IconEye className="mr-2 h-4 w-4" />
{t("filesTable.actions.preview")}
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer py-2" onClick={() => onRename(file)}>
<IconEdit className="mr-2 h-4 w-4" />
{t("filesTable.actions.edit")}
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer py-2" onClick={() => onDownload(file.objectName, file.name)}>
<IconDownload className="mr-2 h-4 w-4" />
{t("filesTable.actions.download")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onDelete(file)}
className="cursor-pointer py-2 text-destructive focus:text-destructive"
>
<IconTrash className="mr-2 h-4 w-4" />
{t("filesTable.actions.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}

View File

@@ -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 (
<div className="rounded-lg shadow-sm overflow-hidden border">
<Table>
<TableHeader>
<TableRow className="border-b-0">
<TableHead className="h-10 text-xs font-bold text-muted-foreground bg-muted/50">
{t("sharesTable.columns.name")}
</TableHead>
<TableHead className="h-10 text-xs font-bold text-muted-foreground bg-muted/50">
{t("sharesTable.columns.createdAt")}
</TableHead>
<TableHead className="h-10 text-xs font-bold text-muted-foreground bg-muted/50">
{t("sharesTable.columns.expiresAt")}
</TableHead>
<TableHead className="h-10 text-xs font-bold text-muted-foreground bg-muted/50">
{t("sharesTable.columns.status")}
</TableHead>
<TableHead className="h-10 text-xs font-bold text-muted-foreground bg-muted/50">
{t("sharesTable.columns.security")}
</TableHead>
<TableHead className="h-10 text-xs font-bold text-muted-foreground bg-muted/50">
{t("sharesTable.columns.files")}
</TableHead>
<TableHead className="h-10 text-xs font-bold text-muted-foreground bg-muted/50">
{t("sharesTable.columns.recipients")}
</TableHead>
<TableHead className="h-10 w-[70px] text-xs font-bold text-muted-foreground bg-muted/50">
{t("sharesTable.columns.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{shares.map((share) => (
<TableRow key={share.id} className="hover:bg-muted/50 transition-colors border-0">
<TableCell className="h-12 border-0">{share.name}</TableCell>
<TableCell className="h-12 ">{format(new Date(share.createdAt), "MM/dd/yyyy HH:mm")}</TableCell>
<TableCell className="h-12 ">
{share.expiration ? format(new Date(share.expiration), "MM/dd/yyyy HH:mm") : t("sharesTable.never")}
</TableCell>
<TableCell className="h-12 ">
<Badge variant="secondary" className={
!share.expiration || new Date(share.expiration) > 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")}
</Badge>
</TableCell>
<TableCell className="h-12 ">
<Badge variant="secondary" className={`flex items-center gap-1 ${
share.security.hasPassword
? "bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-500"
: "bg-green-500/20 hover:bg-green-500/30 text-green-500"
}`}>
{share.security.hasPassword ? (
<IconLock className="h-4 w-4" />
) : (
<IconLockOpen className="h-4 w-4" />
)}
{share.security.hasPassword
? t("sharesTable.security.protected")
: t("sharesTable.security.public")}
</Badge>
</TableCell>
<TableCell className="h-12">{ share.files?.length || 0 } {t("sharesTable.filesCount")}</TableCell>
<TableCell className="h-12">{ share.recipients?.length || 0 } {t("sharesTable.recipientsCount")}</TableCell>
<TableCell className="h-12 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-muted"
>
<IconDotsVertical className="h-4 w-4" />
<span className="sr-only">{t("sharesTable.actions.menu")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuItem className="cursor-pointer py-2" onClick={() => onEdit(share)}>
<IconEdit className="mr-2 h-4 w-4" />
{t("sharesTable.actions.edit")}
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer py-2" onClick={() => onManageFiles(share)}>
<IconFolder className="mr-2 h-4 w-4" />
{t("sharesTable.actions.manageFiles")}
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer py-2" onClick={() => onManageRecipients(share)}>
<IconUsers className="mr-2 h-4 w-4" />
{t("sharesTable.actions.manageRecipients")}
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer py-2" onClick={() => onViewDetails(share)}>
<IconEye className="mr-2 h-4 w-4" />
{t("sharesTable.actions.viewDetails")}
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer py-2" onClick={() => onGenerateLink(share)}>
<IconLink className="mr-2 h-4 w-4" />
{share.alias ? t("sharesTable.actions.editLink") : t("sharesTable.actions.generateLink")}
</DropdownMenuItem>
{share.alias && (
<DropdownMenuItem className="cursor-pointer py-2" onClick={() => onCopyLink(share)}>
<IconCopy className="mr-2 h-4 w-4" />
{t("sharesTable.actions.copyLink")}
</DropdownMenuItem>
)}
{share.recipients?.length > 0 && share.alias && smtpEnabled === "true" && (
<DropdownMenuItem className="cursor-pointer py-2" onClick={() => onNotifyRecipients(share)}>
<IconMail className="mr-2 h-4 w-4" />
{t("sharesTable.actions.notifyRecipients")}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => onDelete(share)}
className="cursor-pointer py-2 text-destructive focus:text-destructive"
>
<IconTrash className="mr-2 h-4 w-4" />
{t("sharesTable.actions.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

View File

@@ -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<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -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<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border dark:border-none shadow-lg py-6 dark:shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -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<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

View File

@@ -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<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator-root"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,41 @@
"use client";
import { getAllConfigs } from "@/http/endpoints";
import { createContext, useContext, useState, useEffect } from "react";
interface ShareContextType {
smtpEnabled: string;
refreshShareContext: () => Promise<void>;
}
const ShareContext = createContext<ShareContextType>({
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 <ShareContext.Provider value={{ smtpEnabled, refreshShareContext }}>{children}</ShareContext.Provider>;
}
export const useShareContext = () => useContext(ShareContext);

View File

@@ -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<void>;
handleDownload: (objectName: string, fileName: string) => Promise<void>;
handleRename: (fileId: string, newName: string, description?: string) => Promise<void>;
}
export function useFileManager(onRefresh: () => Promise<void>) {
const [previewFile, setPreviewFile] = useState<PreviewFile | null>(null);
const [fileToRename, setFileToRename] = useState<FileToRename | null>(null);
const [fileToDelete, setFileToDelete] = useState<FileToDelete | null>(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,
};
}

View File

@@ -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<void>;
handleEdit: (shareId: string, data: any) => Promise<void>;
handleManageFiles: (shareId: string, files: any[]) => Promise<void>;
handleManageRecipients: (shareId: string, recipients: any[]) => Promise<void>;
handleGenerateLink: (shareId: string, alias: string) => Promise<void>;
handleNotifyRecipients: (share: ListUserShares200SharesItem) => Promise<void>;
}
export function useShareManager(onSuccess: () => void) {
const t = useTranslations();
const [shareToDelete, setShareToDelete] = useState<ListUserShares200SharesItem | null>(null);
const [shareToEdit, setShareToEdit] = useState<ListUserShares200SharesItem | null>(null);
const [shareToManageFiles, setShareToManageFiles] = useState<ListUserShares200SharesItem | null>(null);
const [shareToManageRecipients, setShareToManageRecipients] = useState<ListUserShares200SharesItem | null>(null);
const [shareToViewDetails, setShareToViewDetails] = useState<ListUserShares200SharesItem | null>(null);
const [shareToGenerateLink, setShareToGenerateLink] = useState<ListUserShares200SharesItem | null>(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,
};
}

View File

@@ -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" };
}

View File

@@ -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`;
}
}