Compare commits

...

50 Commits

Author SHA1 Message Date
Daniel Luiz Alves
828fbd4cfd v3.0.0-beta.12 (#109) 2025-06-25 08:42:28 -03:00
Daniel Luiz Alves
ea68e771a8 feat: enhance file management features and localization (#108) 2025-06-24 14:05:16 -03:00
Daniel Luiz Alves
229f9a3ca0 feat: enhance file management features and localization
- Added bulk download functionality for files in the PublicSharePage, allowing users to download multiple files as a ZIP.
- Introduced new success and error messages for file deletion and password protection updates, improving user feedback.
- Updated translation files to include new keys for bulk download, file deletion, and password protection messages, enhancing localization support.
- Improved UI components to accommodate new features and ensure a seamless user experience.
2025-06-24 13:58:57 -03:00
Daniel Luiz Alves
dd10c17a3a feat: enhance SMTP configuration options in settings (#107) 2025-06-24 11:19:22 -03:00
Daniel Luiz Alves
ab071916b8 feat: enhance SMTP configuration options in settings
- Added new fields for smtpSecure and smtpNoAuth in the email settings, allowing users to specify the connection security method and disable authentication for internal servers.
- Updated the EmailService to handle the new configuration options, improving flexibility in SMTP setup.
- Enhanced the UI components to include the new fields, ensuring proper user interaction and validation.
- Updated translation files to include descriptions and titles for the new SMTP settings, improving localization support.
2025-06-24 11:15:44 -03:00
Daniel Luiz Alves
1ab0504288 chore: add pull request template for improved contribution guidelines
- Introduced a new pull request template to standardize submissions and enhance clarity for contributors.
- The template includes sections for description, related issues, motivation, AI usage, testing, and a checklist to ensure quality and completeness.
- Aims to streamline the review process and improve collaboration within the project.
2025-06-24 01:02:41 -03:00
Daniel Luiz Alves
652f1f47d2 feat: implement file name truncation in modals for better display
- Added a utility function to intelligently truncate file names while preserving extensions.
- Updated ReceivedFilesModal and DeleteConfirmationModal to use the new truncation function, improving the display of long file names.
- Enhanced UI components to ensure proper layout and readability of file names in both modals.
2025-06-24 00:33:54 -03:00
Daniel Luiz Alves
cd9598a6d3 v3.0.0-beta.11 (#104) 2025-06-23 21:38:38 -03:00
Daniel Luiz Alves
017a1debd3 feat: add SMTP connection testing functionality (#103) 2025-06-23 18:28:54 -03:00
Daniel Luiz Alves
b917dbc05f feat: add SMTP connection testing functionality
- Implemented a new endpoint for testing SMTP connections, allowing administrators to validate SMTP settings before saving.
- Added a method in the EmailService to handle SMTP connection testing, utilizing either provided or saved configurations.
- Introduced a new UI component for testing SMTP connections within the settings, enhancing user experience with real-time feedback.
- Updated translation files to include new keys and messages related to SMTP testing across multiple languages.
2025-06-23 18:26:51 -03:00
Daniel Luiz Alves
4fdee6b98b Feat: Add bulk actions for file management in received files modal (#102) 2025-06-23 17:14:42 -03:00
Daniel Luiz Alves
a669a6c048 fix: change Docker build command to load images instead of pushing
- Updated the Docker build command in build-docker.sh to use --load instead of --push, allowing for local image loading during the build process.
- This change facilitates testing and debugging of Docker images before deployment.
2025-06-23 17:13:59 -03:00
Daniel Luiz Alves
264521f1c4 refactor: update translation management system to enhance key management and remove auto-translation
- Revised the translation management system to focus on key management, automating synchronization and validation of internationalization files.
- Removed the auto-translation functionality, emphasizing the need for manual translation by native speakers or professionals.
- Updated documentation to reflect changes in the workflow and best practices for managing translations.
- Adjusted related scripts and commands in package.json to align with the new translation process.
2025-06-23 17:11:25 -03:00
Daniel Luiz Alves
b1cc9dbb21 feat: add bulk actions for file management in received files modal
- Implemented bulk selection functionality for files in the ReceivedFilesModal, allowing users to select multiple files for actions such as download, copy, and delete.
- Added UI elements for bulk actions, including a dropdown menu for selecting actions and confirmation dialogs for deletion.
- Enhanced user experience by providing feedback during bulk operations and clearing selections after successful actions.
- Localized new messages for bulk actions across multiple languages to ensure consistent user feedback.
2025-06-23 17:11:08 -03:00
Daniel Luiz Alves
719b7f0036 v3.0.0-beta.10 (#101) 2025-06-23 16:04:01 -03:00
Daniel Luiz Alves
68c565f265 Implement file copy functionality from reverse shares to user files (#100) 2025-06-23 15:59:11 -03:00
Daniel Luiz Alves
22c5a44af8 feat: enhance reverse share functionality with field requirements
- Introduced new field requirements for name and email in the ReverseShare model, allowing for configurations of "HIDDEN", "OPTIONAL", or "REQUIRED".
- Updated the Create and Update schemas to include these new fields, ensuring proper validation and handling in the UI.
- Enhanced the file upload section to conditionally require name and email based on the new settings, improving user experience.
- Localized new messages for field requirements across multiple languages, ensuring consistent user feedback.
- Added a script to clean up translation files, addressing issues with multiple prefixes in translation keys.
2025-06-23 15:53:38 -03:00
Daniel Luiz Alves
4e841b272c feat: implement translation management system and enhance localization support
- Added a new translation management system to automate synchronization, validation, and translation of internationalization files.
- Introduced scripts for running translation operations, including checking status, synchronizing keys, and auto-translating strings.
- Updated package.json with new translation-related commands for easier access.
- Enhanced localization files across multiple languages with new keys and improved translations.
- Integrated download functionality for share files in the UI, allowing users to download multiple files seamlessly.
- Refactored components to support new download features and improved user experience.
2025-06-23 12:11:52 -03:00
Daniel Luiz Alves
6af10c6f33 refactor: remove UploadFileModal from SharesModals component
- Eliminated the UploadFileModal from the SharesModals component to streamline the modal management.
- Adjusted the component structure to enhance clarity and maintainability.
2025-06-23 01:28:19 -03:00
Daniel Luiz Alves
978a1e5755 feat: implement file copy functionality from reverse shares to user files
- Added a new endpoint to copy files from reverse shares to a user's personal files, ensuring only the creator can perform this action.
- Implemented error handling for various scenarios, including file not found, unauthorized access, and storage limitations.
- Updated the UI to include a "Copy to my files" action, enhancing user experience and accessibility.
- Localized new messages for success and error states in both English and Portuguese.
- Refactored related components to support the new copy functionality, ensuring a seamless integration into the existing workflow.
2025-06-21 11:37:47 -03:00
Daniel Luiz Alves
c265b8e08d v3.0.0-beta.9 (#90) 2025-06-20 16:33:04 -03:00
Daniel Luiz Alves
d0173a0bf9 refactor: optimize file icon rendering in UploadFileModal
- Consolidated file icon logic by introducing a new renderFileIcon function that utilizes the getFileIcon utility for improved clarity and maintainability.
- Removed redundant icon imports and streamlined the icon rendering process based on file names, enhancing code efficiency.
2025-06-20 16:09:13 -03:00
Daniel Luiz Alves
0d346b75cc refactor: simplify FilePreviewModal by utilizing useFilePreview hook
- Replaced complex state management and effect hooks in FilePreviewModal with a custom useFilePreview hook for improved readability and maintainability.
- Integrated FilePreviewRenderer component to handle different file types and rendering logic, enhancing the modularity of the code.
- Updated file icon mappings in file-icons.tsx to include additional file types and improve visual representation in the UI.
2025-06-20 15:37:00 -03:00
Daniel Luiz Alves
0a65917cbf refactor: update FilePreviewModal to handle text file previews
- Renamed jsonContent state to textContent for clarity and updated related logic to support various text file types.
- Implemented a new loadTextPreview function to handle text and JSON file previews, ensuring proper formatting and error handling.
- Enhanced file type detection to include a broader range of text file extensions, improving the preview functionality for users.
2025-06-20 15:14:35 -03:00
Daniel Luiz Alves
f651f50180 feat: enhance file upload and preview functionality (#89) 2025-06-20 14:44:43 -03:00
Daniel Luiz Alves
1125665bb1 feat: enhance file upload and preview functionality
- Improved the uploadSmallFile method to handle various request body types (buffer, string, object, stream) more effectively.
- Added error handling for unsupported request body types.
- Implemented JSON file preview capability in FilePreviewModal, allowing users to view formatted JSON content.
- Updated localization files to include "retry" messages in multiple languages for better user experience during upload errors.
2025-06-20 14:43:27 -03:00
Daniel Luiz Alves
b65aac3044 refactor: update authentication logic to support email or username (#88) 2025-06-20 13:46:10 -03:00
Daniel Luiz Alves
a865aabed0 refactor: update authentication logic to support email or username
- Modified the login schema to accept either an email or username for user authentication.
- Updated the AuthService to find users by email or username.
- Adjusted localization files to include new labels and placeholders for email or username input across multiple languages.
- Refactored the login form component to reflect the changes in the schema and improve user experience.
2025-06-20 13:45:37 -03:00
Daniel Luiz Alves
561e8faf33 refactor: remove outdated comment in FilePreviewModal
- Eliminated a redundant comment regarding the direct link approach in the file download logic to enhance code clarity and maintainability.
2025-06-20 10:29:08 -03:00
Daniel Luiz Alves
6445b0ce3e refactor: streamline file download logic in FilePreviewModal
- Updated the file download process to use a direct link approach, eliminating unnecessary fetch and blob creation steps.
- Improved code clarity by simplifying the download mechanism while maintaining functionality.
2025-06-20 10:28:44 -03:00
Daniel Luiz Alves
90cd3333cb Improve disk space detection (#87) 2025-06-20 10:19:15 -03:00
Daniel Luiz Alves
2ca0db70c3 localization: add loading and error messages for storage usage in multiple languages
- Enhanced localization files for various languages by adding loading states and detailed error messages related to storage information retrieval.
- Updated translations for "available" and included new keys for "loading," "retry," and various error scenarios to improve user experience during storage operations.
2025-06-20 10:18:33 -03:00
Daniel Luiz Alves
28697fa270 refactor: clean up comments and improve readability in various modules
- Removed unnecessary comments from timeout configuration, OIDC routes, reverse share routes, and other modules to enhance code clarity.
- Streamlined the code by eliminating redundant comments that do not add value to the understanding of the logic.
- Improved overall maintainability by focusing on concise and meaningful code structure.
2025-06-20 10:10:06 -03:00
Daniel Luiz Alves
d739c1b213 refactor: replace ShareFilePreviewModal with FilePreviewModal in files table component
- Updated the files table component to use FilePreviewModal for file previews.
- Removed the ShareFilePreviewModal component as it is no longer needed.
2025-06-20 09:55:03 -03:00
Daniel Luiz Alves
25a0c39135 docs: added instructions for Zitadel (#85) 2025-06-20 09:51:12 -03:00
Daniel Luiz Alves
185fa4c191 fix: change Docker build command from --push to --load
- Updated the Docker build command in build-docker.sh to use --load instead of --push, allowing for local image loading without pushing to a registry.
2025-06-20 09:48:42 -03:00
Daniel Luiz Alves
9dfb034c2e enhance: improve disk space detection and error handling in storage module
- Refactored disk space retrieval logic to support multiple commands based on the operating system.
- Added detailed error handling for disk space detection failures, including specific messages for system configuration issues.
- Updated API responses to provide clearer error messages to the frontend.
- Enhanced the dashboard UI to display loading states and error messages related to disk space retrieval, with retry functionality.
- Improved type definitions to accommodate new error handling in the dashboard components.
2025-06-20 09:48:00 -03:00
ruohki
936a2b71c7 docs: added instructions for Zitadel 2025-06-20 13:10:55 +02:00
Daniel Luiz Alves
cd14c28be1 refactor: simplify Docker environment detection for file storage paths (#77) 2025-06-19 03:02:31 -03:00
Daniel Luiz Alves
3c084a6686 refactor: simplify Docker environment detection for file storage paths
- Replaced manual Docker detection logic with a utility constant for determining if the application is running in a container.
- Updated file storage paths in both server and filesystem storage provider to use the new constant for improved readability and maintainability.
2025-06-19 02:49:47 -03:00
Daniel Luiz Alves
6a1381684b refactor: replace FilePreviewModal with ShareFilePreviewModal (#76) 2025-06-19 02:01:07 -03:00
Daniel Luiz Alves
dc20770fe6 refactor: replace FilePreviewModal with ShareFilePreviewModal in files table component
- Updated the files table component to use ShareFilePreviewModal for file previews.
- Removed the unused import of FilePreviewModal and added the new import for ShareFilePreviewModal.
2025-06-19 01:46:50 -03:00
Daniel Luiz Alves
6e526f7f88 fix: update email transport secure (#75) 2025-06-19 00:51:27 -03:00
Daniel Luiz Alves
858852c8cd refactor: remove unused import from email service 2025-06-19 00:50:09 -03:00
Daniel Luiz Alves
363dedbb2c Update service.ts (#74) 2025-06-19 00:49:27 -03:00
TerrifiedBug
cd215c79b8 Update service.ts
Fix nodemailer secure flag for STARTTLS
2025-06-18 23:45:42 +01:00
Daniel Luiz Alves
98586efbcd v3.0.0-beta.5 (#72) 2025-06-18 18:31:09 -03:00
Daniel Luiz Alves
c724e644c7 fix: update notification endpoint and include request body in API call
- Changed the API endpoint for notifying recipients to include the shareId directly in the URL.
- Added the request body to the fetch call to ensure proper data is sent with the notification request.
- Set the Content-Type header to application/json for the request.
2025-06-18 18:14:20 -03:00
Daniel Luiz Alves
555ff18a87 feat: implement Docker compatibility for file storage paths (#71) 2025-06-18 18:06:51 -03:00
Daniel Luiz Alves
5100e1591b feat: implement Docker compatibility for file storage paths
- Added checks to determine if the application is running in a Docker environment.
- Updated file storage paths to use `/app/server` in Docker and the current working directory for local development.
- Ensured consistent directory creation for uploads and temporary chunks across different environments.
2025-06-18 18:05:46 -03:00
118 changed files with 7620 additions and 1442 deletions

56
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,56 @@
## **🎯 Please make sure you are opening this Pull Request against the `next` branch!**
## 📝 Description
Please provide a clear and concise description of the changes introduced by this pull request.
## 🔗 Related Issue(s)
If this PR fixes or relates to an issue, please link it here (e.g., `Closes #123`).
## 💡 Motivation and Context
Why is this change required? What problem does it solve?
## 🤖 Use of Artificial Intelligence (AI)
The use of AI tools is absolutely welcome and not an issue. For transparency and continuous improvement, please answer the following:
- Did you use any AI tools (such as GitHub Copilot, ChatGPT, etc.) to help develop this PR?
- [ ] No, this PR was developed without the assistance of AI tools.
- [ ] Yes, AI tools assisted in the development of this PR (please specify which ones and how they were used):
- Tool(s) used:
- Brief description of how AI contributed:
- Was this PR generated entirely by an AI tool (i.e., with minimal human intervention)?
- [ ] No
- [ ] Yes (please provide details):
## 🧪 How Has This Been Tested?
Please describe the tests that you ran to verify your changes. Include details about your test environment, and the test cases you ran.
## 📸 Screenshots (if appropriate)
Add any relevant screenshots to help explain your changes.
## 🔄 Types of Changes
Check the relevant option(s) below:
- [ ] 🐛 Bug fix (non-breaking change which fixes an issue)
- [ ] ✨ New feature (non-breaking change which adds functionality)
- [ ] ⚠️ Breaking change (fix or feature that would cause existing functionality to change)
- [ ] 📚 Documentation update
## ✅ Checklist
- [ ] My code follows the code style of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] I have added necessary documentation (if appropriate)
- [ ] I have rebased and/or merged on top of the latest `next` branch
---
🙏 Thank you for your contribution!

View File

@@ -22,6 +22,7 @@
"architecture",
"github-architecture",
"api",
"translation-management",
"contribute",
"open-an-issue",
"---Sponsor this project---",

View File

@@ -137,6 +137,22 @@ The setup process varies depending on your chosen identity provider. Here are ex
![Identity Provider Setup](/assets/v3/oidc/provider-setup.png)
### Zitadel
1. **Create New ProjectApp**: In your desired Zitadel project, create a new application
2. **Name and Type**: Give your application a name and choose **WEB** as the application type
3. **Authentication Method**: Choose Code
4. **Set Redirect URI**: Add your Palmr callback URL to valid redirect URIs
5. **Finish**: After reviewing the configuration create the application
6. **Copy the client ID and client Secrat**: Copy the client id paste it into the **Client ID** of your Palmr OIDC condiguration Form, repeat for the client secret and paste it into the **Client Secret** field
7. **Obtain your Provider URL**: In your Zitadel application go to **URLs** and copy the **Authorization Endpoint (remove the /authorize from that url)** e.g. https://auth.example.com/oauth/v2
**Configuration values:**
- **Issuer URL**: Depends on your Zitadel installation and project. Example: `https://auth.example.com/oauth/v2`
- **Scope**: `openid profile email`
![Zitadel Identity Provider Setup](/assets/v3/oidc/zitadel-provider-setup.png)
---
## Testing OIDC configuration

View File

@@ -0,0 +1,443 @@
---
title: Translation Management
icon: Languages
---
import { Tab, Tabs } from "fumadocs-ui/components/tabs";
import { Callout } from "fumadocs-ui/components/callout";
Palmr includes a comprehensive translation key management system that automates synchronization and validation of the application's internationalization files.
## Overview
The translation management system consists of Python scripts that help maintain consistency across all supported languages:
- **Synchronization**: Automatically add missing translation keys
- **Validation**: Check translation status and completeness
- **Key Management**: Manage translation keys structure and consistency
- **Reporting**: Generate detailed translation reports
## Quick Start
<Tabs items={['npm/pnpm', 'Python Direct']}>
<Tab value="npm/pnpm">
```bash
# Complete workflow (recommended)
pnpm run translations
# Check translation status
pnpm run translations:check
# Synchronize missing keys
pnpm run translations:sync
# Test workflow without changes (dry run)
pnpm run translations:dry-run
# Show help
pnpm run translations:help
```
</Tab>
<Tab value="Python Direct">
```bash
cd apps/web/scripts
# Complete workflow (recommended)
python3 run_translations.py all
# Individual commands
python3 run_translations.py check
python3 run_translations.py sync
python3 run_translations.py all --dry-run
python3 run_translations.py help
```
</Tab>
</Tabs>
## Available Scripts
### Main Commands (npm/pnpm)
| Command | Description |
| ------------------------------- | ----------------------------------------- |
| `pnpm run translations` | Complete workflow: sync + check |
| `pnpm run translations:check` | Check translation status and completeness |
| `pnpm run translations:sync` | Synchronize missing keys from en-US.json |
| `pnpm run translations:dry-run` | Test workflow without making changes |
| `pnpm run translations:help` | Show detailed help and examples |
## Workflow
### 1. Adding New Translation Keys
When you add new text to the application:
1. **Add to English**: Update `apps/web/messages/en-US.json` with your new keys
2. **Sync translations**: Run `pnpm run translations:sync` to add missing keys to all languages
3. **Manual translation**: Translate strings marked with `[TO_TRANSLATE]` manually
4. **Test in UI**: Verify translations work correctly in the application interface
<Callout type="important">
**Manual translation required**: All strings marked with `[TO_TRANSLATE]` must
be manually translated by native speakers or professional translators for
accuracy.
</Callout>
### 2. Checking Translation Status
<Callout>
Always run `pnpm run translations:check` before releases to ensure
completeness.
</Callout>
```bash
# Generate detailed translation report
pnpm run translations:check
```
The report shows:
- **Completeness percentage** for each language
- **Untranslated strings** marked with `[TO_TRANSLATE]`
- **Identical strings** that may need localization
- **Missing keys** compared to English reference
### 3. Manual Translation Process
For all translation strings:
1. **Find untranslated strings**: Look for `[TO_TRANSLATE] Original text` in language files
2. **Replace with translation**: Remove the prefix and add proper translation
3. **Validate**: Run `pnpm run translations:check` to verify completeness
## File Structure
```
apps/web/
├── messages/ # Translation files
│ ├── en-US.json # Reference language (English)
│ ├── pt-BR.json # Portuguese (Brazil)
│ ├── es-ES.json # Spanish
│ └── ... # Other languages
└── scripts/ # Management scripts
├── run_translations.py # Main wrapper
├── sync_translations.py # Synchronization
├── check_translations.py # Status checking
└── clean_translations.py # Cleanup utilities
```
## Prerequisites
- **Python 3.6 or higher** - Required for running the translation scripts
- **No external dependencies** - Scripts use only Python standard libraries
- **UTF-8 support** - Ensure your terminal supports UTF-8 for proper display of translations
## Script Details
### Main Wrapper (`run_translations.py`)
The main script provides a unified interface for all translation operations:
#### Available Commands
- `check` - Check translation status and generate reports
- `sync` - Synchronize missing keys from reference language
- `all` - Run complete workflow (sync + check)
- `help` - Show detailed help with examples
#### How it Works
1. Validates parameters and working directory
2. Calls appropriate individual scripts with passed parameters
3. Provides unified error handling and progress reporting
4. Supports all parameters from individual scripts
### Synchronization Script (`sync_translations.py`)
Maintains consistency across all language files:
#### Process
1. **Load reference**: Reads `en-US.json` as source of truth
2. **Scan languages**: Finds all `*.json` files in messages directory
3. **Compare keys**: Identifies missing keys in each language file
4. **Add missing keys**: Copies structure from reference with `[TO_TRANSLATE]` prefix
5. **Save updates**: Maintains JSON formatting and UTF-8 encoding
#### Key Features
- **Recursive key detection**: Handles nested JSON objects
- **Safe updates**: Preserves existing translations
- **Consistent formatting**: Maintains proper JSON structure
- **Progress reporting**: Shows detailed sync results
### Status Check Script (`check_translations.py`)
Provides comprehensive translation analysis:
#### Generated Reports
- **Completion percentage**: How much of each language is translated
- **Untranslated count**: Strings still marked with `[TO_TRANSLATE]`
- **Identical strings**: Text identical to English (may need localization)
- **Missing keys**: Keys present in reference but not in target language
#### Analysis Features
- **Visual indicators**: Icons show completion status (✅ 🟡 🔴)
- **Detailed breakdowns**: Per-language analysis with specific keys
- **Quality insights**: Identifies potential translation issues
- **Export friendly**: Output can be redirected to files for reports
## Advanced Usage
### Custom Parameters
You can pass additional parameters to the underlying Python scripts for more control:
#### Synchronization Parameters (`sync`)
```bash
# Sync without marking new keys as [TO_TRANSLATE]
python3 scripts/run_translations.py sync --no-mark-untranslated
# Use a different reference file (default: en-US.json)
python3 scripts/run_translations.py sync --reference pt-BR.json
# Specify custom messages directory
python3 scripts/run_translations.py sync --messages-dir /path/to/messages
# Dry run mode - see what would be changed
python3 scripts/run_translations.py sync --dry-run
```
#### Check Parameters (`check`)
```bash
# Use different reference file for comparison
python3 scripts/run_translations.py check --reference pt-BR.json
# Check translations in custom directory
python3 scripts/run_translations.py check --messages-dir /path/to/messages
```
### Parameter Reference
| Parameter | Commands | Description |
| ------------------------ | --------------- | --------------------------------------------- |
| `--dry-run` | `sync`, `all` | Preview changes without modifying files |
| `--messages-dir` | All | Custom directory containing translation files |
| `--reference` | `sync`, `check` | Reference file to use (default: en-US.json) |
| `--no-mark-untranslated` | `sync` | Don't add [TO_TRANSLATE] prefix to new keys |
### Dry Run Mode
Always test changes first:
```bash
# Test complete workflow (recommended)
pnpm run translations:dry-run
# Alternative: Direct Python commands
python3 scripts/run_translations.py all --dry-run
python3 scripts/run_translations.py sync --dry-run
```
### Common Use Cases
#### Scenario 1: Adding Keys Without Marking for Translation
```bash
# Sync new keys without auto-marking for translation
python3 scripts/run_translations.py sync --no-mark-untranslated
# Manually mark specific keys that need translation
# Edit files to add [TO_TRANSLATE] prefix where needed
```
#### Scenario 2: Custom Project Structure
```bash
# Work with translations in different directory
python3 scripts/run_translations.py all --messages-dir ../custom-translations
```
#### Scenario 3: Quality Assurance
```bash
# Use different language as reference for comparison
python3 scripts/run_translations.py check --reference pt-BR.json
# This helps identify inconsistencies when you have high-quality translations
```
## Translation Keys Format
Translation files use nested JSON structure:
```json
{
"common": {
"actions": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete"
},
"messages": {
"success": "Operation completed successfully",
"error": "An error occurred"
}
},
"dashboard": {
"title": "Dashboard",
"welcome": "Welcome to Palmr"
}
}
```
## Manual Translation
<Callout type="warning">
**Professional translation recommended**: For production applications,
consider using professional translation services or native speakers to ensure
high-quality, culturally appropriate translations.
</Callout>
The system marks strings that need translation with the `[TO_TRANSLATE]` prefix:
```json
{
"key": "[TO_TRANSLATE] Original English text"
}
```
After manual translation:
```json
{
"key": "Texto original em inglês"
}
```
### Translation Review Process
1. **Identify**: Use `pnpm run translations:check` to find untranslated strings
2. **Translate**: Replace `[TO_TRANSLATE]` strings with proper translations
3. **Review**: Check each translation for:
- **Context accuracy**: Does it make sense in the UI?
- **Technical terms**: Are they correctly translated?
- **Tone consistency**: Matches the application's voice?
- **Cultural appropriateness**: Suitable for target audience?
4. **Test**: Verify translations in the actual application interface
5. **Document**: Note any translation decisions for future reference
### Common Review Points
- **Button labels**: Ensure they fit within UI constraints
- **Error messages**: Must be clear and helpful to users
- **Navigation items**: Should be intuitive in target language
- **Technical terms**: Some may be better left in English
- **Placeholders**: Maintain formatting and variable names
## Development Guidelines
<Callout type="important">
**Primary Language**: Always use `en-US.json` as the parent language for
development. All new translation keys must be added to English first.
</Callout>
### Translation Workflow for Development
1. **English First**: Add all new text to `apps/web/messages/en-US.json`
2. **Sync keys**: Use scripts to generate key structure for other languages
3. **Manual translation**: All strings must be manually translated for accuracy
4. **Quality Check**: Run translation validation before merging PRs
### Why English as Parent Language?
- **Consistency**: Ensures all languages have the same keys structure
- **Reference**: English serves as the source of truth for meaning
- **Key management**: Scripts use English as source for key synchronization
- **Documentation**: Most technical documentation is in English
## Best Practices
### For Developers
1. **Always use English as reference**: Add new keys to `en-US.json` first - never add keys directly to other languages
2. **Use semantic key names**: `dashboard.welcome` instead of `text1`
3. **Test key sync**: Run `pnpm run translations:dry-run` before committing
4. **Coordinate with translators**: Ensure translation team is aware of new keys
5. **Maintain consistency**: Use existing patterns for similar UI elements
### For Translators
1. **Focus on [TO_TRANSLATE] strings**: These need immediate attention
2. **Check identical strings**: May need localization even if identical to English
3. **Use proper formatting**: Maintain HTML tags and placeholders
4. **Test in context**: Verify translations work in the actual UI
5. **Maintain glossary**: Keep consistent terminology across translations
## Troubleshooting
### Common Issues
**Python not found**: Ensure Python 3.6+ is installed and in PATH
**Permission errors**: Ensure write permissions to message files
**Encoding issues**: Ensure your terminal supports UTF-8
### Getting Help
- Run `pnpm run translations:help` for detailed command examples
- Review generated translation reports for specific issues
- Check the official documentation for complete reference
## Output Examples
### Synchronization Output
```
Loading reference file: en-US.json
Reference file contains 980 keys
Processing 14 translation files...
Processing: pt-BR.json
🔍 Found 12 missing keys
✅ Updated successfully (980/980 keys)
============================================================
SUMMARY
============================================================
✅ ar-SA.json - 980/980 keys
🔄 pt-BR.json - 980/980 keys (+12 added)
```
### Translation Status Report
```
📊 TRANSLATION REPORT
Reference: en-US.json (980 strings)
================================================================================
LANGUAGE COMPLETENESS STRINGS UNTRANSLATED POSSIBLE MATCHES
--------------------------------------------------------------------------------
✅ pt-BR 100.0% 980/980 0 (0.0%) 5
⚠️ fr-FR 100.0% 980/980 12 (2.5%) 3
🟡 de-DE 95.2% 962/980 0 (0.0%) 8
================================================================================
```
## Contributing
When contributing translations:
1. **Follow the workflow**: Use the provided scripts for consistency
2. **Test thoroughly**: Run complete checks before submitting
3. **Document changes**: Note any significant translation decisions
4. **Maintain quality**: Ensure manual translations are accurate and appropriate
The translation management system ensures consistency and makes it easy to maintain high-quality localization across all supported languages.

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

View File

@@ -161,18 +161,20 @@ model UserAuthProvider {
}
model ReverseShare {
id String @id @default(cuid())
name String?
description String?
expiration DateTime?
maxFiles Int?
maxFileSize BigInt?
allowedFileTypes String?
password String?
pageLayout PageLayout @default(DEFAULT)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
name String?
description String?
expiration DateTime?
maxFiles Int?
maxFileSize BigInt?
allowedFileTypes String?
password String?
pageLayout PageLayout @default(DEFAULT)
isActive Boolean @default(true)
nameFieldRequired FieldRequirement @default(OPTIONAL)
emailFieldRequired FieldRequirement @default(OPTIONAL)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
creatorId String
creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade)
@@ -212,6 +214,12 @@ model ReverseShareAlias {
@@map("reverse_share_aliases")
}
enum FieldRequirement {
HIDDEN
OPTIONAL
REQUIRED
}
enum PageLayout {
DEFAULT
WETRANSFER

View File

@@ -117,6 +117,18 @@ const defaultConfigs = [
type: "string",
group: "email",
},
{
key: "smtpSecure",
value: "auto",
type: "string",
group: "email",
},
{
key: "smtpNoAuth",
value: "false",
type: "boolean",
group: "email",
},
{
key: "passwordResetTokenExpiration",
value: "3600",

View File

@@ -6,40 +6,24 @@
*/
export const timeoutConfig = {
// Connection timeouts
connection: {
// How long to wait for initial connection (0 = disabled)
timeout: 0,
// Keep-alive timeout for long-running uploads/downloads
// 20 hours should be enough for most large file operations
keepAlive: 20 * 60 * 60 * 1000, // 20 hours in milliseconds
},
// Request timeouts
request: {
// Global request timeout (0 = disabled, let requests run indefinitely)
timeout: 0,
// Body parsing timeout for large files
bodyTimeout: 0, // Disabled for large files
},
// File operation timeouts
file: {
// Maximum time to wait for file upload (0 = no limit)
uploadTimeout: 0,
// Maximum time to wait for file download (0 = no limit)
downloadTimeout: 0,
// Streaming chunk timeout (time between chunks)
streamTimeout: 30 * 1000, // 30 seconds between chunks
},
// Token expiration (for filesystem storage)
token: {
// How long upload/download tokens remain valid
expiration: 60 * 60 * 1000, // 1 hour in milliseconds
},
};
@@ -52,7 +36,6 @@ export function getTimeoutForFileSize(fileSizeBytes: number) {
const fileSizeGB = fileSizeBytes / (1024 * 1024 * 1024);
if (fileSizeGB > 100) {
// For files larger than 100GB, extend token expiration
return {
...timeoutConfig,
token: {
@@ -62,7 +45,6 @@ export function getTimeoutForFileSize(fileSizeBytes: number) {
}
if (fileSizeGB > 10) {
// For files larger than 10GB, extend token expiration
return {
...timeoutConfig,
token: {
@@ -79,15 +61,12 @@ export function getTimeoutForFileSize(fileSizeBytes: number) {
* You can set these in your .env file to override defaults
*/
export const envTimeoutOverrides = {
// Override connection keep-alive if set in environment
keepAliveTimeout: process.env.KEEP_ALIVE_TIMEOUT
? parseInt(process.env.KEEP_ALIVE_TIMEOUT)
: timeoutConfig.connection.keepAlive,
// Override request timeout if set in environment
requestTimeout: process.env.REQUEST_TIMEOUT ? parseInt(process.env.REQUEST_TIMEOUT) : timeoutConfig.request.timeout,
// Override token expiration if set in environment
tokenExpiration: process.env.TOKEN_EXPIRATION
? parseInt(process.env.TOKEN_EXPIRATION)
: timeoutConfig.token.expiration,

View File

@@ -1,10 +1,25 @@
import { EmailService } from "../email/service";
import { LogoService } from "./logo.service";
import { AppService } from "./service";
import { FastifyReply, FastifyRequest } from "fastify";
import fs from "fs";
import path from "path";
const uploadsDir = path.join(process.cwd(), "uploads/logo");
const isDocker = (() => {
try {
require("fs").statSync("/.dockerenv");
return true;
} catch {
try {
return require("fs").readFileSync("/proc/self/cgroup", "utf8").includes("docker");
} catch {
return false;
}
}
})();
const baseDir = isDocker ? "/app/server" : process.cwd();
const uploadsDir = path.join(baseDir, "uploads/logo");
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
@@ -12,6 +27,7 @@ if (!fs.existsSync(uploadsDir)) {
export class AppController {
private appService = new AppService();
private logoService = new LogoService();
private emailService = new EmailService();
async getAppInfo(request: FastifyRequest, reply: FastifyReply) {
try {
@@ -56,6 +72,24 @@ export class AppController {
}
}
async testSmtpConnection(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
if (!(request as any).user?.isAdmin) {
return reply.status(403).send({ error: "Access restricted to administrators" });
}
const body = request.body as any;
const smtpConfig = body.smtpConfig || undefined;
const result = await this.emailService.testConnection(smtpConfig);
return reply.send(result);
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
async uploadLogo(request: FastifyRequest, reply: FastifyReply) {
try {
const file = await request.file();

View File

@@ -126,6 +126,59 @@ export async function appRoutes(app: FastifyInstance) {
appController.bulkUpdateConfigs.bind(appController)
);
app.post(
"/app/test-smtp",
{
preValidation: adminPreValidation,
schema: {
tags: ["App"],
operationId: "testSmtpConnection",
summary: "Test SMTP connection with provided or saved configuration",
description:
"Validates SMTP connectivity using either provided configuration parameters or the currently saved settings. This endpoint allows testing SMTP settings before saving them permanently. Requires admin privileges.",
body: z
.object({
smtpConfig: z
.object({
smtpEnabled: z.string().describe("Whether SMTP is enabled ('true' or 'false')"),
smtpHost: z.string().describe("SMTP server hostname or IP address (e.g., 'smtp.gmail.com')"),
smtpPort: z
.union([z.string(), z.number()])
.transform(String)
.describe("SMTP server port (typically 587 for TLS, 25 for non-secure)"),
smtpUser: z.string().describe("Username for SMTP authentication (e.g., email address)"),
smtpPass: z.string().describe("Password for SMTP authentication (for Gmail, use App Password)"),
smtpSecure: z
.string()
.optional()
.describe("Connection security method ('auto', 'ssl', 'tls', or 'none')"),
smtpNoAuth: z.string().optional().describe("Disable SMTP authentication ('true' or 'false')"),
})
.optional()
.describe("SMTP configuration to test. If not provided, uses currently saved configuration"),
})
.optional()
.describe("Request body containing SMTP configuration to test. Send empty body to test saved configuration"),
response: {
200: z.object({
success: z.boolean().describe("Whether the SMTP connection test was successful"),
message: z.string().describe("Descriptive message about the test result"),
}),
400: z.object({
error: z.string().describe("Error message describing what went wrong with the test"),
}),
401: z.object({
error: z.string().describe("Authentication error - invalid or missing JWT token"),
}),
403: z.object({
error: z.string().describe("Authorization error - user does not have admin privileges"),
}),
},
},
},
appController.testSmtpConnection.bind(appController)
);
app.post(
"/app/logo",
{

View File

@@ -9,7 +9,7 @@ export const createPasswordSchema = async () => {
};
export const LoginSchema = z.object({
email: z.string().email("Invalid email").describe("User email"),
emailOrUsername: z.string().min(1, "Email or username is required").describe("User email or username"),
password: z.string().min(6, "Password must be at least 6 characters").describe("User password"),
});
export type LoginInput = z.infer<typeof LoginSchema>;

View File

@@ -17,7 +17,7 @@ export async function authRoutes(app: FastifyInstance) {
const passwordSchema = await createPasswordSchema();
const loginSchema = z.object({
email: z.string().email("Invalid email").describe("User email"),
emailOrUsername: z.string().min(1, "Email or username is required").describe("User email or username"),
password: passwordSchema,
});

View File

@@ -13,7 +13,7 @@ export class AuthService {
private emailService = new EmailService();
async login(data: LoginInput) {
const user = await this.userRepository.findUserByEmail(data.email);
const user = await this.userRepository.findUserByEmailOrUsername(data.emailOrUsername);
if (!user) {
throw new Error("Invalid credentials");
}

View File

@@ -1,7 +1,16 @@
import { env } from "../../env";
import { ConfigService } from "../config/service";
import nodemailer from "nodemailer";
interface SmtpConfig {
smtpEnabled: string;
smtpHost: string;
smtpPort: string;
smtpUser: string;
smtpPass: string;
smtpSecure?: string;
smtpNoAuth?: string;
}
export class EmailService {
private configService = new ConfigService();
@@ -11,15 +20,118 @@ export class EmailService {
return null;
}
return nodemailer.createTransport({
const port = Number(await this.configService.getValue("smtpPort"));
const smtpSecure = (await this.configService.getValue("smtpSecure")) || "auto";
const smtpNoAuth = await this.configService.getValue("smtpNoAuth");
let secure = false;
let requireTLS = false;
if (smtpSecure === "ssl") {
secure = true;
} else if (smtpSecure === "tls") {
requireTLS = true;
} else if (smtpSecure === "none") {
secure = false;
requireTLS = false;
} else if (smtpSecure === "auto") {
if (port === 465) {
secure = true;
} else if (port === 587 || port === 25) {
requireTLS = true;
}
}
const transportConfig: any = {
host: await this.configService.getValue("smtpHost"),
port: Number(await this.configService.getValue("smtpPort")),
secure: env.SECURE_SITE === "true" ? true : false,
auth: {
port: port,
secure: secure,
requireTLS: requireTLS,
tls: {
rejectUnauthorized: false,
},
};
if (smtpNoAuth !== "true") {
transportConfig.auth = {
user: await this.configService.getValue("smtpUser"),
pass: await this.configService.getValue("smtpPass"),
};
}
return nodemailer.createTransport(transportConfig);
}
async testConnection(config?: SmtpConfig) {
let smtpConfig: SmtpConfig;
if (config) {
// Use provided configuration
smtpConfig = config;
} else {
// Fallback to saved configuration
smtpConfig = {
smtpEnabled: await this.configService.getValue("smtpEnabled"),
smtpHost: await this.configService.getValue("smtpHost"),
smtpPort: await this.configService.getValue("smtpPort"),
smtpUser: await this.configService.getValue("smtpUser"),
smtpPass: await this.configService.getValue("smtpPass"),
smtpSecure: (await this.configService.getValue("smtpSecure")) || "auto",
smtpNoAuth: await this.configService.getValue("smtpNoAuth"),
};
}
if (smtpConfig.smtpEnabled !== "true") {
throw new Error("SMTP is not enabled");
}
const port = Number(smtpConfig.smtpPort);
const smtpSecure = smtpConfig.smtpSecure || "auto";
const smtpNoAuth = smtpConfig.smtpNoAuth;
let secure = false;
let requireTLS = false;
if (smtpSecure === "ssl") {
secure = true;
} else if (smtpSecure === "tls") {
requireTLS = true;
} else if (smtpSecure === "none") {
secure = false;
requireTLS = false;
} else if (smtpSecure === "auto") {
if (port === 465) {
secure = true;
} else if (port === 587 || port === 25) {
requireTLS = true;
}
}
const transportConfig: any = {
host: smtpConfig.smtpHost,
port: port,
secure: secure,
requireTLS: requireTLS,
tls: {
rejectUnauthorized: false,
},
});
};
if (smtpNoAuth !== "true") {
transportConfig.auth = {
user: smtpConfig.smtpUser,
pass: smtpConfig.smtpPass,
};
}
const transporter = nodemailer.createTransport(transportConfig);
try {
await transporter.verify();
return { success: true, message: "SMTP connection successful" };
} catch (error: any) {
throw new Error(`SMTP connection failed: ${error.message}`);
}
}
async sendPasswordResetEmail(to: string, resetToken: string, origin: string) {

View File

@@ -98,43 +98,82 @@ export class FilesystemController {
} catch (error) {
try {
await fs.promises.unlink(tempPath);
} catch (error) {
console.error("Error deleting temp file:", error);
} catch (cleanupError) {
console.error("Error deleting temp file:", cleanupError);
}
throw error;
}
}
private async uploadSmallFile(request: FastifyRequest, provider: FilesystemStorageProvider, objectName: string) {
const stream = request.body as any;
const chunks: Buffer[] = [];
const body = request.body as any;
return new Promise<void>((resolve, reject) => {
stream.on("data", (chunk: Buffer) => {
chunks.push(chunk);
});
if (Buffer.isBuffer(body)) {
if (body.length === 0) {
throw new Error("No file data received");
}
await provider.uploadFile(objectName, body);
return;
}
stream.on("end", async () => {
try {
const buffer = Buffer.concat(chunks);
if (typeof body === "string") {
const buffer = Buffer.from(body, "utf8");
if (buffer.length === 0) {
throw new Error("No file data received");
}
await provider.uploadFile(objectName, buffer);
return;
}
if (buffer.length === 0) {
throw new Error("No file data received");
if (typeof body === "object" && body !== null && !body.on) {
const buffer = Buffer.from(JSON.stringify(body), "utf8");
if (buffer.length === 0) {
throw new Error("No file data received");
}
await provider.uploadFile(objectName, buffer);
return;
}
if (body && typeof body.on === "function") {
const chunks: Buffer[] = [];
return new Promise<void>((resolve, reject) => {
body.on("data", (chunk: Buffer) => {
chunks.push(chunk);
});
body.on("end", async () => {
try {
const buffer = Buffer.concat(chunks);
if (buffer.length === 0) {
throw new Error("No file data received");
}
await provider.uploadFile(objectName, buffer);
resolve();
} catch (error) {
console.error("Error uploading small file:", error);
reject(error);
}
});
await provider.uploadFile(objectName, buffer);
resolve();
} catch (error) {
console.error("Error uploading small file:", error);
body.on("error", (error: Error) => {
console.error("Error reading upload stream:", error);
reject(error);
}
});
});
}
stream.on("error", (error: Error) => {
console.error("Error reading upload stream:", error);
reject(error);
});
});
try {
const buffer = Buffer.from(body);
if (buffer.length === 0) {
throw new Error("No file data received");
}
await provider.uploadFile(objectName, buffer);
} catch (error) {
throw new Error(`Unsupported request body type: ${typeof body}. Expected stream, buffer, string, or object.`);
}
}
async download(request: FastifyRequest, reply: FastifyReply) {
@@ -151,18 +190,42 @@ export class FilesystemController {
const filePath = provider.getFilePath(tokenData.objectName);
const stats = await fs.promises.stat(filePath);
const isLargeFile = stats.size > 50 * 1024 * 1024;
const fileSize = stats.size;
const isLargeFile = fileSize > 50 * 1024 * 1024;
const fileName = tokenData.fileName || "download";
const range = request.headers.range;
reply.header("Content-Disposition", this.encodeFilenameForHeader(fileName));
reply.header("Content-Type", "application/octet-stream");
reply.header("Content-Length", stats.size);
reply.header("Accept-Ranges", "bytes");
if (isLargeFile) {
await this.downloadLargeFile(reply, provider, filePath);
if (range) {
const parts = range.replace(/bytes=/, "").split("-");
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
const chunkSize = end - start + 1;
reply.status(206);
reply.header("Content-Range", `bytes ${start}-${end}/${fileSize}`);
reply.header("Content-Length", chunkSize);
if (isLargeFile) {
await this.downloadLargeFileRange(reply, provider, tokenData.objectName, start, end);
} else {
const buffer = await provider.downloadFile(tokenData.objectName);
const chunk = buffer.slice(start, end + 1);
reply.send(chunk);
}
} else {
const buffer = await provider.downloadFile(tokenData.objectName);
reply.send(buffer);
reply.header("Content-Length", fileSize);
if (isLargeFile) {
await this.downloadLargeFile(reply, provider, filePath);
} else {
const buffer = await provider.downloadFile(tokenData.objectName);
reply.send(buffer);
}
}
provider.consumeDownloadToken(token);
@@ -183,4 +246,16 @@ export class FilesystemController {
throw error;
}
}
private async downloadLargeFileRange(
reply: FastifyReply,
provider: FilesystemStorageProvider,
objectName: string,
start: number,
end: number
) {
const buffer = await provider.downloadFile(objectName);
const chunk = buffer.slice(start, end + 1);
reply.send(chunk);
}
}

View File

@@ -9,6 +9,10 @@ export async function filesystemRoutes(app: FastifyInstance) {
return payload;
});
app.addContentTypeParser("application/json", async (request: FastifyRequest, payload: any) => {
return payload;
});
app.put(
"/filesystem/upload/:token",
{

View File

@@ -6,7 +6,6 @@ import { z } from "zod";
export async function oidcRoutes(fastify: FastifyInstance) {
const oidcController = new OIDCController();
// Get OIDC configuration
fastify.get(
"/config",
{
@@ -27,7 +26,6 @@ export async function oidcRoutes(fastify: FastifyInstance) {
oidcController.getConfig.bind(oidcController)
);
// Initiate OIDC authorization
fastify.get(
"/authorize",
{
@@ -54,7 +52,6 @@ export async function oidcRoutes(fastify: FastifyInstance) {
oidcController.authorize.bind(oidcController)
);
// Handle OIDC callback
fastify.get(
"/callback",
{

View File

@@ -449,4 +449,31 @@ export class ReverseShareController {
return reply.status(500).send({ error: "Internal server error" });
}
}
async copyFileToUserFiles(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
const { fileId } = request.params as { fileId: string };
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized" });
}
const file = await this.reverseShareService.copyReverseShareFileToUserFiles(fileId, userId);
return reply.send({ file, message: "File copied to your files successfully" });
} catch (error: any) {
if (error.message === "File not found") {
return reply.status(404).send({ error: "File not found" });
}
if (error.message === "Unauthorized to copy this file") {
return reply.status(403).send({ error: "Unauthorized to copy this file" });
}
if (error.message.includes("File size exceeds") || error.message.includes("Insufficient storage")) {
return reply.status(400).send({ error: error.message });
}
console.error("Error in copyFileToUserFiles:", error);
return reply.status(500).send({ error: "Internal server error" });
}
}
}

View File

@@ -1,5 +1,7 @@
import { z } from "zod";
export const FieldRequirementSchema = z.enum(["HIDDEN", "OPTIONAL", "REQUIRED"]);
export const CreateReverseShareSchema = z.object({
name: z.string().optional().describe("The reverse share name"),
description: z.string().optional().describe("The reverse share description"),
@@ -14,6 +16,8 @@ export const CreateReverseShareSchema = z.object({
allowedFileTypes: z.string().nullable().optional().describe("Comma-separated list of allowed file extensions"),
password: z.string().optional().describe("Password for private access"),
pageLayout: z.enum(["WETRANSFER", "DEFAULT"]).default("DEFAULT").describe("Page layout type"),
nameFieldRequired: FieldRequirementSchema.default("OPTIONAL").describe("Name field requirement setting"),
emailFieldRequired: FieldRequirementSchema.default("OPTIONAL").describe("Email field requirement setting"),
});
export const UpdateReverseShareSchema = z.object({
@@ -27,6 +31,8 @@ export const UpdateReverseShareSchema = z.object({
password: z.string().nullable().optional(),
pageLayout: z.enum(["WETRANSFER", "DEFAULT"]).optional(),
isActive: z.boolean().optional(),
nameFieldRequired: FieldRequirementSchema.optional().describe("Name field requirement setting"),
emailFieldRequired: FieldRequirementSchema.optional().describe("Email field requirement setting"),
});
export const ReverseShareFileSchema = z.object({
@@ -53,6 +59,8 @@ export const ReverseShareResponseSchema = z.object({
pageLayout: z.string().describe("Page layout type"),
isActive: z.boolean().describe("Whether the reverse share is active"),
hasPassword: z.boolean().describe("Whether the reverse share has a password"),
nameFieldRequired: z.string().describe("Name field requirement setting"),
emailFieldRequired: z.string().describe("Email field requirement setting"),
createdAt: z.string().describe("The reverse share creation date"),
updatedAt: z.string().describe("The reverse share update date"),
creatorId: z.string().describe("The creator ID"),
@@ -80,6 +88,8 @@ export const ReverseSharePublicSchema = z.object({
pageLayout: z.string().describe("Page layout type"),
hasPassword: z.boolean().describe("Whether the reverse share has a password"),
currentFileCount: z.number().describe("Current number of files uploaded"),
nameFieldRequired: z.string().describe("Name field requirement setting"),
emailFieldRequired: z.string().describe("Email field requirement setting"),
});
export const UploadToReverseShareSchema = z.object({

View File

@@ -26,7 +26,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
}
};
// Create reverse share (authenticated)
app.post(
"/reverse-shares",
{
@@ -50,7 +49,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.createReverseShare.bind(reverseShareController)
);
// List user's reverse shares (authenticated)
app.get(
"/reverse-shares",
{
@@ -72,7 +70,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.listUserReverseShares.bind(reverseShareController)
);
// Get reverse share by ID (authenticated)
app.get(
"/reverse-shares/:id",
{
@@ -98,7 +95,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.getReverseShare.bind(reverseShareController)
);
// Update reverse share (authenticated)
app.put(
"/reverse-shares",
{
@@ -123,7 +119,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.updateReverseShare.bind(reverseShareController)
);
// Update reverse share password (authenticated)
app.put(
"/reverse-shares/:id/password",
{
@@ -151,7 +146,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.updatePassword.bind(reverseShareController)
);
// Delete reverse share (authenticated)
app.delete(
"/reverse-shares/:id",
{
@@ -177,7 +171,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.deleteReverseShare.bind(reverseShareController)
);
// Get reverse share for upload (public)
app.get(
"/reverse-shares/:id/upload",
{
@@ -207,7 +200,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.getReverseShareForUpload.bind(reverseShareController)
);
// Get reverse share for upload by alias (public)
app.get(
"/reverse-shares/alias/:alias/upload",
{
@@ -237,7 +229,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.getReverseShareForUploadByAlias.bind(reverseShareController)
);
// Get presigned URL for file upload (public)
app.post(
"/reverse-shares/:id/presigned-url",
{
@@ -269,7 +260,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.getPresignedUrl.bind(reverseShareController)
);
// Get presigned URL for file upload by alias (public)
app.post(
"/reverse-shares/alias/:alias/presigned-url",
{
@@ -301,7 +291,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.getPresignedUrlByAlias.bind(reverseShareController)
);
// Register file upload completion (public)
app.post(
"/reverse-shares/:id/register-file",
{
@@ -333,7 +322,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.registerFileUpload.bind(reverseShareController)
);
// Register file upload completion by alias (public)
app.post(
"/reverse-shares/alias/:alias/register-file",
{
@@ -365,7 +353,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.registerFileUploadByAlias.bind(reverseShareController)
);
// Check password (public)
app.post(
"/reverse-shares/:id/check-password",
{
@@ -394,11 +381,11 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.checkPassword.bind(reverseShareController)
);
// Download file from reverse share (authenticated)
app.get(
"/reverse-shares/files/:fileId/download",
{
preValidation,
bodyLimit: 1024 * 1024 * 1024 * 1024 * 1024, // 1PB limit for large video files
schema: {
tags: ["Reverse Share"],
operationId: "downloadReverseShareFile",
@@ -421,7 +408,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.downloadFile.bind(reverseShareController)
);
// Delete file from reverse share (authenticated)
app.delete(
"/reverse-shares/files/:fileId",
{
@@ -447,7 +433,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.deleteFile.bind(reverseShareController)
);
// Create or update reverse share alias (authenticated)
app.post(
"/reverse-shares/:reverseShareId/alias",
{
@@ -486,7 +471,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.createOrUpdateAlias.bind(reverseShareController)
);
// Activate reverse share (authenticated)
app.patch(
"/reverse-shares/:id/activate",
{
@@ -512,7 +496,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.activateReverseShare.bind(reverseShareController)
);
// Deactivate reverse share (authenticated)
app.patch(
"/reverse-shares/:id/deactivate",
{
@@ -538,7 +521,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.deactivateReverseShare.bind(reverseShareController)
);
// Update file from reverse share (authenticated)
app.put(
"/reverse-shares/files/:fileId",
{
@@ -565,4 +547,42 @@ export async function reverseShareRoutes(app: FastifyInstance) {
},
reverseShareController.updateFile.bind(reverseShareController)
);
app.post(
"/reverse-shares/files/:fileId/copy",
{
preValidation,
schema: {
tags: ["Reverse Share"],
operationId: "copyReverseShareFileToUserFiles",
summary: "Copy File from Reverse Share to User Files",
description:
"Copy a file from a reverse share to the user's personal files. Only the creator of the reverse share can copy files. The file will be duplicated in storage and added to the user's file collection.",
params: z.object({
fileId: z.string().describe("Unique identifier of the file to copy"),
}),
response: {
200: z.object({
file: z.object({
id: z.string(),
name: z.string(),
description: z.string().nullable(),
extension: z.string(),
size: z.string(),
objectName: z.string(),
userId: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
}),
message: z.string(),
}),
400: z.object({ error: z.string() }),
401: z.object({ error: z.string() }),
403: z.object({ error: z.string() }),
404: z.object({ error: z.string() }),
},
},
},
reverseShareController.copyFileToUserFiles.bind(reverseShareController)
);
}

View File

@@ -19,6 +19,8 @@ interface ReverseShareData {
password: string | null;
pageLayout: string;
isActive: boolean;
nameFieldRequired: string;
emailFieldRequired: string;
createdAt: Date;
updatedAt: Date;
creatorId: string;
@@ -102,6 +104,8 @@ export class ReverseShareService {
pageLayout: reverseShare.pageLayout,
hasPassword: !!reverseShare.password,
currentFileCount,
nameFieldRequired: reverseShare.nameFieldRequired,
emailFieldRequired: reverseShare.emailFieldRequired,
};
}
@@ -141,6 +145,8 @@ export class ReverseShareService {
pageLayout: reverseShare.pageLayout,
hasPassword: !!reverseShare.password,
currentFileCount,
nameFieldRequired: reverseShare.nameFieldRequired,
emailFieldRequired: reverseShare.emailFieldRequired,
};
}
@@ -168,7 +174,6 @@ export class ReverseShareService {
throw new Error("Unauthorized to delete this reverse share");
}
// Delete all files associated with this reverse share
for (const file of reverseShare.files) {
try {
await this.fileService.deleteObject(file.objectName);
@@ -265,7 +270,6 @@ export class ReverseShareService {
}
}
// Check file count limit
if (reverseShare.maxFiles) {
const currentFileCount = await this.reverseShareRepository.countFilesByReverseShareId(reverseShareId);
if (currentFileCount >= reverseShare.maxFiles) {
@@ -273,12 +277,10 @@ export class ReverseShareService {
}
}
// Check file size limit
if (reverseShare.maxFileSize && BigInt(fileData.size) > reverseShare.maxFileSize) {
throw new Error("File size exceeds limit");
}
// Check allowed file types
if (reverseShare.allowedFileTypes) {
const allowedTypes = reverseShare.allowedFileTypes.split(",").map((type) => type.trim().toLowerCase());
if (!allowedTypes.includes(fileData.extension.toLowerCase())) {
@@ -318,7 +320,6 @@ export class ReverseShareService {
}
}
// Check file count limit
if (reverseShare.maxFiles) {
const currentFileCount = await this.reverseShareRepository.countFilesByReverseShareId(reverseShare.id);
if (currentFileCount >= reverseShare.maxFiles) {
@@ -326,12 +327,10 @@ export class ReverseShareService {
}
}
// Check file size limit
if (reverseShare.maxFileSize && BigInt(fileData.size) > reverseShare.maxFileSize) {
throw new Error("File size exceeds limit");
}
// Check allowed file types
if (reverseShare.allowedFileTypes) {
const allowedTypes = reverseShare.allowedFileTypes.split(",").map((type) => type.trim().toLowerCase());
if (!allowedTypes.includes(fileData.extension.toLowerCase())) {
@@ -357,8 +356,9 @@ export class ReverseShareService {
throw new Error("Unauthorized to download this file");
}
const fileName = file.name;
const expires = 3600; // 1 hour
const url = await this.fileService.getPresignedGetUrl(file.objectName, expires);
const url = await this.fileService.getPresignedGetUrl(file.objectName, expires, fileName);
return { url, expiresIn: expires };
}
@@ -372,10 +372,8 @@ export class ReverseShareService {
throw new Error("Unauthorized to delete this file");
}
// Delete from storage
await this.fileService.deleteObject(file.objectName);
// Delete from database
const deletedFile = await this.reverseShareRepository.deleteFile(fileId);
return this.formatFileResponse(deletedFile);
}
@@ -473,7 +471,6 @@ export class ReverseShareService {
data: { name?: string; description?: string | null },
creatorId: string
) {
// Verificar se o arquivo existe e se o usuário tem permissão
const file = await this.reverseShareRepository.findFileById(fileId);
if (!file) {
throw new Error("File not found");
@@ -483,13 +480,10 @@ export class ReverseShareService {
throw new Error("Unauthorized to edit this file");
}
// Se o nome está sendo atualizado, preservar a extensão original
const updateData = { ...data };
if (data.name) {
const originalExtension = file.extension;
// Remove qualquer extensão que o usuário possa ter digitado
const nameWithoutExtension = data.name.replace(/\.[^/.]+$/, "");
// Adiciona a extensão original (garantindo que tenha o ponto)
const extensionWithDot = originalExtension.startsWith(".") ? originalExtension : `.${originalExtension}`;
updateData.name = `${nameWithoutExtension}${extensionWithDot}`;
}
@@ -498,6 +492,96 @@ export class ReverseShareService {
return this.formatFileResponse(updatedFile);
}
async copyReverseShareFileToUserFiles(fileId: string, creatorId: string) {
const file = await this.reverseShareRepository.findFileById(fileId);
if (!file) {
throw new Error("File not found");
}
if (file.reverseShare.creatorId !== creatorId) {
throw new Error("Unauthorized to copy this file");
}
const { prisma } = await import("../../shared/prisma.js");
const { ConfigService } = await import("../config/service.js");
const configService = new ConfigService();
const maxFileSize = BigInt(await configService.getValue("maxFileSize"));
if (file.size > maxFileSize) {
const maxSizeMB = Number(maxFileSize) / (1024 * 1024);
throw new Error(`File size exceeds the maximum allowed size of ${maxSizeMB}MB`);
}
const maxTotalStorage = BigInt(await configService.getValue("maxTotalStoragePerUser"));
const userFiles = await prisma.file.findMany({
where: { userId: creatorId },
select: { size: true },
});
const currentStorage = userFiles.reduce((acc: bigint, userFile: any) => acc + userFile.size, BigInt(0));
if (currentStorage + file.size > maxTotalStorage) {
const availableSpace = Number(maxTotalStorage - currentStorage) / (1024 * 1024);
throw new Error(`Insufficient storage space. You have ${availableSpace.toFixed(2)}MB available`);
}
const newObjectName = `${creatorId}/${Date.now()}-${file.name}`;
if (this.fileService.isFilesystemMode()) {
const { FilesystemStorageProvider } = await import("../../providers/filesystem-storage.provider.js");
const provider = FilesystemStorageProvider.getInstance();
const sourceBuffer = await provider.downloadFile(file.objectName);
await provider.uploadFile(newObjectName, sourceBuffer);
} else {
const downloadUrl = await this.fileService.getPresignedGetUrl(file.objectName, 300);
const uploadUrl = await this.fileService.getPresignedPutUrl(newObjectName, 300);
const response = await fetch(downloadUrl);
if (!response.ok) {
throw new Error(`Failed to download file: ${response.statusText}`);
}
const fileBuffer = Buffer.from(await response.arrayBuffer());
const uploadResponse = await fetch(uploadUrl, {
method: "PUT",
body: fileBuffer,
headers: {
"Content-Type": "application/octet-stream",
},
});
if (!uploadResponse.ok) {
throw new Error(`Failed to upload file: ${uploadResponse.statusText}`);
}
}
const newFileRecord = await prisma.file.create({
data: {
name: file.name,
description: file.description || `Copied from: ${file.reverseShare.name || "Unnamed"}`,
extension: file.extension,
size: file.size,
objectName: newObjectName,
userId: creatorId,
},
});
return {
id: newFileRecord.id,
name: newFileRecord.name,
description: newFileRecord.description,
extension: newFileRecord.extension,
size: newFileRecord.size.toString(),
objectName: newFileRecord.objectName,
userId: newFileRecord.userId,
createdAt: newFileRecord.createdAt.toISOString(),
updatedAt: newFileRecord.updatedAt.toISOString(),
};
}
private formatReverseShareResponse(reverseShare: ReverseShareData) {
const result = {
id: reverseShare.id,
@@ -535,6 +619,8 @@ export class ReverseShareService {
updatedAt: reverseShare.alias.updatedAt.toISOString(),
}
: null,
nameFieldRequired: reverseShare.nameFieldRequired,
emailFieldRequired: reverseShare.emailFieldRequired,
};
return result;

View File

@@ -22,7 +22,20 @@ export class StorageController {
const diskSpace = await this.storageService.getDiskSpace(userId, isAdmin);
return reply.send(diskSpace);
} catch (error: any) {
return reply.status(500).send({ error: error.message });
console.error("Controller error in getDiskSpace:", error);
if (error.message?.includes("Unable to determine actual disk space")) {
return reply.status(503).send({
error: "Disk space detection unavailable - system configuration issue",
details: "Please check system permissions and available disk utilities",
code: "DISK_SPACE_DETECTION_FAILED",
});
}
return reply.status(500).send({
error: "Failed to retrieve disk space information",
details: error.message || "Unknown error occurred",
});
}
}

View File

@@ -1,35 +1,134 @@
import { IS_RUNNING_IN_CONTAINER } from "../../utils/container-detection";
import { ConfigService } from "../config/service";
import { PrismaClient } from "@prisma/client";
import { exec } from "child_process";
import fs from "node:fs";
import { promisify } from "util";
import fs from 'node:fs';
const execAsync = promisify(exec);
const prisma = new PrismaClient();
export class StorageService {
private configService = new ConfigService();
private isDockerCached = undefined;
private _hasDockerEnv() {
private _ensureNumber(value: number, fallback: number = 0): number {
if (isNaN(value) || !isFinite(value)) {
return fallback;
}
return value;
}
private _safeParseInt(value: string): number {
const parsed = parseInt(value);
return this._ensureNumber(parsed, 0);
}
private async _tryDiskSpaceCommand(command: string): Promise<{ total: number; available: number } | null> {
try {
fs.statSync('/.dockerenv');
return true;
} catch {
return false;
console.log(`Trying disk space command: ${command}`);
const { stdout, stderr } = await execAsync(command);
if (stderr) {
console.warn(`Command stderr: ${stderr}`);
}
console.log(`Command stdout: ${stdout}`);
let total = 0;
let available = 0;
if (process.platform === "win32") {
const lines = stdout.trim().split("\n").slice(1);
for (const line of lines) {
const parts = line.trim().split(/\s+/);
if (parts.length >= 3) {
const [, size, freespace] = parts;
total += this._safeParseInt(size);
available += this._safeParseInt(freespace);
}
}
} else if (process.platform === "darwin") {
const lines = stdout.trim().split("\n");
if (lines.length >= 2) {
const parts = lines[1].trim().split(/\s+/);
if (parts.length >= 4) {
const [, size, , avail] = parts;
total = this._safeParseInt(size) * 1024;
available = this._safeParseInt(avail) * 1024;
}
}
} else {
const lines = stdout.trim().split("\n");
if (lines.length >= 2) {
const parts = lines[1].trim().split(/\s+/);
if (parts.length >= 4) {
const [, size, , avail] = parts;
if (command.includes("-B1")) {
total = this._safeParseInt(size);
available = this._safeParseInt(avail);
} else {
total = this._safeParseInt(size) * 1024;
available = this._safeParseInt(avail) * 1024;
}
}
}
}
if (total > 0 && available >= 0) {
console.log(`Successfully parsed disk space: ${total} bytes total, ${available} bytes available`);
return { total, available };
} else {
console.warn(`Invalid values parsed: total=${total}, available=${available}`);
return null;
}
} catch (error) {
console.warn(`Command failed: ${command}`, error);
return null;
}
}
private _hasDockerCGroup() {
try {
return fs.readFileSync('/proc/self/cgroup', 'utf8').includes('docker');
} catch {
return false;
}
}
private async _getDiskSpaceMultiplePaths(): Promise<{ total: number; available: number } | null> {
const pathsToTry = IS_RUNNING_IN_CONTAINER
? ["/app/server/uploads", "/app/server", "/app", "/"]
: [".", "./uploads", process.cwd()];
private _isDocker() {
return this.isDockerCached ?? (this._hasDockerEnv() || this._hasDockerCGroup());
for (const pathToCheck of pathsToTry) {
console.log(`Trying path: ${pathToCheck}`);
if (pathToCheck.includes("uploads")) {
try {
if (!fs.existsSync(pathToCheck)) {
fs.mkdirSync(pathToCheck, { recursive: true });
console.log(`Created directory: ${pathToCheck}`);
}
} catch (err) {
console.warn(`Could not create path ${pathToCheck}:`, err);
continue;
}
}
if (!fs.existsSync(pathToCheck)) {
console.warn(`Path does not exist: ${pathToCheck}`);
continue;
}
const commandsToTry =
process.platform === "win32"
? ["wmic logicaldisk get size,freespace,caption"]
: process.platform === "darwin"
? [`df -k "${pathToCheck}"`, `df "${pathToCheck}"`]
: [`df -B1 "${pathToCheck}"`, `df -k "${pathToCheck}"`, `df "${pathToCheck}"`];
for (const command of commandsToTry) {
const result = await this._tryDiskSpaceCommand(command);
if (result) {
console.log(`✅ Successfully got disk space for path: ${pathToCheck}`);
return result;
}
}
}
return null;
}
async getDiskSpace(
@@ -43,49 +142,40 @@ export class StorageService {
}> {
try {
if (isAdmin) {
const isDocker = this._isDocker();
const pathToCheck = isDocker ? "/app/server/uploads" : ".";
console.log(`Running in container: ${IS_RUNNING_IN_CONTAINER}`);
const command = process.platform === "win32"
? "wmic logicaldisk get size,freespace,caption"
: process.platform === "darwin"
? `df -k ${pathToCheck}`
: `df -B1 ${pathToCheck}`;
const diskInfo = await this._getDiskSpaceMultiplePaths();
const { stdout } = await execAsync(command);
let total = 0;
let available = 0;
if (!diskInfo) {
console.error("❌ CRITICAL: Could not determine disk space using any method!");
console.error("This indicates a serious system issue. Please check:");
console.error("1. File system permissions");
console.error("2. Available disk utilities (df, wmic)");
console.error("3. Container/system configuration");
if (process.platform === "win32") {
const lines = stdout.trim().split("\n").slice(1);
for (const line of lines) {
const [, size, freespace] = line.trim().split(/\s+/);
total += parseInt(size) || 0;
available += parseInt(freespace) || 0;
}
} else if (process.platform === "darwin") {
const lines = stdout.trim().split("\n");
const [, size, , avail] = lines[1].trim().split(/\s+/);
total = parseInt(size) * 1024;
available = parseInt(avail) * 1024;
} else {
const lines = stdout.trim().split("\n");
const [, size, , avail] = lines[1].trim().split(/\s+/);
total = parseInt(size);
available = parseInt(avail);
throw new Error("Unable to determine actual disk space - system configuration issue");
}
const { total, available } = diskInfo;
const used = total - available;
const diskSizeGB = this._ensureNumber(total / (1024 * 1024 * 1024), 0);
const diskUsedGB = this._ensureNumber(used / (1024 * 1024 * 1024), 0);
const diskAvailableGB = this._ensureNumber(available / (1024 * 1024 * 1024), 0);
console.log(
`✅ Real disk space: ${diskSizeGB.toFixed(2)}GB total, ${diskUsedGB.toFixed(2)}GB used, ${diskAvailableGB.toFixed(2)}GB available`
);
return {
diskSizeGB: Number((total / (1024 * 1024 * 1024)).toFixed(2)),
diskUsedGB: Number((used / (1024 * 1024 * 1024)).toFixed(2)),
diskAvailableGB: Number((available / (1024 * 1024 * 1024)).toFixed(2)),
uploadAllowed: true,
diskSizeGB: Number(diskSizeGB.toFixed(2)),
diskUsedGB: Number(diskUsedGB.toFixed(2)),
diskAvailableGB: Number(diskAvailableGB.toFixed(2)),
uploadAllowed: diskAvailableGB > 0.1, // At least 100MB free
};
} else if (userId) {
const maxTotalStorage = BigInt(await this.configService.getValue("maxTotalStoragePerUser"));
const maxStorageGB = Number(maxTotalStorage) / (1024 * 1024 * 1024);
const maxStorageGB = this._ensureNumber(Number(maxTotalStorage) / (1024 * 1024 * 1024), 10);
const userFiles = await prisma.file.findMany({
where: { userId },
@@ -94,21 +184,24 @@ export class StorageService {
const totalUsedStorage = userFiles.reduce((acc, file) => acc + file.size, BigInt(0));
const usedStorageGB = Number(totalUsedStorage) / (1024 * 1024 * 1024);
const availableStorageGB = maxStorageGB - usedStorageGB;
const usedStorageGB = this._ensureNumber(Number(totalUsedStorage) / (1024 * 1024 * 1024), 0);
const availableStorageGB = this._ensureNumber(maxStorageGB - usedStorageGB, 0);
return {
diskSizeGB: maxStorageGB,
diskUsedGB: usedStorageGB,
diskAvailableGB: availableStorageGB,
diskSizeGB: Number(maxStorageGB.toFixed(2)),
diskUsedGB: Number(usedStorageGB.toFixed(2)),
diskAvailableGB: Number(availableStorageGB.toFixed(2)),
uploadAllowed: availableStorageGB > 0,
};
}
throw new Error("User ID is required for non-admin users");
} catch (error) {
console.error("Error getting disk space:", error);
throw new Error("Failed to get disk space information");
console.error("Error getting disk space:", error);
throw new Error(
`Failed to get disk space information: ${error instanceof Error ? error.message : String(error)}`
);
}
}

View File

@@ -7,6 +7,7 @@ export interface IUserRepository {
findUserByEmail(email: string): Promise<User | null>;
findUserById(id: string): Promise<User | null>;
findUserByUsername(username: string): Promise<User | null>;
findUserByEmailOrUsername(emailOrUsername: string): Promise<User | null>;
listUsers(): Promise<User[]>;
updateUser(data: UpdateUserInput & { password?: string }): Promise<User>;
deleteUser(id: string): Promise<User>;
@@ -41,6 +42,14 @@ export class PrismaUserRepository implements IUserRepository {
return prisma.user.findUnique({ where: { username } });
}
async findUserByEmailOrUsername(emailOrUsername: string): Promise<User | null> {
return prisma.user.findFirst({
where: {
OR: [{ email: emailOrUsername }, { username: emailOrUsername }],
},
});
}
async listUsers(): Promise<User[]> {
return prisma.user.findMany();
}

View File

@@ -30,7 +30,6 @@ export async function userRoutes(app: FastifyInstance) {
.description("Unauthorized: a valid token is required to access this resource.");
}
}
// If usersCount is 0, allow the request to proceed without authentication
} catch (err) {
console.error(err);
return reply.status(500).send({ error: "Internal server error" }).description("Internal server error");

View File

@@ -1,5 +1,6 @@
import { env } from "../env";
import { StorageProvider } from "../types/storage";
import { IS_RUNNING_IN_CONTAINER } from "../utils/container-detection";
import * as crypto from "crypto";
import * as fsSync from "fs";
import * as fs from "fs/promises";
@@ -9,12 +10,14 @@ import { pipeline } from "stream/promises";
export class FilesystemStorageProvider implements StorageProvider {
private static instance: FilesystemStorageProvider;
private uploadsDir = path.join(process.cwd(), "uploads");
private uploadsDir: string;
private encryptionKey = env.ENCRYPTION_KEY;
private uploadTokens = new Map<string, { objectName: string; expiresAt: number }>();
private downloadTokens = new Map<string, { objectName: string; expiresAt: number; fileName?: string }>();
private constructor() {
this.uploadsDir = IS_RUNNING_IN_CONTAINER ? "/app/server/uploads" : path.join(process.cwd(), "uploads");
this.ensureUploadsDir();
setInterval(() => this.cleanExpiredTokens(), 5 * 60 * 1000);
}
@@ -214,8 +217,10 @@ export class FilesystemStorageProvider implements StorageProvider {
if (encryptedBuffer.length > 16) {
try {
return this.decryptFileBuffer(encryptedBuffer);
} catch (error) {
console.warn("Failed to decrypt with new method, trying legacy format");
} catch (error: unknown) {
if (error instanceof Error) {
console.warn("Failed to decrypt with new method, trying legacy format", error.message);
}
return this.decryptFileLegacy(encryptedBuffer);
}
}

View File

@@ -5,7 +5,6 @@ import * as readline from "readline";
const prisma = new PrismaClient();
// Função para ler entrada do usuário de forma assíncrona
function createReadlineInterface() {
return readline.createInterface({
input: process.stdin,
@@ -17,15 +16,12 @@ function question(rl: readline.Interface, query: string): Promise<string> {
return new Promise((resolve) => rl.question(query, resolve));
}
// Função para validar formato de email básico
function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
// Função para validar senha com base nas regras do sistema
function isValidPassword(password: string): boolean {
// Minimum length baseado na configuração padrão do sistema (8 caracteres)
return password.length >= 8;
}
@@ -38,7 +34,6 @@ async function resetUserPassword() {
console.log("This script allows you to reset a user's password directly from the Docker terminal.");
console.log("⚠️ WARNING: This bypasses normal security checks. Use only when necessary!\n");
// Solicitar email do usuário
let email: string;
let user: any;
@@ -55,7 +50,6 @@ async function resetUserPassword() {
continue;
}
// Buscar usuário no banco de dados
user = await prisma.user.findUnique({
where: { email: email.toLowerCase() },
select: {
@@ -83,7 +77,6 @@ async function resetUserPassword() {
break;
}
// Mostrar informações do usuário encontrado
console.log("\n✅ User found:");
console.log(` Name: ${user.firstName} ${user.lastName}`);
console.log(` Username: ${user.username}`);
@@ -91,14 +84,12 @@ async function resetUserPassword() {
console.log(` Status: ${user.isActive ? "Active" : "Inactive"}`);
console.log(` Admin: ${user.isAdmin ? "Yes" : "No"}\n`);
// Confirmar se deseja prosseguir
const confirm = await question(rl, "Do you want to reset the password for this user? (y/n): ");
if (confirm.toLowerCase() !== "y") {
console.log("\n👋 Operation cancelled.");
return;
}
// Solicitar nova senha
let newPassword: string;
while (true) {
console.log("\n🔑 Enter new password requirements:");
@@ -126,18 +117,15 @@ async function resetUserPassword() {
break;
}
// Hash da senha usando bcrypt (mesmo método usado pelo sistema)
console.log("\n🔄 Hashing password...");
const hashedPassword = await bcrypt.hash(newPassword, 10);
// Atualizar senha no banco de dados
console.log("💾 Updating password in database...");
await prisma.user.update({
where: { id: user.id },
data: { password: hashedPassword },
});
// Limpar tokens de reset de senha existentes para este usuário
console.log("🧹 Cleaning up existing password reset tokens...");
await prisma.passwordReset.deleteMany({
where: {
@@ -159,7 +147,6 @@ async function resetUserPassword() {
}
}
// Função para listar usuários (funcionalidade auxiliar)
async function listUsers() {
try {
console.log("\n👥 Registered Users:");
@@ -198,7 +185,6 @@ async function listUsers() {
}
}
// Main function
async function main() {
const args = process.argv.slice(2);
@@ -227,7 +213,6 @@ async function main() {
await resetUserPassword();
}
// Handle process termination
process.on("SIGINT", async () => {
console.log("\n\n👋 Goodbye!");
await prisma.$disconnect();
@@ -239,7 +224,6 @@ process.on("SIGTERM", async () => {
process.exit(0);
});
// Run the script
if (require.main === module) {
main().catch(console.error);
}

View File

@@ -11,6 +11,7 @@ import { reverseShareRoutes } from "./modules/reverse-share/routes";
import { shareRoutes } from "./modules/share/routes";
import { storageRoutes } from "./modules/storage/routes";
import { userRoutes } from "./modules/user/routes";
import { IS_RUNNING_IN_CONTAINER } from "./utils/container-detection";
import fastifyMultipart from "@fastify/multipart";
import fastifyStatic from "@fastify/static";
import * as fs from "fs/promises";
@@ -26,21 +27,22 @@ if (typeof global.crypto === "undefined") {
}
async function ensureDirectories() {
const uploadsDir = path.join(process.cwd(), "uploads");
const tempChunksDir = path.join(process.cwd(), "temp-chunks");
const baseDir = IS_RUNNING_IN_CONTAINER ? "/app/server" : process.cwd();
const uploadsDir = path.join(baseDir, "uploads");
const tempChunksDir = path.join(baseDir, "temp-chunks");
try {
await fs.access(uploadsDir);
} catch {
await fs.mkdir(uploadsDir, { recursive: true });
console.log("📁 Created uploads directory");
console.log(`📁 Created uploads directory: ${uploadsDir}`);
}
try {
await fs.access(tempChunksDir);
} catch {
await fs.mkdir(tempChunksDir, { recursive: true });
console.log("📁 Created temp-chunks directory");
console.log(`📁 Created temp-chunks directory: ${tempChunksDir}`);
}
}
@@ -62,8 +64,11 @@ async function startServer() {
});
if (env.ENABLE_S3 !== "true") {
const baseDir = IS_RUNNING_IN_CONTAINER ? "/app/server" : process.cwd();
const uploadsPath = path.join(baseDir, "uploads");
await app.register(fastifyStatic, {
root: path.join(process.cwd(), "uploads"),
root: uploadsPath,
prefix: "/uploads/",
decorateReply: false,
});

View File

@@ -0,0 +1,45 @@
import * as fsSync from "fs";
/**
* Determines if the application is running inside a container environment.
* Checks common container indicators like /.dockerenv and cgroup file patterns.
*
* This function caches its result after the first call for performance.
*
* @returns {boolean} True if running in a container, false otherwise.
*/
function isRunningInContainer(): boolean {
try {
if (fsSync.existsSync("/.dockerenv")) {
return true;
}
const cgroupContent = fsSync.readFileSync("/proc/self/cgroup", "utf8");
const containerPatterns = [
"docker",
"containerd",
"lxc",
"kubepods",
"pod",
"/containers/",
"system.slice/container-",
];
for (const pattern of containerPatterns) {
if (cgroupContent.includes(pattern)) {
return true;
}
}
if (fsSync.existsSync("/.well-known/container")) {
return true;
}
} catch (e: unknown) {
if (e instanceof Error) {
console.warn("Could not perform full container detection:", e.message);
}
}
return false;
}
export const IS_RUNNING_IN_CONTAINER = isRunningInContainer();

View File

@@ -202,6 +202,8 @@
"login": {
"welcome": "مرحبا بك",
"signInToContinue": "قم بتسجيل الدخول للمتابعة",
"emailOrUsernameLabel": "البريد الإلكتروني أو اسم المستخدم",
"emailOrUsernamePlaceholder": "أدخل بريدك الإلكتروني أو اسم المستخدم",
"emailLabel": "البريد الإلكتروني",
"emailPlaceholder": "أدخل بريدك الإلكتروني",
"passwordLabel": "كلمة المرور",
@@ -526,10 +528,30 @@
"oidcAdminEmailDomains": {
"title": "نطاقات البريد الإلكتروني للمشرف",
"description": "نطاقات البريد الإلكتروني التي تحصل على صلاحيات المشرف تلقائياً"
},
"testSmtp": {
"title": "اختبار اتصال SMTP",
"description": "اختبار ما إذا كان تكوين SMTP صالحًا"
},
"smtpNoAuth": {
"title": "بدون مصادقة",
"description": "قم بتمكين هذا للخوادم الداخلية التي لا تتطلب اسم مستخدم/كلمة مرور (يخفي حقول المصادقة)"
},
"smtpSecure": {
"title": "أمان الاتصال",
"description": "طريقة أمان اتصال SMTP - تلقائي (موصى به)، SSL، STARTTLS، أو بدون (غير آمن)",
"options": {
"auto": "تلقائي (موصى به)",
"ssl": "SSL (منفذ 465)",
"tls": "STARTTLS (منفذ 587)",
"none": "بدون (غير آمن)"
}
}
},
"buttons": {
"save": "احفظ {group}"
"save": "احفظ {group}",
"testSmtp": "اختبار الاتصال",
"testing": "جاري الاختبار..."
},
"errors": {
"loadFailed": "فشل في تحميل الإعدادات",
@@ -537,14 +559,22 @@
},
"messages": {
"noChanges": "لا توجد تغييرات للحفظ",
"updateSuccess": "تم تحديث إعدادات {group} بنجاح"
"updateSuccess": "تم تحديث إعدادات {group} بنجاح",
"smtpTestFailed": "فشل اتصال SMTP: {error}",
"smtpTestGenericError": "فشل اختبار اتصال SMTP. يرجى التحقق من إعداداتك والمحاولة مرة أخرى.",
"smtpTestSuccess": "تم اتصال SMTP بنجاح! تكوين البريد الإلكتروني الخاص بك يعمل بشكل صحيح.",
"smtpMissingAuth": "يرجى ملء اسم المستخدم وكلمة المرور الخاصة بـ SMTP، أو تمكين خيار 'بدون مصادقة'.",
"smtpMissingHostPort": "يرجى ملء مضيف ومنفذ SMTP قبل الاختبار.",
"smtpNotEnabled": "SMTP غير مفعل. يرجى تفعيل SMTP أولاً."
},
"title": "الإعدادات",
"breadcrumb": "الإعدادات",
"pageTitle": "الإعدادات",
"tooltips": {
"oidcScope": "أدخل نطاقاً واضغط Enter للإضافة",
"oidcAdminEmailDomains": "أدخل نطاقاً واضغط Enter للإضافة"
"oidcAdminEmailDomains": "أدخل نطاقاً واضغط Enter للإضافة",
"testSmtp": "يختبر اتصال SMTP بالقيم المدخلة حاليًا في النموذج. لجعل التغييرات دائمة، تذكر حفظ إعداداتك بعد الاختبار.",
"defaultPlaceholder": "أدخل واضغط Enter"
},
"redirectUri": {
"placeholder": "https://mysite.com",
@@ -577,7 +607,8 @@
"title": "لم يتم العثور على المشاركة",
"description": "قد يكون تم حذف هذه المشاركة أو انتهت صلاحيتها."
},
"pageTitle": "المشاركة"
"pageTitle": "المشاركة",
"downloadAll": "تحميل الكل"
},
"shareActions": {
"deleteTitle": "حذف المشاركة",
@@ -654,7 +685,16 @@
"securityUpdateError": "فشل في تحديث إعدادات الأمان",
"expirationUpdateError": "فشل في تحديث إعدادات انتهاء الصلاحية",
"securityUpdateSuccess": "تم تحديث إعدادات الأمان بنجاح",
"expirationUpdateSuccess": "تم تحديث إعدادات انتهاء الصلاحية بنجاح"
"expirationUpdateSuccess": "تم تحديث إعدادات انتهاء الصلاحية بنجاح",
"creatingZip": "إنشاء ملف zip ...",
"defaultShareName": "يشارك",
"downloadError": "فشل تنزيل ملفات المشاركة",
"downloadSuccess": "بدأ التنزيل بنجاح",
"multipleSharesZipName": "{count} _shares_files.zip",
"noFilesToDownload": "لا توجد ملفات متاحة للتنزيل",
"singleShareZipName": "{ShareName} _files.zip",
"zipDownloadError": "فشل في إنشاء ملف مضغوط",
"zipDownloadSuccess": "تم تنزيل ملف zip بنجاح"
},
"shares": {
"errors": {
@@ -717,11 +757,14 @@
"editLink": "تحرير الرابط",
"copyLink": "نسخ الرابط",
"notifyRecipients": "إشعار المستقبلين",
"delete": "حذف"
"delete": "حذف",
"downloadShareFiles": "قم بتنزيل جميع الملفات"
},
"bulkActions": {
"delete": "حذف",
"selected": "{count, plural, =1 {مشاركة واحدة محددة} other {# مشاركات محددة}}"
"selected": "{count, plural, =1 {مشاركة واحدة محددة} other {# مشاركات محددة}}",
"actions": "الإجراءات",
"download": "تنزيل محدد"
},
"selectAll": "تحديد الكل",
"selectShare": "تحديد المشاركة {shareName}"
@@ -730,7 +773,15 @@
"title": "استخدام التخزين",
"ariaLabel": "شريط تقدم استخدام التخزين",
"used": "المستخدمة",
"available": "المتاحة"
"available": "متاح",
"loading": "جارٍ التحميل...",
"retry": "إعادة المحاولة",
"errors": {
"title": "معلومات التخزين غير متوفرة",
"detectionFailed": "تعذر اكتشاف مساحة القرص. قد يكون هذا بسبب مشاكل في إعدادات النظام أو صلاحيات غير كافية.",
"serverError": "حدث خطأ في الخادم أثناء استرجاع معلومات التخزين. يرجى المحاولة مرة أخرى لاحقاً.",
"unknown": "حدث خطأ غير متوقع أثناء تحميل معلومات التخزين."
}
},
"theme": {
"toggle": "تبديل السمة",
@@ -748,6 +799,7 @@
"uploadProgress": "تقدم الرفع",
"upload": "رفع",
"startUploads": "بدء الرفع",
"retry": "إعادة المحاولة",
"finish": "إنهاء",
"success": "تم رفع الملف بنجاح",
"allSuccess": "{count, plural, =1 {تم رفع الملف بنجاح} other {تم رفع # ملف بنجاح}}",
@@ -844,6 +896,7 @@
"passwordLength": "يجب أن تحتوي كلمة المرور على 8 أحرف على الأقل",
"passwordsMatch": "كلمتا المرور غير متطابقتين",
"emailRequired": "البريد الإلكتروني مطلوب",
"emailOrUsernameRequired": "البريد الإلكتروني أو اسم المستخدم مطلوب",
"passwordRequired": "كلمة المرور مطلوبة",
"nameRequired": "الاسم مطلوب",
"required": "هذا الحقل مطلوب"
@@ -1000,7 +1053,15 @@
"noFilesLimit": "بدون حد للملفات",
"noSizeLimit": "بدون حد للحجم",
"allFileTypes": "جميع أنواع الملفات",
"fileTypesHelp": "أدخل الامتدادات بدون نقطة، مفصولة بمسافة أو فاصلة أو شرطة أو خط عمودي"
"fileTypesHelp": "أدخل الامتدادات بدون نقطة، مفصولة بمسافة أو فاصلة أو شرطة أو خط عمودي",
"emailFieldRequired": "حقل البريد الإلكتروني",
"fieldOptions": {
"hidden": "مختفي",
"optional": "خياري",
"required": "مطلوب"
},
"fieldRequirements": "المتطلبات الميدانية",
"nameFieldRequired": "حقل الاسم"
},
"card": {
"untitled": "رابط بدون عنوان",
@@ -1145,7 +1206,9 @@
},
"actions": {
"preview": "معاينة",
"download": "تحميل"
"download": "تحميل",
"copyToMyFiles": "انسخ إلى ملفاتي",
"copying": "نسخ ..."
},
"uploadedBy": "تم الرفع بواسطة {name}",
"anonymous": "مجهول",
@@ -1153,7 +1216,27 @@
"downloadError": "خطأ في تحميل الملف",
"editSuccess": "تم تحديث الملف بنجاح",
"editError": "خطأ في تحديث الملف",
"previewNotAvailable": "المعاينة غير متوفرة"
"previewNotAvailable": "المعاينة غير متوفرة",
"copyError": "خطأ نسخ الملف إلى ملفاتك",
"copySuccess": "تم نسخ الملف إلى ملفاتك بنجاح",
"bulkActions": {
"selected": "{count, plural, =0 {لا ملفات محددة} =1 {ملف واحد محدد} =2 {ملفان محددان} other {# ملفات محددة}}",
"actions": "إجراءات",
"download": "تحميل المحدد",
"copyToMyFiles": "نسخ المحدد إلى ملفاتي",
"delete": "حذف المحدد"
},
"bulkCopyProgress": "جارٍ نسخ {count, plural, =1 {ملف واحد} =2 {ملفين} other {# ملفات}} إلى ملفاتك...",
"bulkCopySuccess": "{count, plural, =1 {تم نسخ ملف واحد إلى ملفاتك بنجاح} =2 {تم نسخ ملفين إلى ملفاتك بنجاح} other {تم نسخ # ملفات إلى ملفاتك بنجاح}}",
"bulkDeleteConfirmButton": "حذف {count, plural, =1 {الملف} =2 {الملفين} other {الملفات}}",
"bulkDeleteConfirmMessage": "هل أنت متأكد أنك تريد حذف {count, plural, =1 {هذا الملف} =2 {هذين الملفين} other {هذه الملفات الـ #}}؟ لا يمكن التراجع عن هذا الإجراء.",
"bulkDeleteConfirmTitle": "حذف الملفات المحددة",
"bulkDeleteProgress": "جارٍ حذف {count, plural, =1 {ملف واحد} =2 {ملفين} other {# ملفات}}...",
"bulkDeleteSuccess": "{count, plural, =1 {تم حذف ملف واحد بنجاح} =2 {تم حذف ملفين بنجاح} other {تم حذف # ملفات بنجاح}}",
"selectAll": "تحديد الكل",
"selectFile": "تحديد الملف {fileName}",
"deleteError": "خطأ في حذف الملف",
"deleteSuccess": "تم حذف الملف بنجاح"
}
},
"form": {
@@ -1214,7 +1297,19 @@
"passwordHelp": "يجب أن تكون كلمة المرور 4 أحرف على الأقل",
"passwordPlaceholder": "أدخل كلمة مرور لحماية الرابط"
},
"submit": "إنشاء رابط استلام"
"submit": "إنشاء رابط استلام",
"emailFieldRequired": {
"label": "متطلبات حقل البريد الإلكتروني",
"description": "تكوين ما إذا كان يجب عرض حقل البريد الإلكتروني للتحميل وإذا كان مطلوبًا"
},
"fieldRequirements": {
"title": "المتطلبات الميدانية",
"description": "تكوين الحقول المعروضة في نموذج التحميل"
},
"nameFieldRequired": {
"label": "اسم حقل الاسم",
"description": "تكوين إذا كان يجب عرض حقل اسم التحميل وإذا كان مطلوبًا"
}
},
"messages": {
"created": "تم إنشاء رابط الاستلام بنجاح!",
@@ -1224,14 +1319,17 @@
"deleteSuccess": "تم حذف رابط الاستلام بنجاح!",
"aliasCreated": "تم إنشاء الاسم المستعار بنجاح!",
"activateSuccess": "تم تفعيل رابط الاستلام بنجاح!",
"deactivateSuccess": "تم تعطيل رابط الاستلام بنجاح!"
"deactivateSuccess": "تم تعطيل رابط الاستلام بنجاح!",
"passwordProtectionDisabled": "تم إزالة حماية كلمة المرور بنجاح!",
"passwordProtectionEnabled": "تم تمكين حماية كلمة المرور بنجاح!"
},
"errors": {
"loadFailed": "فشل في تحميل روابط الاستلام",
"createFailed": "فشل في إنشاء رابط الاستلام. حاول مرة أخرى.",
"updateFailed": "فشل في تحديث رابط الاستلام. حاول مرة أخرى.",
"deleteFailed": "فشل في حذف رابط الاستلام. حاول مرة أخرى.",
"aliasCreateFailed": "فشل في إنشاء الاسم المستعار. حاول مرة أخرى."
"aliasCreateFailed": "فشل في إنشاء الاسم المستعار. حاول مرة أخرى.",
"passwordUpdateFailed": "فشل في تحديث حماية كلمة المرور"
},
"delete": {
"title": "حذف رابط الاستلام",
@@ -1269,11 +1367,14 @@
"linkInactive": "هذا الرابط غير نشط.",
"linkExpired": "هذا الرابط منتهي الصلاحية.",
"uploadFailed": "خطأ في رفع الملف",
"retry": "إعادة المحاولة",
"fileTooLarge": "الملف كبير جداً. الحجم الأقصى: {maxSize}",
"fileTypeNotAllowed": "نوع الملف غير مسموح به. الأنواع المقبولة: {allowedTypes}",
"maxFilesExceeded": "الحد الأقصى المسموح به هو {maxFiles} ملف/ملفات",
"selectAtLeastOneFile": "اختر ملفاً واحداً على الأقل",
"provideNameOrEmail": "قم بتوفير اسمك أو بريدك الإلكتروني"
"provideNameOrEmail": "قم بتوفير اسمك أو بريدك الإلكتروني",
"provideEmailRequired": "البريد الإلكتروني مطلوب",
"provideNameRequired": "الاسم مطلوب"
},
"fileDropzone": {
"dragActive": "أفلت الملفات هنا",
@@ -1296,7 +1397,9 @@
"descriptionLabel": "الوصف (اختياري)",
"descriptionPlaceholder": "أضف وصفاً للملفات...",
"uploadButton": "رفع {count} ملف/ملفات",
"uploading": "جارٍ الرفع..."
"uploading": "جارٍ الرفع...",
"emailLabelOptional": "البريد الإلكتروني (اختياري)",
"nameLabelOptional": "الاسم (اختياري)"
},
"success": {
"title": "تم رفع الملفات بنجاح! 🎉",
@@ -1326,7 +1429,9 @@
"components": {
"fileRow": {
"addDescription": "إضافة وصف...",
"anonymous": "مجهول"
"anonymous": "مجهول",
"deleteError": "خطأ في حذف الملف",
"deleteSuccess": "تم حذف الملف بنجاح"
},
"fileActions": {
"edit": "تحرير",
@@ -1334,12 +1439,15 @@
"cancel": "إلغاء",
"preview": "معاينة",
"download": "تحميل",
"delete": "حذف"
"delete": "حذف",
"copyToMyFiles": "انسخ إلى ملفاتي",
"copying": "نسخ ..."
},
"editField": {
"saveChanges": "حفظ التغييرات",
"cancelEdit": "إلغاء التحرير"
}
}
},
"defaultLinkName": "الملفات المستلمة"
}
}

View File

@@ -202,6 +202,8 @@
"login": {
"welcome": "Willkommen zu",
"signInToContinue": "Melden Sie sich an, um fortzufahren",
"emailOrUsernameLabel": "E-Mail-Adresse oder Benutzername",
"emailOrUsernamePlaceholder": "Geben Sie Ihre E-Mail-Adresse oder Benutzernamen ein",
"emailLabel": "E-Mail-Adresse",
"emailPlaceholder": "Geben Sie Ihre E-Mail-Adresse ein",
"passwordLabel": "Passwort",
@@ -526,10 +528,30 @@
"oidcAdminEmailDomains": {
"title": "Admin-E-Mail-Domains",
"description": "E-Mail-Domains, die automatisch Administratorrechte erhalten"
},
"testSmtp": {
"title": "SMTP-Verbindung testen",
"description": "Testen Sie, ob die SMTP-Konfiguration gültig ist"
},
"smtpNoAuth": {
"title": "Keine Authentifizierung",
"description": "Aktivieren Sie dies für interne Server, die keinen Benutzernamen/Passwort benötigen (blendet Authentifizierungsfelder aus)"
},
"smtpSecure": {
"title": "Verbindungssicherheit",
"description": "SMTP-Verbindungssicherheitsmethode - Auto (empfohlen), SSL, STARTTLS oder Keine (unsicher)",
"options": {
"auto": "Auto (Empfohlen)",
"ssl": "SSL (Port 465)",
"tls": "STARTTLS (Port 587)",
"none": "Keine (Unsicher)"
}
}
},
"buttons": {
"save": "{group} speichern"
"save": "{group} speichern",
"testSmtp": "Verbindung testen",
"testing": "Teste..."
},
"errors": {
"loadFailed": "Fehler beim Laden der Einstellungen",
@@ -537,14 +559,22 @@
},
"messages": {
"noChanges": "Keine Änderungen zum Speichern",
"updateSuccess": "{group}-Einstellungen erfolgreich aktualisiert"
"updateSuccess": "{group}-Einstellungen erfolgreich aktualisiert",
"smtpTestFailed": "SMTP-Verbindung fehlgeschlagen: {error}",
"smtpTestGenericError": "SMTP-Verbindungstest fehlgeschlagen. Bitte überprüfen Sie Ihre Einstellungen und versuchen Sie es erneut.",
"smtpTestSuccess": "SMTP-Verbindung erfolgreich! Ihre E-Mail-Konfiguration funktioniert korrekt.",
"smtpMissingAuth": "Bitte geben Sie SMTP-Benutzername und Passwort ein oder aktivieren Sie die Option 'Keine Authentifizierung'.",
"smtpMissingHostPort": "Bitte geben Sie SMTP-Host und Port vor dem Test ein.",
"smtpNotEnabled": "SMTP ist nicht aktiviert. Bitte aktivieren Sie zuerst SMTP."
},
"title": "Einstellungen",
"breadcrumb": "Einstellungen",
"pageTitle": "Einstellungen",
"tooltips": {
"oidcScope": "Geben Sie einen Scope ein und drücken Sie Enter zum Hinzufügen",
"oidcAdminEmailDomains": "Geben Sie eine Domain ein und drücken Sie Enter zum Hinzufügen"
"oidcAdminEmailDomains": "Geben Sie eine Domain ein und drücken Sie Enter zum Hinzufügen",
"testSmtp": "Testet die SMTP-Verbindung mit den aktuell im Formular eingegebenen Werten. Um die Änderungen dauerhaft zu machen, denken Sie daran, Ihre Einstellungen nach dem Test zu speichern.",
"defaultPlaceholder": "Eingeben und Enter drücken"
},
"redirectUri": {
"placeholder": "https://meineseite.com",
@@ -577,7 +607,8 @@
"title": "Freigabe nicht gefunden",
"description": "Diese Freigabe wurde möglicherweise gelöscht oder ist abgelaufen."
},
"pageTitle": "Freigabe"
"pageTitle": "Freigabe",
"downloadAll": "Alle herunterladen"
},
"shareActions": {
"deleteTitle": "Freigabe Löschen",
@@ -654,7 +685,16 @@
"securityUpdateError": "Sicherheitseinstellungen konnten nicht aktualisiert werden",
"expirationUpdateError": "Ablaufeinstellungen konnten nicht aktualisiert werden",
"securityUpdateSuccess": "Sicherheitseinstellungen erfolgreich aktualisiert",
"expirationUpdateSuccess": "Ablaufeinstellungen erfolgreich aktualisiert"
"expirationUpdateSuccess": "Ablaufeinstellungen erfolgreich aktualisiert",
"creatingZip": "ZIP -Datei erstellen ...",
"defaultShareName": "Aktie",
"downloadError": "Download Share -Dateien nicht herunterladen",
"downloadSuccess": "Download begann erfolgreich",
"multipleSharesZipName": "{count} _shares_files.zip",
"noFilesToDownload": "Keine Dateien zum Download zur Verfügung stehen",
"singleShareZipName": "{ShareName} _files.zip",
"zipDownloadError": "Die ZIP -Datei erstellt nicht",
"zipDownloadSuccess": "ZIP -Datei erfolgreich heruntergeladen"
},
"shares": {
"errors": {
@@ -717,11 +757,14 @@
"editLink": "Link Bearbeiten",
"copyLink": "Link Kopieren",
"notifyRecipients": "Empfänger Benachrichtigen",
"delete": "Löschen"
"delete": "Löschen",
"downloadShareFiles": "Laden Sie alle Dateien herunter"
},
"bulkActions": {
"delete": "Löschen",
"selected": "{count, plural, =1 {1 Freigabe ausgewählt} other {# Freigaben ausgewählt}}"
"selected": "{count, plural, =1 {1 Freigabe ausgewählt} other {# Freigaben ausgewählt}}",
"actions": "Aktionen",
"download": "Download ausgewählt"
},
"selectAll": "Alle auswählen",
"selectShare": "Freigabe {shareName} auswählen"
@@ -730,7 +773,15 @@
"title": "Speichernutzung",
"ariaLabel": "Fortschrittsbalken der Speichernutzung",
"used": "genutzt",
"available": "verfügbar"
"available": "verfügbar",
"loading": "Wird geladen...",
"retry": "Wiederholen",
"errors": {
"title": "Speicherinformationen nicht verfügbar",
"detectionFailed": "Speicherplatz konnte nicht erkannt werden. Dies kann an Systemkonfigurationsproblemen oder unzureichenden Berechtigungen liegen.",
"serverError": "Serverfehler beim Abrufen der Speicherinformationen. Bitte versuchen Sie es später erneut.",
"unknown": "Ein unerwarteter Fehler ist beim Laden der Speicherinformationen aufgetreten."
}
},
"theme": {
"toggle": "Design umschalten",
@@ -748,6 +799,7 @@
"uploadProgress": "Upload-Fortschritt",
"upload": "Hochladen",
"startUploads": "Uploads Starten",
"retry": "Wiederholen",
"finish": "Beenden",
"success": "Datei erfolgreich hochgeladen",
"allSuccess": "{count, plural, =1 {Datei erfolgreich hochgeladen} other {# Dateien erfolgreich hochgeladen}}",
@@ -844,6 +896,7 @@
"passwordLength": "Das Passwort muss mindestens 8 Zeichen lang sein",
"passwordsMatch": "Die Passwörter stimmen nicht überein",
"emailRequired": "E-Mail ist erforderlich",
"emailOrUsernameRequired": "E-Mail oder Benutzername ist erforderlich",
"passwordRequired": "Passwort ist erforderlich",
"nameRequired": "Name ist erforderlich",
"required": "Dieses Feld ist erforderlich"
@@ -1000,7 +1053,15 @@
"noFilesLimit": "Kein Dateilimit",
"noSizeLimit": "Kein Größenlimit",
"allFileTypes": "Alle Dateitypen",
"fileTypesHelp": "Geben Sie Erweiterungen ohne Punkt ein, getrennt durch Leerzeichen, Komma, Bindestrich oder Pipe"
"fileTypesHelp": "Geben Sie Erweiterungen ohne Punkt ein, getrennt durch Leerzeichen, Komma, Bindestrich oder Pipe",
"emailFieldRequired": "E -Mail -Feld",
"fieldOptions": {
"hidden": "Versteckt",
"optional": "Fakultativ",
"required": "Erforderlich"
},
"fieldRequirements": "Feldanforderungen",
"nameFieldRequired": "Namensfeld"
},
"card": {
"untitled": "Unbenannter Link",
@@ -1145,7 +1206,9 @@
},
"actions": {
"preview": "Vorschau",
"download": "Herunterladen"
"download": "Herunterladen",
"copyToMyFiles": "Kopieren Sie in meine Dateien",
"copying": "Kopieren..."
},
"uploadedBy": "Hochgeladen von {name}",
"anonymous": "Anonym",
@@ -1153,7 +1216,27 @@
"downloadError": "Fehler beim Herunterladen der Datei",
"editSuccess": "Datei erfolgreich aktualisiert",
"editError": "Fehler beim Aktualisieren der Datei",
"previewNotAvailable": "Vorschau nicht verfügbar"
"previewNotAvailable": "Vorschau nicht verfügbar",
"copyError": "Fehler beim Kopieren der Datei in Ihre Dateien",
"copySuccess": "Datei erfolgreich in Ihre Dateien kopiert",
"bulkActions": {
"selected": "{count, plural, =1 {1 Datei ausgewählt} other {# Dateien ausgewählt}}",
"actions": "Aktionen",
"download": "Ausgewählte herunterladen",
"copyToMyFiles": "Ausgewählte in meine Dateien kopieren",
"delete": "Ausgewählte löschen"
},
"bulkCopyProgress": "{count, plural, =1 {1 Datei wird} other {# Dateien werden}} in Ihre Dateien kopiert...",
"bulkCopySuccess": "{count, plural, =1 {1 Datei wurde erfolgreich in Ihre Dateien kopiert} other {# Dateien wurden erfolgreich in Ihre Dateien kopiert}}",
"bulkDeleteConfirmButton": "{count, plural, =1 {Datei löschen} other {Dateien löschen}}",
"bulkDeleteConfirmMessage": "Sind Sie sicher, dass Sie {count, plural, =1 {diese Datei} other {diese # Dateien}} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"bulkDeleteConfirmTitle": "Ausgewählte Dateien löschen",
"bulkDeleteProgress": "{count, plural, =1 {1 Datei wird} other {# Dateien werden}} gelöscht...",
"bulkDeleteSuccess": "{count, plural, =1 {1 Datei wurde erfolgreich gelöscht} other {# Dateien wurden erfolgreich gelöscht}}",
"selectAll": "Alle auswählen",
"selectFile": "Datei {fileName} auswählen",
"deleteError": "Fehler beim Löschen der Datei",
"deleteSuccess": "Datei erfolgreich gelöscht"
}
},
"form": {
@@ -1214,7 +1297,19 @@
"passwordHelp": "Das Passwort muss mindestens 4 Zeichen lang sein",
"passwordPlaceholder": "Geben Sie ein Passwort ein, um den Link zu schützen"
},
"submit": "Empfangslink erstellen"
"submit": "Empfangslink erstellen",
"emailFieldRequired": {
"label": "E -Mail -Feldanforderung",
"description": "Konfigurieren Sie, ob das Feld Uploader -E -Mail angezeigt werden soll und ob es erforderlich ist"
},
"fieldRequirements": {
"title": "Feldanforderungen",
"description": "Konfigurieren Sie, welche Felder im Upload -Formular angezeigt werden"
},
"nameFieldRequired": {
"label": "Namensfeldbedarf",
"description": "Konfigurieren Sie, ob das Feld Uploader -Name angezeigt werden soll und ob es erforderlich ist"
}
},
"messages": {
"created": "Empfangslink erfolgreich erstellt!",
@@ -1224,14 +1319,17 @@
"deleteSuccess": "Empfangslink erfolgreich gelöscht!",
"aliasCreated": "Alias erfolgreich erstellt!",
"activateSuccess": "Empfangslink erfolgreich aktiviert!",
"deactivateSuccess": "Empfangslink erfolgreich deaktiviert!"
"deactivateSuccess": "Empfangslink erfolgreich deaktiviert!",
"passwordProtectionDisabled": "Passwortschutz erfolgreich entfernt!",
"passwordProtectionEnabled": "Passwortschutz erfolgreich aktiviert!"
},
"errors": {
"loadFailed": "Fehler beim Laden der Empfangslinks",
"createFailed": "Fehler beim Erstellen des Empfangslinks. Bitte versuchen Sie es erneut.",
"updateFailed": "Fehler beim Aktualisieren des Empfangslinks. Bitte versuchen Sie es erneut.",
"deleteFailed": "Fehler beim Löschen des Empfangslinks. Bitte versuchen Sie es erneut.",
"aliasCreateFailed": "Fehler beim Erstellen des Alias. Bitte versuchen Sie es erneut."
"aliasCreateFailed": "Fehler beim Erstellen des Alias. Bitte versuchen Sie es erneut.",
"passwordUpdateFailed": "Fehler beim Aktualisieren des Passwortschutzes"
},
"delete": {
"title": "Empfangslink löschen",
@@ -1269,11 +1367,14 @@
"linkInactive": "Dieser Link ist inaktiv.",
"linkExpired": "Dieser Link ist abgelaufen.",
"uploadFailed": "Fehler beim Hochladen der Datei",
"retry": "Wiederholen",
"fileTooLarge": "Datei zu groß. Maximale Größe: {maxSize}",
"fileTypeNotAllowed": "Dateityp nicht erlaubt. Erlaubte Typen: {allowedTypes}",
"maxFilesExceeded": "Maximal {maxFiles} Dateien erlaubt",
"selectAtLeastOneFile": "Wählen Sie mindestens eine Datei aus",
"provideNameOrEmail": "Geben Sie Ihren Namen oder E-Mail an"
"provideNameOrEmail": "Geben Sie Ihren Namen oder E-Mail an",
"provideEmailRequired": "E -Mail ist erforderlich",
"provideNameRequired": "Name ist erforderlich"
},
"fileDropzone": {
"dragActive": "Dateien hier ablegen",
@@ -1296,7 +1397,9 @@
"descriptionLabel": "Beschreibung (optional)",
"descriptionPlaceholder": "Fügen Sie eine Beschreibung zu den Dateien hinzu...",
"uploadButton": "{count} Datei(en) senden",
"uploading": "Wird hochgeladen..."
"uploading": "Wird hochgeladen...",
"emailLabelOptional": "E-Mail (optional)",
"nameLabelOptional": "Name (optional)"
},
"success": {
"title": "Dateien erfolgreich hochgeladen! 🎉",
@@ -1334,12 +1437,15 @@
"cancel": "Abbrechen",
"preview": "Vorschau",
"download": "Herunterladen",
"delete": "Löschen"
"delete": "Löschen",
"copyToMyFiles": "Kopieren Sie in meine Dateien",
"copying": "Kopieren..."
},
"editField": {
"saveChanges": "Änderungen speichern",
"cancelEdit": "Bearbeitung abbrechen"
}
}
},
"defaultLinkName": "Empfangene Dateien"
}
}

View File

@@ -202,6 +202,8 @@
"login": {
"welcome": "Welcome to",
"signInToContinue": "Sign in to continue",
"emailOrUsernameLabel": "Email or Username",
"emailOrUsernamePlaceholder": "Enter your email or username",
"emailLabel": "Email Address",
"emailPlaceholder": "Enter your email",
"passwordLabel": "Password",
@@ -463,6 +465,24 @@
"title": "Sender Email",
"description": "Sender email address"
},
"smtpSecure": {
"title": "Connection Security",
"description": "SMTP connection security method - Auto (recommended), SSL, STARTTLS, or None (insecure)",
"options": {
"auto": "Auto (Recommended)",
"ssl": "SSL (Port 465)",
"tls": "STARTTLS (Port 587)",
"none": "None (Insecure)"
}
},
"smtpNoAuth": {
"title": "No Authentication",
"description": "Enable this for internal servers that don't require username/password (hides auth fields)"
},
"testSmtp": {
"title": "Test SMTP Connection",
"description": "Test if the SMTP configuration is valid"
},
"maxLoginAttempts": {
"title": "Maximum Login Attempts",
"description": "Maximum number of login attempts before blocking"
@@ -529,7 +549,9 @@
}
},
"buttons": {
"save": "Save {group}"
"save": "Save {group}",
"testSmtp": "Test Connection",
"testing": "Testing..."
},
"errors": {
"loadFailed": "Failed to load settings",
@@ -537,14 +559,22 @@
},
"messages": {
"noChanges": "No changes to save",
"updateSuccess": "{group} settings updated successfully"
"updateSuccess": "{group} settings updated successfully",
"smtpTestSuccess": "SMTP connection successful! Your email configuration is working correctly.",
"smtpTestFailed": "SMTP connection failed: {error}",
"smtpTestGenericError": "Failed to test SMTP connection. Please check your settings and try again.",
"smtpNotEnabled": "SMTP is not enabled. Please enable SMTP first.",
"smtpMissingHostPort": "Please fill in SMTP Host and Port before testing.",
"smtpMissingAuth": "Please fill in SMTP Username and Password, or enable 'No Authentication' option."
},
"title": "Settings",
"breadcrumb": "Settings",
"pageTitle": "Settings",
"tooltips": {
"oidcScope": "Enter a scope and press Enter to add",
"oidcAdminEmailDomains": "Enter a domain and press Enter to add"
"oidcAdminEmailDomains": "Enter a domain and press Enter to add",
"testSmtp": "Tests the SMTP connection with the values currently entered in the form. To make changes permanent, remember to save your settings after testing.",
"defaultPlaceholder": "Enter and press Enter"
},
"redirectUri": {
"placeholder": "https://mysite.com",
@@ -573,6 +603,7 @@
"created": "Created: {date}",
"expires": "Expires: {date}"
},
"downloadAll": "Download All",
"notFound": {
"title": "Share Not Found",
"description": "This share may have been deleted or expired."
@@ -712,7 +743,16 @@
"notifyError": "Failed to notify recipients",
"bulkDeleteError": "Failed to delete shares",
"bulkDeleteLoading": "Deleting {count, plural, =1 {1 share} other {# shares}}...",
"bulkDeleteSuccess": "{count, plural, =1 {1 share deleted successfully} other {# shares deleted successfully}}"
"bulkDeleteSuccess": "{count, plural, =1 {1 share deleted successfully} other {# shares deleted successfully}}",
"downloadSuccess": "Download started successfully",
"downloadError": "Failed to download share files",
"noFilesToDownload": "No files available to download",
"creatingZip": "Creating ZIP file...",
"zipDownloadSuccess": "ZIP file downloaded successfully",
"zipDownloadError": "Failed to create ZIP file",
"singleShareZipName": "{shareName}_files.zip",
"multipleSharesZipName": "{count}_shares_files.zip",
"defaultShareName": "Share"
},
"shares": {
"errors": {
@@ -775,9 +815,12 @@
"editLink": "Edit Link",
"copyLink": "Copy Link",
"notifyRecipients": "Notify Recipients",
"downloadShareFiles": "Download All Files",
"delete": "Delete"
},
"bulkActions": {
"actions": "Actions",
"download": "Download Selected",
"delete": "Delete",
"selected": "{count, plural, =1 {1 share selected} other {# shares selected}}"
},
@@ -788,7 +831,15 @@
"title": "Storage Usage",
"ariaLabel": "Storage usage progress bar",
"used": "used",
"available": "available"
"available": "available",
"loading": "Loading...",
"retry": "Retry",
"errors": {
"title": "Storage information unavailable",
"detectionFailed": "Unable to detect disk space. This may be due to system configuration issues or insufficient permissions.",
"serverError": "Server error occurred while retrieving storage information. Please try again later.",
"unknown": "An unexpected error occurred while loading storage information."
}
},
"theme": {
"toggle": "Toggle theme",
@@ -806,6 +857,7 @@
"uploadProgress": "Upload progress",
"upload": "Upload",
"startUploads": "Start Uploads",
"retry": "Retry",
"finish": "Finish",
"success": "File uploaded successfully",
"allSuccess": "{count, plural, =1 {File uploaded successfully} other {# files uploaded successfully}}",
@@ -901,6 +953,7 @@
"passwordLength": "Password must be at least 8 characters long",
"passwordsMatch": "Passwords must match",
"emailRequired": "Email is required",
"emailOrUsernameRequired": "Email or username is required",
"passwordRequired": "Password is required",
"passwordMinLength": "Password must be at least 6 characters",
"nameRequired": "Name is required",
@@ -1000,7 +1053,15 @@
"noFilesLimit": "No file limit",
"noSizeLimit": "No size limit",
"allFileTypes": "All file types",
"fileTypesHelp": "Enter extensions without dots, separated by space, comma, dash or pipe"
"fileTypesHelp": "Enter extensions without dots, separated by space, comma, dash or pipe",
"fieldRequirements": "Field Requirements",
"nameFieldRequired": "Name Field",
"emailFieldRequired": "Email Field",
"fieldOptions": {
"hidden": "Hidden",
"optional": "Optional",
"required": "Required"
}
},
"card": {
"untitled": "Untitled Link",
@@ -1145,7 +1206,9 @@
},
"actions": {
"preview": "Preview",
"download": "Download"
"download": "Download",
"copyToMyFiles": "Copy to my files",
"copying": "Copying..."
},
"uploadedBy": "Uploaded by {name}",
"anonymous": "Anonymous",
@@ -1153,7 +1216,27 @@
"downloadError": "Error downloading file",
"editSuccess": "File updated successfully",
"editError": "Error updating file",
"previewNotAvailable": "Preview not available"
"previewNotAvailable": "Preview not available",
"copySuccess": "File copied to your files successfully",
"copyError": "Error copying file to your files",
"deleteSuccess": "File deleted successfully",
"deleteError": "Error deleting file",
"bulkCopySuccess": "{count, plural, =1 {1 file copied to your files successfully} other {# files copied to your files successfully}}",
"bulkDeleteSuccess": "{count, plural, =1 {1 file deleted successfully} other {# files deleted successfully}}",
"bulkCopyProgress": "Copying {count, plural, =1 {1 file} other {# files}} to your files...",
"bulkDeleteProgress": "Deleting {count, plural, =1 {1 file} other {# files}}...",
"bulkDeleteConfirmTitle": "Delete Selected Files",
"bulkDeleteConfirmMessage": "Are you sure you want to delete {count, plural, =1 {this file} other {these # files}}? This action cannot be undone.",
"bulkDeleteConfirmButton": "Delete {count, plural, =1 {File} other {Files}}",
"bulkActions": {
"selected": "{count, plural, =1 {1 file selected} other {# files selected}}",
"actions": "Actions",
"download": "Download Selected",
"copyToMyFiles": "Copy Selected to My Files",
"delete": "Delete Selected"
},
"selectAll": "Select all",
"selectFile": "Select file {fileName}"
}
},
"form": {
@@ -1214,6 +1297,18 @@
"passwordHelp": "Password must be at least 4 characters",
"passwordPlaceholder": "Enter a password to protect the link"
},
"nameFieldRequired": {
"label": "Name Field Requirement",
"description": "Configure if the uploader name field should be shown and if it's required"
},
"emailFieldRequired": {
"label": "Email Field Requirement",
"description": "Configure if the uploader email field should be shown and if it's required"
},
"fieldRequirements": {
"title": "Field Requirements",
"description": "Configure which fields are shown in the upload form"
},
"submit": "Create Receive Link"
},
"messages": {
@@ -1224,14 +1319,18 @@
"deleteSuccess": "Receive link deleted successfully!",
"aliasCreated": "Alias created successfully!",
"activateSuccess": "Receive link activated successfully!",
"deactivateSuccess": "Receive link deactivated successfully!"
"deactivateSuccess": "Receive link deactivated successfully!",
"passwordProtectionEnabled": "Password protection enabled successfully!",
"passwordProtectionDisabled": "Password protection removed successfully!"
},
"defaultLinkName": "Received Files",
"errors": {
"loadFailed": "Failed to load receive links",
"createFailed": "Failed to create receive link. Please try again.",
"updateFailed": "Failed to update receive link. Please try again.",
"deleteFailed": "Failed to delete receive link. Please try again.",
"aliasCreateFailed": "Failed to create alias. Please try again."
"aliasCreateFailed": "Failed to create alias. Please try again.",
"passwordUpdateFailed": "Failed to update password protection"
},
"delete": {
"title": "Delete receive link",
@@ -1269,11 +1368,14 @@
"linkInactive": "This link is inactive.",
"linkExpired": "This link has expired.",
"uploadFailed": "Error uploading file",
"retry": "Retry",
"fileTooLarge": "File too large. Maximum size: {maxSize}",
"fileTypeNotAllowed": "File type not allowed. Accepted types: {allowedTypes}",
"maxFilesExceeded": "Maximum of {maxFiles} files allowed",
"selectAtLeastOneFile": "Select at least one file",
"provideNameOrEmail": "Please provide your name or email"
"provideNameOrEmail": "Please provide your name or email",
"provideNameRequired": "Name is required",
"provideEmailRequired": "Email is required"
},
"fileDropzone": {
"dragActive": "Drop files here",
@@ -1290,8 +1392,10 @@
},
"form": {
"nameLabel": "Name",
"nameLabelOptional": "Name (optional)",
"namePlaceholder": "Your name",
"emailLabel": "Email",
"emailLabelOptional": "Email (optional)",
"emailPlaceholder": "your@email.com",
"descriptionLabel": "Description (optional)",
"descriptionPlaceholder": "Add a description to the files...",
@@ -1334,7 +1438,9 @@
"cancel": "Cancel",
"preview": "Preview",
"download": "Download",
"delete": "Delete"
"delete": "Delete",
"copyToMyFiles": "Copy to my files",
"copying": "Copying..."
},
"editField": {
"saveChanges": "Save changes",

View File

@@ -202,6 +202,8 @@
"login": {
"welcome": "Bienvenido a",
"signInToContinue": "Inicia sesión para continuar",
"emailOrUsernameLabel": "Correo electrónico o nombre de usuario",
"emailOrUsernamePlaceholder": "Introduce tu correo electrónico o nombre de usuario",
"emailLabel": "Dirección de correo electrónico",
"emailPlaceholder": "Introduce tu correo electrónico",
"passwordLabel": "Contraseña",
@@ -526,10 +528,30 @@
"oidcAdminEmailDomains": {
"title": "Dominios de Correo Admin",
"description": "Dominios de correo que reciben privilegios de administrador automáticamente"
},
"testSmtp": {
"title": "Probar Conexión SMTP",
"description": "Probar si la configuración SMTP es válida"
},
"smtpNoAuth": {
"title": "Sin Autenticación",
"description": "Habilitar para servidores internos que no requieren usuario/contraseña (oculta los campos de autenticación)"
},
"smtpSecure": {
"title": "Seguridad de Conexión",
"description": "Método de seguridad de conexión SMTP - Auto (recomendado), SSL, STARTTLS o Ninguno (inseguro)",
"options": {
"auto": "Auto (Recomendado)",
"ssl": "SSL (Puerto 465)",
"tls": "STARTTLS (Puerto 587)",
"none": "Ninguno (Inseguro)"
}
}
},
"buttons": {
"save": "Guardar {group}"
"save": "Guardar {group}",
"testSmtp": "Probar Conexión",
"testing": "Probando..."
},
"errors": {
"loadFailed": "Error al cargar la configuración",
@@ -537,14 +559,22 @@
},
"messages": {
"noChanges": "No hay cambios para guardar",
"updateSuccess": "Configuración de {group} actualizada exitosamente"
"updateSuccess": "Configuración de {group} actualizada exitosamente",
"smtpTestSuccess": "¡Conexión SMTP exitosa! Tu configuración de correo funciona correctamente.",
"smtpTestFailed": "Falló la conexión SMTP: {error}",
"smtpTestGenericError": "Error al probar la conexión SMTP. Por favor revisa tu configuración e inténtalo de nuevo.",
"smtpMissingAuth": "Por favor ingresa el Usuario y Contraseña SMTP, o habilita la opción 'Sin Autenticación'.",
"smtpMissingHostPort": "Por favor ingresa el Host y Puerto SMTP antes de probar.",
"smtpNotEnabled": "SMTP no está habilitado. Por favor habilita SMTP primero."
},
"title": "Configuración",
"breadcrumb": "Configuración",
"pageTitle": "Configuración",
"tooltips": {
"oidcScope": "Ingrese un ámbito y presione Enter para agregar",
"oidcAdminEmailDomains": "Ingrese un dominio y presione Enter para agregar"
"oidcAdminEmailDomains": "Ingrese un dominio y presione Enter para agregar",
"testSmtp": "Prueba la conexión SMTP con los valores actualmente ingresados en el formulario. Para hacer los cambios permanentes, recuerda guardar la configuración después de probar.",
"defaultPlaceholder": "Ingrese y presione Enter"
},
"redirectUri": {
"placeholder": "https://misitio.com",
@@ -577,7 +607,8 @@
"title": "Compartición no encontrada",
"description": "Esta compartición puede haber sido eliminada o haber expirado."
},
"pageTitle": "Compartición"
"pageTitle": "Compartición",
"downloadAll": "Descargar Todo"
},
"shareActions": {
"deleteTitle": "Eliminar Compartir",
@@ -654,7 +685,16 @@
"securityUpdateError": "Error al actualizar configuración de seguridad",
"expirationUpdateError": "Error al actualizar configuración de expiración",
"securityUpdateSuccess": "Configuración de seguridad actualizada exitosamente",
"expirationUpdateSuccess": "Configuración de expiración actualizada exitosamente"
"expirationUpdateSuccess": "Configuración de expiración actualizada exitosamente",
"creatingZip": "Creación de archivo zip ...",
"defaultShareName": "Compartir",
"downloadError": "No se pudo descargar archivos compartidos",
"downloadSuccess": "Descargar comenzó con éxito",
"multipleSharesZipName": "{Count} _shares_files.zip",
"noFilesToDownload": "No hay archivos disponibles para descargar",
"singleShareZipName": "{Sharename} _files.zip",
"zipDownloadError": "No se pudo crear un archivo zip",
"zipDownloadSuccess": "Archivo zip descargado correctamente"
},
"shares": {
"errors": {
@@ -717,11 +757,14 @@
"editLink": "Editar Enlace",
"copyLink": "Copiar Enlace",
"notifyRecipients": "Notificar Destinatarios",
"delete": "Eliminar"
"delete": "Eliminar",
"downloadShareFiles": "Descargar todos los archivos"
},
"bulkActions": {
"delete": "Eliminar",
"selected": "{count, plural, =1 {1 compartido seleccionado} other {# compartidos seleccionados}}"
"selected": "{count, plural, =1 {1 compartido seleccionado} other {# compartidos seleccionados}}",
"actions": "Comportamiento",
"download": "Descargar Seleccionados"
},
"selectAll": "Seleccionar todo",
"selectShare": "Seleccionar compartido {shareName}"
@@ -730,7 +773,15 @@
"title": "Uso de almacenamiento",
"ariaLabel": "Barra de progreso del uso de almacenamiento",
"used": "usados",
"available": "disponibles"
"available": "disponible",
"loading": "Cargando...",
"retry": "Reintentar",
"errors": {
"title": "Información de almacenamiento no disponible",
"detectionFailed": "No se pudo detectar el espacio en disco. Esto puede deberse a problemas de configuración del sistema o permisos insuficientes.",
"serverError": "Ocurrió un error del servidor al recuperar la información de almacenamiento. Por favor, inténtelo de nuevo más tarde.",
"unknown": "Ocurrió un error inesperado al cargar la información de almacenamiento."
}
},
"theme": {
"toggle": "Cambiar tema",
@@ -748,6 +799,7 @@
"uploadProgress": "Progreso de la subida",
"upload": "Subir",
"startUploads": "Iniciar Subidas",
"retry": "Reintentar",
"finish": "Finalizar",
"success": "Archivo subido exitosamente",
"allSuccess": "{count, plural, =1 {Archivo subido exitosamente} other {# archivos subidos exitosamente}}",
@@ -844,6 +896,7 @@
"passwordLength": "La contraseña debe tener al menos 8 caracteres",
"passwordsMatch": "Las contraseñas no coinciden",
"emailRequired": "Se requiere el correo electrónico",
"emailOrUsernameRequired": "Se requiere el correo electrónico o nombre de usuario",
"passwordRequired": "Se requiere la contraseña",
"nameRequired": "El nombre es obligatorio",
"required": "Este campo es obligatorio"
@@ -1000,7 +1053,15 @@
"noFilesLimit": "Sin límite de archivos",
"noSizeLimit": "Sin límite de tamaño",
"allFileTypes": "Todos los tipos de archivo",
"fileTypesHelp": "Escribe las extensiones sin punto, separadas por espacio, coma, guion o barra vertical"
"fileTypesHelp": "Escribe las extensiones sin punto, separadas por espacio, coma, guion o barra vertical",
"emailFieldRequired": "Campo de correo electrónico",
"fieldOptions": {
"hidden": "Oculto",
"optional": "Opcional",
"required": "Obligatorio"
},
"fieldRequirements": "Requisitos de campo",
"nameFieldRequired": "Campo de nombre"
},
"card": {
"untitled": "Enlace sin título",
@@ -1145,7 +1206,9 @@
},
"actions": {
"preview": "Vista previa",
"download": "Descargar"
"download": "Descargar",
"copyToMyFiles": "Copiar a mis archivos",
"copying": "Proceso de copiar..."
},
"uploadedBy": "Enviado por {name}",
"anonymous": "Anónimo",
@@ -1153,7 +1216,27 @@
"downloadError": "Error al descargar archivo",
"editSuccess": "Archivo actualizado con éxito",
"editError": "Error al actualizar archivo",
"previewNotAvailable": "Vista previa no disponible"
"previewNotAvailable": "Vista previa no disponible",
"copyError": "Error de copiar el archivo a sus archivos",
"copySuccess": "Archivo copiado en sus archivos correctamente",
"bulkActions": {
"selected": "{count, plural, =1 {1 archivo seleccionado} other {# archivos seleccionados}}",
"actions": "Acciones",
"download": "Descargar Seleccionados",
"copyToMyFiles": "Copiar Seleccionados a Mis Archivos",
"delete": "Eliminar Seleccionados"
},
"bulkCopyProgress": "Copiando {count, plural, =1 {1 archivo} other {# archivos}} a tus archivos...",
"bulkCopySuccess": "{count, plural, =1 {1 archivo copiado a tus archivos correctamente} other {# archivos copiados a tus archivos correctamente}}",
"bulkDeleteConfirmButton": "Eliminar {count, plural, =1 {Archivo} other {Archivos}}",
"bulkDeleteConfirmMessage": "¿Estás seguro de que quieres eliminar {count, plural, =1 {este archivo} other {estos # archivos}}? Esta acción no se puede deshacer.",
"bulkDeleteConfirmTitle": "Eliminar Archivos Seleccionados",
"bulkDeleteProgress": "Eliminando {count, plural, =1 {1 archivo} other {# archivos}}...",
"bulkDeleteSuccess": "{count, plural, =1 {1 archivo eliminado correctamente} other {# archivos eliminados correctamente}}",
"selectAll": "Seleccionar todo",
"selectFile": "Seleccionar archivo {fileName}",
"deleteError": "Error al eliminar el archivo",
"deleteSuccess": "Archivo eliminado correctamente"
}
},
"form": {
@@ -1214,7 +1297,19 @@
"passwordHelp": "La contraseña debe tener al menos 4 caracteres",
"passwordPlaceholder": "Ingresa una contraseña para proteger el enlace"
},
"submit": "Crear Enlace de Recepción"
"submit": "Crear Enlace de Recepción",
"emailFieldRequired": {
"label": "Requisito de campo de correo electrónico",
"description": "Configurar si se debe mostrar el campo de correo electrónico del cargador y si es necesario"
},
"fieldRequirements": {
"title": "Requisitos de campo",
"description": "Configurar qué campos se muestran en el formulario de carga"
},
"nameFieldRequired": {
"label": "Requisito de campo de nombre",
"description": "Configurar si se debe mostrar el campo Nombre del cargador y si es necesario"
}
},
"messages": {
"created": "¡Enlace de recepción creado con éxito!",
@@ -1224,14 +1319,17 @@
"deleteSuccess": "¡Enlace de recepción eliminado con éxito!",
"aliasCreated": "¡Alias creado con éxito!",
"activateSuccess": "¡Enlace de recepción activado con éxito!",
"deactivateSuccess": "¡Enlace de recepción desactivado con éxito!"
"deactivateSuccess": "¡Enlace de recepción desactivado con éxito!",
"passwordProtectionDisabled": "¡Protección por contraseña eliminada con éxito!",
"passwordProtectionEnabled": "¡Protección por contraseña habilitada con éxito!"
},
"errors": {
"loadFailed": "Error al cargar enlaces de recepción",
"createFailed": "Error al crear enlace de recepción. Inténtalo de nuevo.",
"updateFailed": "Error al actualizar enlace de recepción. Inténtalo de nuevo.",
"deleteFailed": "Error al eliminar enlace de recepción. Inténtalo de nuevo.",
"aliasCreateFailed": "Error al crear alias. Inténtalo de nuevo."
"aliasCreateFailed": "Error al crear alias. Inténtalo de nuevo.",
"passwordUpdateFailed": "Error al actualizar la protección por contraseña"
},
"delete": {
"title": "Eliminar enlace de recepción",
@@ -1269,11 +1367,14 @@
"linkInactive": "Este enlace está inactivo.",
"linkExpired": "Este enlace ha expirado.",
"uploadFailed": "Error al subir archivo",
"retry": "Reintentar",
"fileTooLarge": "Archivo demasiado grande. Tamaño máximo: {maxSize}",
"fileTypeNotAllowed": "Tipo de archivo no permitido. Tipos aceptados: {allowedTypes}",
"maxFilesExceeded": "Máximo de {maxFiles} archivos permitidos",
"selectAtLeastOneFile": "Selecciona al menos un archivo",
"provideNameOrEmail": "Proporciona tu nombre o correo electrónico"
"provideNameOrEmail": "Proporciona tu nombre o correo electrónico",
"provideEmailRequired": "Se requiere correo electrónico",
"provideNameRequired": "Se requiere el nombre"
},
"fileDropzone": {
"dragActive": "Suelta los archivos aquí",
@@ -1296,7 +1397,9 @@
"descriptionLabel": "Descripción (opcional)",
"descriptionPlaceholder": "Añade una descripción a los archivos...",
"uploadButton": "Enviar {count} archivo(s)",
"uploading": "Enviando..."
"uploading": "Enviando...",
"emailLabelOptional": "Correo electrónico (opcional)",
"nameLabelOptional": "Nombre (opcional)"
},
"success": {
"title": "¡Archivos enviados con éxito! 🎉",
@@ -1334,12 +1437,15 @@
"cancel": "Cancelar",
"preview": "Vista previa",
"download": "Descargar",
"delete": "Eliminar"
"delete": "Eliminar",
"copyToMyFiles": "Copiar a mis archivos",
"copying": "Proceso de copiar..."
},
"editField": {
"saveChanges": "Guardar cambios",
"cancelEdit": "Cancelar edición"
}
}
},
"defaultLinkName": "Archivos recibidos"
}
}

View File

@@ -202,6 +202,8 @@
"login": {
"welcome": "Bienvenue à",
"signInToContinue": "Connectez-vous pour continuer",
"emailOrUsernameLabel": "Email ou Nom d'utilisateur",
"emailOrUsernamePlaceholder": "Entrez votre email ou nom d'utilisateur",
"emailLabel": "Adresse e-mail",
"emailPlaceholder": "Entrez votre e-mail",
"passwordLabel": "Mot de passe",
@@ -529,10 +531,30 @@
"oidcAdminEmailDomains": {
"title": "Domaines Email Admin",
"description": "Domaines email qui reçoivent automatiquement les privilèges d'administrateur"
},
"testSmtp": {
"title": "[TO_TRANSLATE] Test SMTP Connection",
"description": "[TO_TRANSLATE] Test if the SMTP configuration is valid"
},
"smtpNoAuth": {
"title": "Pas d'Authentification",
"description": "Activez cette option pour les serveurs internes qui ne nécessitent pas de nom d'utilisateur/mot de passe (masque les champs d'authentification)"
},
"smtpSecure": {
"title": "Sécurité de la Connexion",
"description": "Méthode de sécurité de connexion SMTP - Auto (recommandé), SSL, STARTTLS, ou Aucune (non sécurisé)",
"options": {
"auto": "Auto (Recommandé)",
"ssl": "SSL (Port 465)",
"tls": "STARTTLS (Port 587)",
"none": "Aucune (Non sécurisé)"
}
}
},
"buttons": {
"save": "Enregistrer {group}"
"save": "Enregistrer {group}",
"testSmtp": "Tester la Connexion",
"testing": "Test en cours..."
},
"errors": {
"loadFailed": "Échec du chargement des paramètres",
@@ -540,11 +562,19 @@
},
"messages": {
"noChanges": "Aucun changement à enregistrer",
"updateSuccess": "Paramètres {group} mis à jour avec succès"
"updateSuccess": "Paramètres {group} mis à jour avec succès",
"smtpTestFailed": "La connexion SMTP a échoué : {error}",
"smtpTestGenericError": "Échec du test de connexion SMTP. Veuillez vérifier vos paramètres et réessayer.",
"smtpTestSuccess": "Connexion SMTP réussie ! Votre configuration email fonctionne correctement.",
"smtpMissingAuth": "Veuillez remplir le nom d'utilisateur et le mot de passe SMTP, ou activer l'option 'Pas d'Authentification'.",
"smtpMissingHostPort": "Veuillez remplir l'hôte et le port SMTP avant de tester.",
"smtpNotEnabled": "SMTP n'est pas activé. Veuillez d'abord activer SMTP."
},
"tooltips": {
"oidcScope": "Entrez une portée et appuyez sur Entrée pour l'ajouter",
"oidcAdminEmailDomains": "Entrez un domaine et appuyez sur Entrée pour l'ajouter"
"oidcAdminEmailDomains": "Entrez un domaine et appuyez sur Entrée pour l'ajouter",
"testSmtp": "Teste la connexion SMTP avec les valeurs actuellement saisies dans le formulaire. Pour rendre les modifications permanentes, n'oubliez pas d'enregistrer vos paramètres après le test.",
"defaultPlaceholder": "Entrez et appuyez sur Entrée"
},
"redirectUri": {
"placeholder": "https://monsite.com",
@@ -577,7 +607,8 @@
"title": "Partage Non Trouvé",
"description": "Ce partage a peut-être été supprimé ou a expiré."
},
"pageTitle": "Partage"
"pageTitle": "Partage",
"downloadAll": "Tout Télécharger"
},
"shareActions": {
"deleteTitle": "Supprimer le Partage",
@@ -654,7 +685,16 @@
"securityUpdateError": "Échec de mise à jour des paramètres de sécurité",
"expirationUpdateError": "Échec de mise à jour des paramètres d'expiration",
"securityUpdateSuccess": "Paramètres de sécurité mis à jour avec succès",
"expirationUpdateSuccess": "Paramètres d'expiration mis à jour avec succès"
"expirationUpdateSuccess": "Paramètres d'expiration mis à jour avec succès",
"creatingZip": "Création d'un fichier zip ...",
"defaultShareName": "Partager",
"downloadError": "Échec de téléchargement des fichiers de partage",
"downloadSuccess": "Le téléchargement a commencé avec succès",
"multipleSharesZipName": "{count} _shares_files.zip",
"noFilesToDownload": "Aucun fichier disponible en téléchargement",
"singleShareZipName": "{sharename} _files.zip",
"zipDownloadError": "Échec de la création du fichier zip",
"zipDownloadSuccess": "Fichier zip téléchargé avec succès"
},
"shares": {
"errors": {
@@ -717,11 +757,14 @@
"editLink": "Modifier le Lien",
"copyLink": "Copier le Lien",
"notifyRecipients": "Notifier les Destinataires",
"delete": "Supprimer"
"delete": "Supprimer",
"downloadShareFiles": "Télécharger tous les fichiers"
},
"bulkActions": {
"delete": "Supprimer",
"selected": "{count, plural, =1 {1 partage sélectionné} other {# partages sélectionnés}}"
"selected": "{count, plural, =1 {1 partage sélectionné} other {# partages sélectionnés}}",
"actions": "Actes",
"download": "Télécharger sélectionné"
},
"selectAll": "Tout sélectionner",
"selectShare": "Sélectionner le partage {shareName}"
@@ -730,7 +773,15 @@
"title": "Utilisation du Stockage",
"ariaLabel": "Barre de progression de l'utilisation du stockage",
"used": "utilisé",
"available": "disponible"
"available": "disponible",
"loading": "Chargement...",
"retry": "Réessayer",
"errors": {
"title": "Informations de stockage non disponibles",
"detectionFailed": "Impossible de détecter l'espace disque. Cela peut être dû à des problèmes de configuration système ou à des permissions insuffisantes.",
"serverError": "Une erreur serveur s'est produite lors de la récupération des informations de stockage. Veuillez réessayer plus tard.",
"unknown": "Une erreur inattendue s'est produite lors du chargement des informations de stockage."
}
},
"theme": {
"toggle": "Changer le thème",
@@ -748,6 +799,7 @@
"uploadProgress": "Progression du téléchargement",
"upload": "Télécharger",
"startUploads": "Commencer les Téléchargements",
"retry": "Réessayer",
"finish": "Terminer",
"success": "Fichier téléchargé avec succès",
"allSuccess": "{count, plural, =1 {Fichier téléchargé avec succès} other {# fichiers téléchargés avec succès}}",
@@ -844,6 +896,7 @@
"passwordLength": "Le mot de passe doit contenir au moins 8 caractères",
"passwordsMatch": "Les mots de passe ne correspondent pas",
"emailRequired": "L'email est requis",
"emailOrUsernameRequired": "L'email ou le nom d'utilisateur est requis",
"passwordRequired": "Le mot de passe est requis",
"nameRequired": "Nome é obrigatório",
"required": "Este campo é obrigatório"
@@ -1000,7 +1053,15 @@
"noFilesLimit": "Sans limite de fichiers",
"noSizeLimit": "Sans limite de taille",
"allFileTypes": "Tous types de fichiers",
"fileTypesHelp": "Saisissez les extensions sans point, séparées par espace, virgule, tiret ou barre verticale"
"fileTypesHelp": "Saisissez les extensions sans point, séparées par espace, virgule, tiret ou barre verticale",
"emailFieldRequired": "Champ de courrier électronique",
"fieldOptions": {
"hidden": "Masqué",
"optional": "Optionnel",
"required": "Obligatoire"
},
"fieldRequirements": "Exigences sur le terrain",
"nameFieldRequired": "Champ de nom"
},
"card": {
"untitled": "Lien sans titre",
@@ -1145,7 +1206,9 @@
},
"actions": {
"preview": "Aperçu",
"download": "Télécharger"
"download": "Télécharger",
"copyToMyFiles": "Copier dans mes fichiers",
"copying": "Copier..."
},
"uploadedBy": "Envoyé par {name}",
"anonymous": "Anonyme",
@@ -1153,7 +1216,27 @@
"downloadError": "Erreur lors du téléchargement",
"editSuccess": "Fichier mis à jour avec succès",
"editError": "Erreur lors de la mise à jour du fichier",
"previewNotAvailable": "Aperçu non disponible"
"previewNotAvailable": "Aperçu non disponible",
"copyError": "Erreur de copie du fichier dans vos fichiers",
"copySuccess": "Fichier copié dans vos fichiers avec succès",
"bulkActions": {
"selected": "{count, plural, =1 {1 fichier sélectionné} other {# fichiers sélectionnés}}",
"actions": "Actes",
"download": "Télécharger la sélection",
"copyToMyFiles": "Copier la sélection dans mes fichiers",
"delete": "Supprimer la sélection"
},
"bulkCopyProgress": "Copie de {count, plural, =1 {1 fichier} other {# fichiers}} dans vos fichiers...",
"bulkCopySuccess": "{count, plural, =1 {1 fichier copié dans vos fichiers avec succès} other {# fichiers copiés dans vos fichiers avec succès}}",
"bulkDeleteConfirmButton": "Supprimer {count, plural, =1 {le fichier} other {les fichiers}}",
"bulkDeleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer {count, plural, =1 {ce fichier} other {ces # fichiers}} ? Cette action est irréversible.",
"bulkDeleteConfirmTitle": "Supprimer les fichiers sélectionnés",
"bulkDeleteProgress": "Suppression de {count, plural, =1 {1 fichier} other {# fichiers}}...",
"bulkDeleteSuccess": "{count, plural, =1 {1 fichier supprimé avec succès} other {# fichiers supprimés avec succès}}",
"selectAll": "Tout sélectionner",
"selectFile": "Sélectionner le fichier {fileName}",
"deleteError": "Erreur lors de la suppression du fichier",
"deleteSuccess": "Fichier supprimé avec succès"
}
},
"form": {
@@ -1214,7 +1297,19 @@
"passwordHelp": "Le mot de passe doit contenir au moins 4 caractères",
"passwordPlaceholder": "Saisissez un mot de passe pour protéger le lien"
},
"submit": "Créer le Lien de Réception"
"submit": "Créer le Lien de Réception",
"emailFieldRequired": {
"label": "Exigence de champ de messagerie",
"description": "Configurez si le champ de messagerie du téléchargeur doit être affiché et s'il est requis"
},
"fieldRequirements": {
"title": "Exigences sur le terrain",
"description": "Configurer quels champs sont affichés dans le formulaire de téléchargement"
},
"nameFieldRequired": {
"label": "Exigence de champ de nom",
"description": "Configurer si le champ Nom du téléchargeur doit être affiché et s'il est requis"
}
},
"messages": {
"created": "Lien de réception créé avec succès !",
@@ -1224,14 +1319,17 @@
"deleteSuccess": "Lien de réception supprimé avec succès !",
"aliasCreated": "Alias créé avec succès !",
"activateSuccess": "Lien de réception activé avec succès !",
"deactivateSuccess": "Lien de réception désactivé avec succès !"
"deactivateSuccess": "Lien de réception désactivé avec succès !",
"passwordProtectionDisabled": "Protection par mot de passe supprimée avec succès !",
"passwordProtectionEnabled": "Protection par mot de passe activée avec succès !"
},
"errors": {
"loadFailed": "Échec du chargement des liens de réception",
"createFailed": "Échec de la création du lien de réception. Veuillez réessayer.",
"updateFailed": "Échec de la mise à jour du lien de réception. Veuillez réessayer.",
"deleteFailed": "Échec de la suppression du lien de réception. Veuillez réessayer.",
"aliasCreateFailed": "Échec de la création de l'alias. Veuillez réessayer."
"aliasCreateFailed": "Échec de la création de l'alias. Veuillez réessayer.",
"passwordUpdateFailed": "Échec de la mise à jour de la protection par mot de passe"
},
"delete": {
"title": "Supprimer le lien de réception",
@@ -1269,11 +1367,14 @@
"linkInactive": "Ce lien est inactif.",
"linkExpired": "Ce lien a expiré.",
"uploadFailed": "Erreur lors de l'envoi du fichier",
"retry": "Réessayer",
"fileTooLarge": "Fichier trop volumineux. Taille maximale : {maxSize}",
"fileTypeNotAllowed": "Type de fichier non autorisé. Types acceptés : {allowedTypes}",
"maxFilesExceeded": "Maximum de {maxFiles} fichiers autorisés",
"selectAtLeastOneFile": "Sélectionnez au moins un fichier",
"provideNameOrEmail": "Indiquez votre nom ou e-mail"
"provideNameOrEmail": "Indiquez votre nom ou e-mail",
"provideEmailRequired": "Un e-mail est requis",
"provideNameRequired": "Le nom est requis"
},
"fileDropzone": {
"dragActive": "Déposez les fichiers ici",
@@ -1296,7 +1397,9 @@
"descriptionLabel": "Description (optionnelle)",
"descriptionPlaceholder": "Ajoutez une description aux fichiers...",
"uploadButton": "Envoyer {count} fichier(s)",
"uploading": "Envoi en cours..."
"uploading": "Envoi en cours...",
"emailLabelOptional": "E-mail (facultatif)",
"nameLabelOptional": "Nom (facultatif)"
},
"success": {
"title": "Fichiers envoyés avec succès ! 🎉",
@@ -1334,12 +1437,15 @@
"cancel": "Annuler",
"preview": "Aperçu",
"download": "Télécharger",
"delete": "Supprimer"
"delete": "Supprimer",
"copyToMyFiles": "Copier dans mes fichiers",
"copying": "Copier..."
},
"editField": {
"saveChanges": "Enregistrer les modifications",
"cancelEdit": "Annuler la modification"
}
}
},
"defaultLinkName": "Fichiers reçus"
}
}

View File

@@ -202,6 +202,8 @@
"login": {
"welcome": "स्वागत है में",
"signInToContinue": "जारी रखने के लिए साइन इन करें",
"emailOrUsernameLabel": "ईमेल या उपयोगकर्ता नाम",
"emailOrUsernamePlaceholder": "अपना ईमेल या उपयोगकर्ता नाम दर्ज करें",
"emailLabel": "ईमेल पता",
"emailPlaceholder": "अपना ईमेल दर्ज करें",
"passwordLabel": "पासवर्ड",
@@ -526,10 +528,30 @@
"oidcAdminEmailDomains": {
"title": "एडमिन ईमेल डोमेन",
"description": "जिन ईमेल डोमेन को स्वचालित रूप से व्यवस्थापक विशेषाधिकार प्राप्त होंगे"
},
"testSmtp": {
"title": "SMTP कनेक्शन का परीक्षण करें",
"description": "जांचें कि SMTP कॉन्फ़िगरेशन मान्य है"
},
"smtpNoAuth": {
"title": "कोई प्रमाणीकरण नहीं",
"description": "आंतरिक सर्वर के लिए सक्षम करें जिन्हें उपयोगकर्ता नाम/पासवर्ड की आवश्यकता नहीं होती है (प्रमाणीकरण फ़ील्ड छिपाता है)"
},
"smtpSecure": {
"title": "कनेक्शन सुरक्षा",
"description": "SMTP कनेक्शन सुरक्षा विधि - स्वचालित (अनुशंसित), SSL, STARTTLS, या कोई नहीं (असुरक्षित)",
"options": {
"auto": "स्वचालित (अनुशंसित)",
"ssl": "SSL (पोर्ट 465)",
"tls": "STARTTLS (पोर्ट 587)",
"none": "कोई नहीं (असुरक्षित)"
}
}
},
"buttons": {
"save": "{group} सहेजें"
"save": "{group} सहेजें",
"testSmtp": "कनेक्शन का परीक्षण करें",
"testing": "परीक्षण किया जा रहा है..."
},
"errors": {
"loadFailed": "सेटिंग्स लोड करने में विफल",
@@ -537,14 +559,22 @@
},
"messages": {
"noChanges": "सहेजने के लिए कोई परिवर्तन नहीं",
"updateSuccess": "{group} सेटिंग्स सफलतापूर्वक अपडेट हुईं"
"updateSuccess": "{group} सेटिंग्स सफलतापूर्वक अपडेट हुईं",
"smtpTestFailed": "SMTP कनेक्शन विफल: {error}",
"smtpTestGenericError": "SMTP कनेक्शन का परीक्षण करने में विफल। कृपया अपनी सेटिंग्स जांचें और पुनः प्रयास करें।",
"smtpTestSuccess": "SMTP कनेक्शन सफल! आपका ईमेल कॉन्फ़िगरेशन सही ढंग से काम कर रहा है।",
"smtpMissingAuth": "कृपया SMTP उपयोगकर्ता नाम और पासवर्ड भरें, या 'कोई प्रमाणीकरण नहीं' विकल्प सक्षम करें।",
"smtpMissingHostPort": "परीक्षण से पहले कृपया SMTP होस्ट और पोर्ट भरें।",
"smtpNotEnabled": "SMTP सक्षम नहीं है। कृपया पहले SMTP सक्षम करें।"
},
"title": "सेटिंग्स",
"breadcrumb": "सेटिंग्स",
"pageTitle": "सेटिंग्स",
"tooltips": {
"oidcScope": "स्कोप जोड़ने के लिए एक स्कोप दर्ज करें और Enter दबाएं",
"oidcAdminEmailDomains": "डोमेन जोड़ने के लिए एक डोमेन दर्ज करें और Enter दबाएं"
"oidcAdminEmailDomains": "डोमेन जोड़ने के लिए एक डोमेन दर्ज करें और Enter दबाएं",
"testSmtp": "फॉर्म में वर्तमान में दर्ज मानों के साथ SMTP कनेक्शन का परीक्षण करता है। परिवर्तनों को स्थायी बनाने के लिए, परीक्षण के बाद अपनी सेटिंग्स को सहेजना याद रखें।",
"defaultPlaceholder": "दर्ज करें और Enter दबाएं"
},
"redirectUri": {
"placeholder": "https://mysite.com",
@@ -577,7 +607,8 @@
"title": "साझाकरण नहीं मिला",
"description": "यह साझाकरण हटा दिया गया हो सकता है या समाप्त हो चुका है।"
},
"pageTitle": "साझाकरण"
"pageTitle": "साझाकरण",
"downloadAll": "सभी डाउनलोड करें"
},
"shareActions": {
"deleteTitle": "साझाकरण हटाएं",
@@ -654,7 +685,16 @@
"securityUpdateError": "सुरक्षा सेटिंग्स अपडेट करने में विफल",
"expirationUpdateError": "समाप्ति सेटिंग्स अपडेट करने में विफल",
"securityUpdateSuccess": "सुरक्षा सेटिंग्स सफलतापूर्वक अपडेट हुईं",
"expirationUpdateSuccess": "समाप्ति सेटिंग्स सफलतापूर्वक अपडेट हुईं"
"expirationUpdateSuccess": "समाप्ति सेटिंग्स सफलतापूर्वक अपडेट हुईं",
"creatingZip": "ज़िप फ़ाइल बनाना ...",
"defaultShareName": "शेयर करना",
"downloadError": "शेयर फ़ाइलें डाउनलोड करने में विफल",
"downloadSuccess": "डाउनलोड सफलतापूर्वक शुरू हुआ",
"multipleSharesZipName": "{गिनती} _shares_files.zip",
"noFilesToDownload": "डाउनलोड करने के लिए कोई फाइलें उपलब्ध नहीं हैं",
"singleShareZipName": "{Sharename} _files.zip",
"zipDownloadError": "ज़िप फ़ाइल बनाने में विफल",
"zipDownloadSuccess": "ज़िप फ़ाइल सफलतापूर्वक डाउनलोड की गई"
},
"shares": {
"errors": {
@@ -717,11 +757,14 @@
"editLink": "लिंक संपादित करें",
"copyLink": "लिंक कॉपी करें",
"notifyRecipients": "प्राप्तकर्ताओं को सूचित करें",
"delete": "हटाएं"
"delete": "हटाएं",
"downloadShareFiles": "सभी फ़ाइलें डाउनलोड करें"
},
"bulkActions": {
"delete": "हटाएं",
"selected": "{count, plural, =1 {1 साझाकरण चयनित} other {# साझाकरण चयनित}}"
"selected": "{count, plural, =1 {1 साझाकरण चयनित} other {# साझाकरण चयनित}}",
"actions": "कार्रवाई",
"download": "चयनित डाउनलोड करें"
},
"selectAll": "सभी चुनें",
"selectShare": "साझाकरण {shareName} चुनें"
@@ -730,7 +773,15 @@
"title": "स्टोरेज उपयोग",
"ariaLabel": "स्टोरेज उपयोग प्रगति पट्टी",
"used": "उपयोग किया गया",
"available": "उपलब्ध"
"available": "उपलब्ध",
"loading": "लोड हो रहा है...",
"retry": "पुनः प्रयास करें",
"errors": {
"title": "स्टोरेज जानकारी अनुपलब्ध",
"detectionFailed": "डिस्क स्पेस का पता लगाने में असमर्थ। यह सिस्टम कॉन्फ़िगरेशन समस्याओं या अपर्याप्त अनुमतियों के कारण हो सकता है।",
"serverError": "स्टोरेज जानकारी प्राप्त करते समय सर्वर त्रुटि हुई। कृपया बाद में पुनः प्रयास करें।",
"unknown": "स्टोरेज जानकारी लोड करते समय एक अनपेक्षित त्रुटि हुई।"
}
},
"theme": {
"toggle": "थीम टॉगल करें",
@@ -748,6 +799,7 @@
"uploadProgress": "अपलोड प्रगति",
"upload": "अपलोड",
"startUploads": "अपलोड शुरू करें",
"retry": "पुनः प्रयास करें",
"finish": "समाप्त",
"success": "फ़ाइल सफलतापूर्वक अपलोड की गई",
"allSuccess": "{count, plural, =1 {फ़ाइल सफलतापूर्वक अपलोड की गई} other {# फ़ाइलें सफलतापूर्वक अपलोड की गईं}}",
@@ -844,6 +896,7 @@
"passwordLength": "पासवर्ड कम से कम 8 अक्षर का होना चाहिए",
"passwordsMatch": "पासवर्ड मेल नहीं खाते",
"emailRequired": "ईमेल आवश्यक है",
"emailOrUsernameRequired": "ईमेल या उपयोगकर्ता नाम आवश्यक है",
"passwordRequired": "पासवर्ड आवश्यक है",
"nameRequired": "नाम आवश्यक है",
"required": "यह फ़ील्ड आवश्यक है"
@@ -1000,7 +1053,15 @@
"noFilesLimit": "फ़ाइलों की कोई सीमा नहीं",
"noSizeLimit": "आकार की कोई सीमा नहीं",
"allFileTypes": "सभी फ़ाइल प्रकार",
"fileTypesHelp": "एक्सटेंशन बिना बिंदु के, स्पेस, कॉमा, हाइफन या पाइप से अलग करके टाइप करें"
"fileTypesHelp": "एक्सटेंशन बिना बिंदु के, स्पेस, कॉमा, हाइफन या पाइप से अलग करके टाइप करें",
"emailFieldRequired": "ईमेल क्षेत्र",
"fieldOptions": {
"hidden": "छिपा हुआ",
"optional": "वैकल्पिक",
"required": "आवश्यक"
},
"fieldRequirements": "क्षेत्र आवश्यकताएँ",
"nameFieldRequired": "नाम क्षेत्र"
},
"card": {
"untitled": "शीर्षकहीन लिंक",
@@ -1145,7 +1206,9 @@
},
"actions": {
"preview": "पूर्वावलोकन",
"download": "डाउनलोड"
"download": "डाउनलोड",
"copyToMyFiles": "मेरी फ़ाइलों में कॉपी करें",
"copying": "नकल ..."
},
"uploadedBy": "{name} द्वारा भेजा गया",
"anonymous": "अज्ञात",
@@ -1153,7 +1216,27 @@
"downloadError": "फ़ाइल डाउनलोड करने में त्रुटि",
"editSuccess": "फ़ाइल सफलतापूर्वक अपडेट की गई",
"editError": "फ़ाइल अपडेट करने में त्रुटि",
"previewNotAvailable": "पूर्वावलोकन उपलब्ध नहीं है"
"previewNotAvailable": "पूर्वावलोकन उपलब्ध नहीं है",
"copyError": "अपनी फ़ाइलों में फ़ाइल की नकल करने में त्रुटि",
"copySuccess": "आपकी फ़ाइलों को सफलतापूर्वक कॉपी की गई फ़ाइल",
"bulkActions": {
"selected": "{count, plural, =1 {1 फ़ाइल चयनित} other {# फ़ाइलें चयनित}}",
"actions": "कार्रवाइयां",
"download": "चयनित डाउनलोड करें",
"copyToMyFiles": "चयनित को मेरी फ़ाइलों में कॉपी करें",
"delete": "चयनित हटाएं"
},
"bulkCopyProgress": "{count, plural, =1 {1 फ़ाइल} other {# फ़ाइलें}} आपकी फ़ाइलों में कॉपी की जा रही हैं...",
"bulkCopySuccess": "{count, plural, =1 {1 फ़ाइल सफलतापूर्वक आपकी फ़ाइलों में कॉपी की गई} other {# फ़ाइलें सफलतापूर्वक आपकी फ़ाइलों में कॉपी की गईं}}",
"bulkDeleteConfirmButton": "{count, plural, =1 {फ़ाइल} other {फ़ाइलें}} हटाएं",
"bulkDeleteConfirmMessage": "क्या आप वाकई {count, plural, =1 {इस फ़ाइल} other {इन # फ़ाइलों}} को हटाना चाहते हैं? यह क्रिया पूर्ववत नहीं की जा सकती।",
"bulkDeleteConfirmTitle": "चयनित फ़ाइलें हटाएं",
"bulkDeleteProgress": "{count, plural, =1 {1 फ़ाइल} other {# फ़ाइलें}} हटाई जा रही हैं...",
"bulkDeleteSuccess": "{count, plural, =1 {1 फ़ाइल सफलतापूर्वक हटाई गई} other {# फ़ाइलें सफलतापूर्वक हटाई गईं}}",
"selectAll": "सभी चुनें",
"selectFile": "फ़ाइल {fileName} चुनें",
"deleteError": "फ़ाइल हटाने में त्रुटि",
"deleteSuccess": "फ़ाइल सफलतापूर्वक हटा दी गई"
}
},
"form": {
@@ -1214,7 +1297,19 @@
"passwordHelp": "पासवर्ड कम से कम 4 अक्षर का होना चाहिए",
"passwordPlaceholder": "लिंक की सुरक्षा के लिए पासवर्ड दर्ज करें"
},
"submit": "प्राप्ति लिंक बनाएं"
"submit": "प्राप्ति लिंक बनाएं",
"emailFieldRequired": {
"label": "ईमेल क्षेत्र की आवश्यकता",
"description": "कॉन्फ़िगर करें कि क्या अपलोडर ईमेल फ़ील्ड दिखाया जाना चाहिए और यदि आवश्यक है"
},
"fieldRequirements": {
"title": "क्षेत्र आवश्यकताएँ",
"description": "कॉन्फ़िगर करें कि कौन से फ़ील्ड अपलोड फॉर्म में दिखाए गए हैं"
},
"nameFieldRequired": {
"label": "नाम क्षेत्र की आवश्यकता",
"description": "कॉन्फ़िगर करें कि क्या अपलोडर नाम फ़ील्ड दिखाया जाना चाहिए और यदि आवश्यक है"
}
},
"messages": {
"created": "प्राप्ति लिंक सफलतापूर्वक बनाया गया!",
@@ -1224,14 +1319,17 @@
"deleteSuccess": "प्राप्ति लिंक सफलतापूर्वक हटाया गया!",
"aliasCreated": "उपनाम सफलतापूर्वक बनाया गया!",
"activateSuccess": "प्राप्ति लिंक सफलतापूर्वक सक्रिय किया गया!",
"deactivateSuccess": "प्राप्ति लिंक सफलतापूर्वक निष्क्रिय किया गया!"
"deactivateSuccess": "प्राप्ति लिंक सफलतापूर्वक निष्क्रिय किया गया!",
"passwordProtectionDisabled": "पासवर्ड सुरक्षा सफलतापूर्वक हटा दी गई!",
"passwordProtectionEnabled": "पासवर्ड सुरक्षा सफलतापूर्वक सक्षम की गई!"
},
"errors": {
"loadFailed": "प्राप्ति लिंक लोड करने में विफल",
"createFailed": "प्राप्ति लिंक बनाने में विफल। कृपया पुनः प्रयास करें।",
"updateFailed": "प्राप्ति लिंक अपडेट करने में विफल। कृपया पुनः प्रयास करें।",
"deleteFailed": "प्राप्ति लिंक हटाने में विफल। कृपया पुनः प्रयास करें।",
"aliasCreateFailed": "उपनाम बनाने में विफल। कृपया पुनः प्रयास करें।"
"aliasCreateFailed": "उपनाम बनाने में विफल। कृपया पुनः प्रयास करें।",
"passwordUpdateFailed": "पासवर्ड सुरक्षा अपडेट करने में विफल"
},
"delete": {
"title": "प्राप्ति लिंक हटाएं",
@@ -1269,11 +1367,14 @@
"linkInactive": "यह लिंक निष्क्रिय है।",
"linkExpired": "यह लिंक समाप्त हो गया है।",
"uploadFailed": "फ़ाइल अपलोड करने में त्रुटि",
"retry": "पुनः प्रयास करें",
"fileTooLarge": "फ़ाइल बहुत बड़ी है। अधिकतम आकार: {maxSize}",
"fileTypeNotAllowed": "फ़ाइल प्रकार अनुमत नहीं है। स्वीकृत प्रकार: {allowedTypes}",
"maxFilesExceeded": "अधिकतम {maxFiles} फ़ाइलें अनुमत हैं",
"selectAtLeastOneFile": "कम से कम एक फ़ाइल चुनें",
"provideNameOrEmail": "अपना नाम या ईमेल प्रदान करें"
"provideNameOrEmail": "अपना नाम या ईमेल प्रदान करें",
"provideEmailRequired": "ईमेल की जरूरत है",
"provideNameRequired": "नाम आवश्यक है"
},
"fileDropzone": {
"dragActive": "फ़ाइलें यहां छोड़ें",
@@ -1296,7 +1397,9 @@
"descriptionLabel": "विवरण (वैकल्पिक)",
"descriptionPlaceholder": "फ़ाइलों का विवरण जोड़ें...",
"uploadButton": "{count} फ़ाइल(ें) भेजें",
"uploading": "भेजा जा रहा है..."
"uploading": "भेजा जा रहा है...",
"emailLabelOptional": "ईमेल वैकल्पिक)",
"nameLabelOptional": "नाम: (वैकल्पिक)"
},
"success": {
"title": "फ़ाइलें सफलतापूर्वक भेजी गईं! 🎉",
@@ -1334,12 +1437,15 @@
"cancel": "रद्द करें",
"preview": "पूर्वावलोकन",
"download": "डाउनलोड",
"delete": "हटाएं"
"delete": "हटाएं",
"copyToMyFiles": "मेरी फ़ाइलों में कॉपी करें",
"copying": "नकल ..."
},
"editField": {
"saveChanges": "परिवर्तन सहेजें",
"cancelEdit": "संपादन रद्द करें"
}
}
},
"defaultLinkName": "प्राप्त फ़ाइलें"
}
}

View File

@@ -202,6 +202,8 @@
"login": {
"welcome": "Benvenuto in",
"signInToContinue": "Accedi per continuare",
"emailOrUsernameLabel": "Email o Nome utente",
"emailOrUsernamePlaceholder": "Inserisci la tua email o nome utente",
"emailLabel": "Indirizzo Email",
"emailPlaceholder": "Inserisci la tua email",
"passwordLabel": "Parola d'accesso",
@@ -526,10 +528,30 @@
"oidcAdminEmailDomains": {
"title": "Domini Email Admin",
"description": "Domini email che ricevono automaticamente i privilegi di amministratore"
},
"testSmtp": {
"title": "Test Connessione SMTP",
"description": "Verifica se la configurazione SMTP è valida"
},
"smtpNoAuth": {
"title": "Nessuna Autenticazione",
"description": "Abilita questa opzione per i server interni che non richiedono nome utente/password (nasconde i campi di autenticazione)"
},
"smtpSecure": {
"title": "Sicurezza Connessione",
"description": "Metodo di sicurezza della connessione SMTP - Auto (raccomandato), SSL, STARTTLS o Nessuno (non sicuro)",
"options": {
"auto": "Auto (Raccomandato)",
"ssl": "SSL (Porta 465)",
"tls": "STARTTLS (Porta 587)",
"none": "Nessuno (Non sicuro)"
}
}
},
"buttons": {
"save": "Salva {group}"
"save": "Salva {group}",
"testSmtp": "Prova Connessione",
"testing": "Verifica in corso..."
},
"errors": {
"loadFailed": "Errore durante il caricamento delle impostazioni",
@@ -537,14 +559,22 @@
},
"messages": {
"noChanges": "Nessuna modifica da salvare",
"updateSuccess": "Impostazioni {group} aggiornate con successo"
"updateSuccess": "Impostazioni {group} aggiornate con successo",
"smtpTestFailed": "Connessione SMTP fallita: {error}",
"smtpTestGenericError": "Impossibile testare la connessione SMTP. Controlla le impostazioni e riprova.",
"smtpTestSuccess": "Connessione SMTP riuscita! La configurazione email funziona correttamente.",
"smtpMissingAuth": "Inserisci Nome utente e Password SMTP, oppure abilita l'opzione 'Nessuna Autenticazione'.",
"smtpMissingHostPort": "Inserisci Host e Porta SMTP prima di effettuare il test.",
"smtpNotEnabled": "SMTP non è abilitato. Abilitalo prima di procedere."
},
"title": "Impostazioni",
"breadcrumb": "Impostazioni",
"pageTitle": "Impostazioni",
"tooltips": {
"oidcScope": "Inserisci uno scope e premi Invio per aggiungerlo",
"oidcAdminEmailDomains": "Inserisci un dominio e premi Invio per aggiungerlo"
"oidcAdminEmailDomains": "Inserisci un dominio e premi Invio per aggiungerlo",
"testSmtp": "Verifica la connessione SMTP con i valori attualmente inseriti nel modulo. Per rendere permanenti le modifiche, ricordati di salvare le impostazioni dopo il test.",
"defaultPlaceholder": "Inserisci e premi Invio"
},
"redirectUri": {
"placeholder": "https://miosito.com",
@@ -577,7 +607,8 @@
"title": "Condivisione Non Trovata",
"description": "Questa condivisione potrebbe essere stata eliminata o è scaduta."
},
"pageTitle": "Condivisione"
"pageTitle": "Condivisione",
"downloadAll": "Scarica Tutto"
},
"shareActions": {
"deleteTitle": "Elimina Condivisione",
@@ -654,7 +685,16 @@
"securityUpdateError": "Impossibile aggiornare le impostazioni di sicurezza",
"expirationUpdateError": "Impossibile aggiornare le impostazioni di scadenza",
"securityUpdateSuccess": "Impostazioni di sicurezza aggiornate con successo",
"expirationUpdateSuccess": "Impostazioni di scadenza aggiornate con successo"
"expirationUpdateSuccess": "Impostazioni di scadenza aggiornate con successo",
"creatingZip": "Creazione di file zip ...",
"defaultShareName": "Condividere",
"downloadError": "Impossibile scaricare i file di condivisione",
"downloadSuccess": "Download avviato con successo",
"multipleSharesZipName": "{Count} _Shares_files.zip",
"noFilesToDownload": "Nessun file disponibile per il download",
"singleShareZipName": "{Sharename} _files.zip",
"zipDownloadError": "Impossibile creare un file zip",
"zipDownloadSuccess": "File zip scaricato correttamente"
},
"shares": {
"errors": {
@@ -717,11 +757,14 @@
"editLink": "Modifica Link",
"copyLink": "Copia Link",
"notifyRecipients": "Notifica Destinatari",
"delete": "Elimina"
"delete": "Elimina",
"downloadShareFiles": "Scarica tutti i file"
},
"bulkActions": {
"delete": "Elimina",
"selected": "{count, plural, =1 {1 condivisione selezionata} other {# condivisioni selezionate}}"
"selected": "{count, plural, =1 {1 condivisione selezionata} other {# condivisioni selezionate}}",
"actions": "Azioni",
"download": "Scarica selezionato"
},
"selectAll": "Seleziona tutto",
"selectShare": "Seleziona condivisione {shareName}"
@@ -730,7 +773,15 @@
"title": "Utilizzo Archiviazione",
"ariaLabel": "Barra di progresso utilizzo archiviazione",
"used": "utilizzato",
"available": "disponibile"
"available": "disponibile",
"loading": "Caricamento...",
"retry": "Riprova",
"errors": {
"title": "Informazioni di archiviazione non disponibili",
"detectionFailed": "Impossibile rilevare lo spazio su disco. Ciò potrebbe essere dovuto a problemi di configurazione del sistema o permessi insufficienti.",
"serverError": "Si è verificato un errore del server durante il recupero delle informazioni di archiviazione. Riprova più tardi.",
"unknown": "Si è verificato un errore imprevisto durante il caricamento delle informazioni di archiviazione."
}
},
"theme": {
"toggle": "Cambia tema",
@@ -748,6 +799,7 @@
"uploadProgress": "Progresso caricamento",
"upload": "Carica",
"startUploads": "Inizia Caricamenti",
"retry": "Riprova",
"finish": "Termina",
"success": "File caricato con successo",
"allSuccess": "{count, plural, =1 {File caricato con successo} other {# file caricati con successo}}",
@@ -843,6 +895,7 @@
"passwordLength": "La parola d'accesso deve essere di almeno 8 caratteri",
"passwordsMatch": "Le parole d'accesso devono corrispondere",
"emailRequired": "L'indirizzo email è obbligatorio",
"emailOrUsernameRequired": "L'indirizzo email o il nome utente è obbligatorio",
"passwordRequired": "La parola d'accesso è obbligatoria",
"passwordMinLength": "La password deve contenere almeno 6 caratteri",
"nameRequired": "Il nome è obbligatorio",
@@ -1000,7 +1053,15 @@
"noFilesLimit": "Nessun limite di file",
"noSizeLimit": "Nessun limite di dimensione",
"allFileTypes": "Tutti i tipi di file",
"fileTypesHelp": "Inserisci le estensioni senza punto, separate da spazio, virgola, trattino o barra verticale"
"fileTypesHelp": "Inserisci le estensioni senza punto, separate da spazio, virgola, trattino o barra verticale",
"emailFieldRequired": "Campo e -mail",
"fieldOptions": {
"hidden": "Nascosto",
"optional": "Opzionale",
"required": "Obbligatorio"
},
"fieldRequirements": "Requisiti sul campo",
"nameFieldRequired": "Campo nome"
},
"card": {
"untitled": "Link senza titolo",
@@ -1145,7 +1206,9 @@
},
"actions": {
"preview": "Anteprima",
"download": "Scarica"
"download": "Scarica",
"copyToMyFiles": "Copia sui miei file",
"copying": "Copia ..."
},
"uploadedBy": "Inviato da {name}",
"anonymous": "Anonimo",
@@ -1153,7 +1216,27 @@
"downloadError": "Errore durante il download del file",
"editSuccess": "File aggiornato con successo",
"editError": "Errore durante l'aggiornamento del file",
"previewNotAvailable": "Anteprima non disponibile"
"previewNotAvailable": "Anteprima non disponibile",
"copyError": "Errore di copia del file sui tuoi file",
"copySuccess": "File copiato sui tuoi file correttamente",
"bulkActions": {
"selected": "{count, plural, =1 {1 file selezionato} other {# file selezionati}}",
"actions": "Azioni",
"download": "Scarica Selezionati",
"copyToMyFiles": "Copia Selezionati sui Miei File",
"delete": "Elimina Selezionati"
},
"bulkCopyProgress": "Copia di {count, plural, =1 {1 file} other {# file}} sui tuoi file in corso...",
"bulkCopySuccess": "{count, plural, =1 {1 file copiato sui tuoi file con successo} other {# file copiati sui tuoi file con successo}}",
"bulkDeleteConfirmButton": "Elimina {count, plural, =1 {File} other {File}}",
"bulkDeleteConfirmMessage": "Sei sicuro di voler eliminare {count, plural, =1 {questo file} other {questi # file}}? Questa azione non può essere annullata.",
"bulkDeleteConfirmTitle": "Elimina File Selezionati",
"bulkDeleteProgress": "Eliminazione di {count, plural, =1 {1 file} other {# file}} in corso...",
"bulkDeleteSuccess": "{count, plural, =1 {1 file eliminato con successo} other {# file eliminati con successo}}",
"selectAll": "Seleziona tutto",
"selectFile": "Seleziona file {fileName}",
"deleteError": "Errore durante l'eliminazione del file",
"deleteSuccess": "File eliminato con successo"
}
},
"form": {
@@ -1214,7 +1297,19 @@
"passwordHelp": "La password deve contenere almeno 4 caratteri",
"passwordPlaceholder": "Inserisci una password per proteggere il link"
},
"submit": "Crea Link di Ricezione"
"submit": "Crea Link di Ricezione",
"emailFieldRequired": {
"label": "Requisito del campo e -mail",
"description": "Configurare se il campo e -mail del caricatore deve essere visualizzato e se è richiesto"
},
"fieldRequirements": {
"title": "Requisiti sul campo",
"description": "Configurare quali campi sono mostrati nel modulo di caricamento"
},
"nameFieldRequired": {
"label": "Requisiti del campo Nome",
"description": "Configurare se il campo Nome del caricatore deve essere visualizzato e se è richiesto"
}
},
"messages": {
"created": "Link di ricezione creato con successo!",
@@ -1224,14 +1319,17 @@
"deleteSuccess": "Link di ricezione eliminato con successo!",
"aliasCreated": "Alias creato con successo!",
"activateSuccess": "Link di ricezione attivato con successo!",
"deactivateSuccess": "Link di ricezione disattivato con successo!"
"deactivateSuccess": "Link di ricezione disattivato con successo!",
"passwordProtectionDisabled": "Protezione con password rimossa con successo!",
"passwordProtectionEnabled": "Protezione con password abilitata con successo!"
},
"errors": {
"loadFailed": "Impossibile caricare i link di ricezione",
"createFailed": "Impossibile creare il link di ricezione. Riprova.",
"updateFailed": "Impossibile aggiornare il link di ricezione. Riprova.",
"deleteFailed": "Impossibile eliminare il link di ricezione. Riprova.",
"aliasCreateFailed": "Impossibile creare l'alias. Riprova."
"aliasCreateFailed": "Impossibile creare l'alias. Riprova.",
"passwordUpdateFailed": "Impossibile aggiornare la protezione con password"
},
"delete": {
"title": "Elimina link di ricezione",
@@ -1269,11 +1367,14 @@
"linkInactive": "Questo link è inattivo.",
"linkExpired": "Questo link è scaduto.",
"uploadFailed": "Errore durante l'invio del file",
"retry": "Riprova",
"fileTooLarge": "File troppo grande. Dimensione massima: {maxSize}",
"fileTypeNotAllowed": "Tipo di file non consentito. Tipi accettati: {allowedTypes}",
"maxFilesExceeded": "Massimo {maxFiles} file consentiti",
"selectAtLeastOneFile": "Seleziona almeno un file",
"provideNameOrEmail": "Inserisci il tuo nome o email"
"provideNameOrEmail": "Inserisci il tuo nome o email",
"provideEmailRequired": "È richiesta l'e -mail",
"provideNameRequired": "È richiesto il nome"
},
"fileDropzone": {
"dragActive": "Rilascia i file qui",
@@ -1296,7 +1397,9 @@
"descriptionLabel": "Descrizione (opzionale)",
"descriptionPlaceholder": "Aggiungi una descrizione ai file...",
"uploadButton": "Invia {count} file",
"uploading": "Invio in corso..."
"uploading": "Invio in corso...",
"emailLabelOptional": "Email (opzionale)",
"nameLabelOptional": "Nome (opzionale)"
},
"success": {
"title": "File inviati con successo! 🎉",
@@ -1334,12 +1437,15 @@
"cancel": "Annulla",
"preview": "Anteprima",
"download": "Scarica",
"delete": "Elimina"
"delete": "Elimina",
"copyToMyFiles": "Copia sui miei file",
"copying": "Copia ..."
},
"editField": {
"saveChanges": "Salva modifiche",
"cancelEdit": "Annulla modifica"
}
}
},
"defaultLinkName": "File ricevuti"
}
}

View File

@@ -202,6 +202,8 @@
"login": {
"welcome": "ようこそへ",
"signInToContinue": "続行するにはサインインしてください",
"emailOrUsernameLabel": "メールアドレスまたはユーザー名",
"emailOrUsernamePlaceholder": "メールアドレスまたはユーザー名を入力してください",
"emailLabel": "メールアドレス",
"emailPlaceholder": "メールアドレスを入力してください",
"passwordLabel": "パスワード",
@@ -526,10 +528,30 @@
"oidcAdminEmailDomains": {
"title": "管理者メールドメイン",
"description": "自動的に管理者権限が付与されるメールドメイン"
},
"testSmtp": {
"title": "SMTP接続テスト",
"description": "SMTP設定が有効かどうかをテストします"
},
"smtpNoAuth": {
"title": "認証なし",
"description": "ユーザー名/パスワードを必要としない内部サーバー用(認証フィールドを非表示)"
},
"smtpSecure": {
"title": "接続セキュリティ",
"description": "SMTP接続セキュリティ方式 - 自動推奨、SSL、STARTTLS、または なし(安全でない)",
"options": {
"auto": "自動(推奨)",
"ssl": "SSLポート465",
"tls": "STARTTLSポート587",
"none": "なし(安全でない)"
}
}
},
"buttons": {
"save": "{group}を保存"
"save": "{group}を保存",
"testSmtp": "接続テスト",
"testing": "テスト中..."
},
"errors": {
"loadFailed": "設定の読み込みに失敗しました",
@@ -537,14 +559,22 @@
},
"messages": {
"noChanges": "保存する変更はありません",
"updateSuccess": "{group}の設定が正常に更新されました"
"updateSuccess": "{group}の設定が正常に更新されました",
"smtpTestFailed": "SMTP接続に失敗しました: {error}",
"smtpTestGenericError": "SMTP接続のテストに失敗しました。設定を確認して再度お試しください。",
"smtpTestSuccess": "SMTP接続に成功しましたメール設定は正常に動作しています。",
"smtpMissingAuth": "SMTPユーザー名とパスワードを入力するか、「認証なし」オプションを有効にしてください。",
"smtpMissingHostPort": "テストの前にSMTPホストとポートを入力してください。",
"smtpNotEnabled": "SMTPが有効になっていません。最初にSMTPを有効にしてください。"
},
"title": "設定",
"breadcrumb": "設定",
"pageTitle": "設定",
"tooltips": {
"oidcScope": "スコープを入力してEnterキーを押して追加",
"oidcAdminEmailDomains": "ドメインを入力してEnterキーを押して追加"
"oidcAdminEmailDomains": "ドメインを入力してEnterキーを押して追加",
"testSmtp": "フォームに現在入力されている値でSMTP接続をテストします。変更を永続化するには、テスト後に設定を保存することを忘れないでください。",
"defaultPlaceholder": "入力してEnterキーを押してください"
},
"redirectUri": {
"placeholder": "https://mysite.com",
@@ -577,7 +607,8 @@
"title": "共有が見つかりません",
"description": "この共有は削除されたか、期限が切れている可能性があります。"
},
"pageTitle": "共有"
"pageTitle": "共有",
"downloadAll": "すべてダウンロード"
},
"shareActions": {
"deleteTitle": "共有を削除",
@@ -654,7 +685,16 @@
"securityUpdateError": "セキュリティ設定の更新に失敗しました",
"expirationUpdateError": "有効期限設定の更新に失敗しました",
"securityUpdateSuccess": "セキュリティ設定が正常に更新されました",
"expirationUpdateSuccess": "有効期限設定が正常に更新されました"
"expirationUpdateSuccess": "有効期限設定が正常に更新されました",
"creatingZip": "zipファイルの作成...",
"defaultShareName": "共有",
"downloadError": "共有ファイルをダウンロードできませんでした",
"downloadSuccess": "ダウンロードは正常に開始されました",
"multipleSharesZipName": "{count} _shares_files.zip",
"noFilesToDownload": "ダウンロードできるファイルはありません",
"singleShareZipName": "{sharename} _files.zip",
"zipDownloadError": "zipファイルの作成に失敗しました",
"zipDownloadSuccess": "zipファイルは正常にダウンロードされました"
},
"shares": {
"errors": {
@@ -717,11 +757,14 @@
"editLink": "リンク編集",
"copyLink": "リンクコピー",
"notifyRecipients": "受信者に通知",
"delete": "削除"
"delete": "削除",
"downloadShareFiles": "すべてのファイルをダウンロードします"
},
"bulkActions": {
"delete": "削除",
"selected": "{count, plural, =1 {1つの共有が選択されました} other {#つの共有が選択されました}}"
"selected": "{count, plural, =1 {1つの共有が選択されました} other {#つの共有が選択されました}}",
"actions": "アクション",
"download": "選択したダウンロード"
},
"selectAll": "すべて選択",
"selectShare": "共有{shareName}を選択"
@@ -730,7 +773,15 @@
"title": "ストレージ使用量",
"ariaLabel": "ストレージ使用状況のプログレスバー",
"used": "使用済み",
"available": "利用可能"
"available": "利用可能",
"loading": "読み込み中...",
"retry": "再試行",
"errors": {
"title": "ストレージ情報が利用できません",
"detectionFailed": "ディスク容量を検出できません。システム設定の問題または権限が不足している可能性があります。",
"serverError": "ストレージ情報の取得中にサーバーエラーが発生しました。後でもう一度お試しください。",
"unknown": "ストレージ情報の読み込み中に予期せぬエラーが発生しました。"
}
},
"theme": {
"toggle": "テーマを切り替える",
@@ -761,6 +812,7 @@
},
"multipleTitle": "複数ファイルをアップロード",
"startUploads": "アップロードを開始",
"retry": "再試行",
"allSuccess": "{count, plural, =1 {ファイルがアップロードされました} other {#個のファイルがアップロードされました}}",
"partialSuccess": "{success}個のファイルがアップロードされ、{error}個が失敗しました",
"dragAndDrop": "またはここにファイルをドラッグ&ドロップ"
@@ -844,6 +896,7 @@
"passwordLength": "パスワードは最低8文字必要です",
"passwordsMatch": "パスワードが一致しません",
"emailRequired": "メールアドレスは必須です",
"emailOrUsernameRequired": "メールアドレスまたはユーザー名は必須です",
"passwordRequired": "パスワードは必須です",
"nameRequired": "名前は必須です",
"required": "このフィールドは必須です"
@@ -1000,7 +1053,15 @@
"noFilesLimit": "ファイル数制限なし",
"noSizeLimit": "サイズ制限なし",
"allFileTypes": "すべてのファイル形式",
"fileTypesHelp": "拡張子をドット無しで入力し、スペース、カンマ、ハイフン、パイプで区切ってください"
"fileTypesHelp": "拡張子をドット無しで入力し、スペース、カンマ、ハイフン、パイプで区切ってください",
"emailFieldRequired": "電子メールフィールド",
"fieldOptions": {
"hidden": "隠れた",
"optional": "オプション",
"required": "必須"
},
"fieldRequirements": "フィールド要件",
"nameFieldRequired": "名前フィールド"
},
"card": {
"untitled": "無題のリンク",
@@ -1145,7 +1206,9 @@
},
"actions": {
"preview": "プレビュー",
"download": "ダウンロード"
"download": "ダウンロード",
"copyToMyFiles": "私のファイルにコピーします",
"copying": "コピー..."
},
"uploadedBy": "{name}が送信",
"anonymous": "匿名",
@@ -1153,7 +1216,27 @@
"downloadError": "ファイルのダウンロードに失敗しました",
"editSuccess": "ファイルを更新しました",
"editError": "ファイルの更新に失敗しました",
"previewNotAvailable": "プレビューは利用できません"
"previewNotAvailable": "プレビューは利用できません",
"copyError": "ファイルにファイルをコピーするエラー",
"copySuccess": "ファイルに正常にコピーされたファイル",
"bulkActions": {
"selected": "{count, plural, =1 {1ファイルを選択} other {#ファイルを選択}}",
"actions": "アクション",
"download": "選択したファイルをダウンロード",
"copyToMyFiles": "選択したファイルを私のファイルにコピー",
"delete": "選択したファイルを削除"
},
"bulkCopyProgress": "{count, plural, =1 {1ファイル} other {#ファイル}}を私のファイルにコピー中...",
"bulkCopySuccess": "{count, plural, =1 {1ファイル} other {#ファイル}}を私のファイルに正常にコピーしました",
"bulkDeleteConfirmButton": "{count, plural, =1 {ファイル} other {ファイル}}を削除",
"bulkDeleteConfirmMessage": "{count, plural, =1 {このファイル} other {これらの#ファイル}}を削除してもよろしいですか?この操作は取り消せません。",
"bulkDeleteConfirmTitle": "選択したファイルを削除",
"bulkDeleteProgress": "{count, plural, =1 {1ファイル} other {#ファイル}}を削除中...",
"bulkDeleteSuccess": "{count, plural, =1 {1ファイル} other {#ファイル}}を正常に削除しました",
"selectAll": "すべて選択",
"selectFile": "ファイル{fileName}を選択",
"deleteError": "ファイルの削除に失敗しました",
"deleteSuccess": "ファイルを正常に削除しました"
}
},
"form": {
@@ -1214,7 +1297,19 @@
"passwordHelp": "パスワードは4文字以上必要です",
"passwordPlaceholder": "リンクを保護するパスワードを入力"
},
"submit": "受信リンクを作成"
"submit": "受信リンクを作成",
"emailFieldRequired": {
"label": "電子メールフィールドの要件",
"description": "アップローダーの電子メールフィールドが表示されるかどうか、そしてそれが必要かどうかを構成します"
},
"fieldRequirements": {
"title": "フィールド要件",
"description": "アップロードフォームに表示されるフィールドを構成します"
},
"nameFieldRequired": {
"label": "名前フィールドの要件",
"description": "アップローダー名フィールドが表示されるかどうか、そしてそれが必要かどうかを構成します"
}
},
"messages": {
"created": "受信リンクを作成しました!",
@@ -1224,14 +1319,17 @@
"deleteSuccess": "受信リンクを削除しました!",
"aliasCreated": "エイリアスを作成しました!",
"activateSuccess": "受信リンクを有効化しました!",
"deactivateSuccess": "受信リンクを無効化しました!"
"deactivateSuccess": "受信リンクを無効化しました!",
"passwordProtectionDisabled": "パスワード保護を正常に解除しました!",
"passwordProtectionEnabled": "パスワード保護を正常に有効化しました!"
},
"errors": {
"loadFailed": "受信リンクの読み込みに失敗しました",
"createFailed": "受信リンクの作成に失敗しました。もう一度お試しください。",
"updateFailed": "受信リンクの更新に失敗しました。もう一度お試しください。",
"deleteFailed": "受信リンクの削除に失敗しました。もう一度お試しください。",
"aliasCreateFailed": "エイリアスの作成に失敗しました。もう一度お試しください。"
"aliasCreateFailed": "エイリアスの作成に失敗しました。もう一度お試しください。",
"passwordUpdateFailed": "パスワード保護の更新に失敗しました"
},
"delete": {
"title": "受信リンクを削除",
@@ -1269,11 +1367,14 @@
"linkInactive": "このリンクは無効です。",
"linkExpired": "このリンクは期限切れです。",
"uploadFailed": "ファイルのアップロードに失敗しました",
"retry": "再試行",
"fileTooLarge": "ファイルが大きすぎます。最大サイズ: {maxSize}",
"fileTypeNotAllowed": "このファイル形式は許可されていません。許可される形式: {allowedTypes}",
"maxFilesExceeded": "最大 {maxFiles} ファイルまで許可されています",
"selectAtLeastOneFile": "少なくとも1つのファイルを選択してください",
"provideNameOrEmail": "名前またはメールアドレスを入力してください"
"provideNameOrEmail": "名前またはメールアドレスを入力してください",
"provideEmailRequired": "メールが必要です",
"provideNameRequired": "名前が必要です"
},
"fileDropzone": {
"dragActive": "ここにファイルをドロップ",
@@ -1296,7 +1397,9 @@
"descriptionLabel": "説明(オプション)",
"descriptionPlaceholder": "ファイルの説明を追加...",
"uploadButton": "{count} ファイルを送信",
"uploading": "送信中..."
"uploading": "送信中...",
"emailLabelOptional": "メール(オプション)",
"nameLabelOptional": "名前(オプション)"
},
"success": {
"title": "ファイルを送信しました! 🎉",
@@ -1334,12 +1437,15 @@
"cancel": "キャンセル",
"preview": "プレビュー",
"download": "ダウンロード",
"delete": "削除"
"delete": "削除",
"copyToMyFiles": "私のファイルにコピーします",
"copying": "コピー..."
},
"editField": {
"saveChanges": "変更を保存",
"cancelEdit": "編集をキャンセル"
}
}
},
"defaultLinkName": "受信したファイル"
}
}

View File

@@ -202,6 +202,8 @@
"login": {
"welcome": "에 오신 것을 환영합니다",
"signInToContinue": "계속하려면 로그인하세요",
"emailOrUsernameLabel": "이메일 또는 사용자 이름",
"emailOrUsernamePlaceholder": "이메일 또는 사용자 이름을 입력하세요",
"emailLabel": "이메일 주소",
"emailPlaceholder": "이메일을 입력하세요",
"passwordLabel": "비밀번호",
@@ -526,10 +528,30 @@
"oidcAdminEmailDomains": {
"title": "관리자 이메일 도메인",
"description": "자동으로 관리자 권한이 부여되는 이메일 도메인"
},
"testSmtp": {
"title": "SMTP 연결 테스트",
"description": "SMTP 구성이 유효한지 테스트합니다"
},
"smtpNoAuth": {
"title": "인증 없음",
"description": "사용자 이름/비밀번호가 필요하지 않은 내부 서버의 경우 활성화하세요 (인증 필드 숨김)"
},
"smtpSecure": {
"title": "연결 보안",
"description": "SMTP 연결 보안 방식 - 자동 (권장), SSL, STARTTLS 또는 없음 (비보안)",
"options": {
"auto": "자동 (권장)",
"ssl": "SSL (포트 465)",
"tls": "STARTTLS (포트 587)",
"none": "없음 (비보안)"
}
}
},
"buttons": {
"save": "{group} 저장"
"save": "{group} 저장",
"testSmtp": "연결 테스트",
"testing": "테스트 중..."
},
"errors": {
"loadFailed": "설정을 불러오는데 실패했습니다",
@@ -537,14 +559,22 @@
},
"messages": {
"noChanges": "저장할 변경 사항이 없습니다",
"updateSuccess": "{group} 설정이 성공적으로 업데이트되었습니다"
"updateSuccess": "{group} 설정이 성공적으로 업데이트되었습니다",
"smtpTestFailed": "SMTP 연결 실패: {error}",
"smtpTestGenericError": "SMTP 연결 테스트에 실패했습니다. 설정을 확인하고 다시 시도해주세요.",
"smtpTestSuccess": "SMTP 연결 성공! 이메일 구성이 올바르게 작동합니다.",
"smtpMissingAuth": "SMTP 사용자 이름과 비밀번호를 입력하거나 '인증 없음' 옵션을 활성화하세요.",
"smtpMissingHostPort": "테스트하기 전에 SMTP 호스트와 포트를 입력하세요.",
"smtpNotEnabled": "SMTP가 활성화되지 않았습니다. 먼저 SMTP를 활성화하세요."
},
"title": "설정",
"breadcrumb": "설정",
"pageTitle": "설정",
"tooltips": {
"oidcScope": "스코프를 입력하고 Enter 키를 눌러 추가",
"oidcAdminEmailDomains": "도메인을 입력하고 Enter 키를 눌러 추가"
"oidcAdminEmailDomains": "도메인을 입력하고 Enter 키를 눌러 추가",
"testSmtp": "현재 입력된 값으로 SMTP 연결을 테스트합니다. 변경 사항을 영구적으로 적용하려면 테스트 후 설정을 저장하는 것을 잊지 마세요.",
"defaultPlaceholder": "입력하고 Enter 키를 누르세요"
},
"redirectUri": {
"placeholder": "https://mysite.com",
@@ -577,7 +607,8 @@
"title": "공유를 찾을 수 없습니다",
"description": "이 공유는 삭제되었거나 만료되었을 수 있습니다."
},
"pageTitle": "공유"
"pageTitle": "공유",
"downloadAll": "모두 다운로드"
},
"shareActions": {
"deleteTitle": "공유 삭제",
@@ -654,7 +685,16 @@
"securityUpdateError": "보안 설정 업데이트에 실패했습니다",
"expirationUpdateError": "만료 설정 업데이트에 실패했습니다",
"securityUpdateSuccess": "보안 설정이 성공적으로 업데이트되었습니다",
"expirationUpdateSuccess": "만료 설정이 성공적으로 업데이트되었습니다"
"expirationUpdateSuccess": "만료 설정이 성공적으로 업데이트되었습니다",
"creatingZip": "zip 파일 만들기 ...",
"defaultShareName": "공유하다",
"downloadError": "공유 파일을 다운로드하지 못했습니다",
"downloadSuccess": "다운로드가 성공적으로 시작되었습니다",
"multipleSharesZipName": "{count} _shares_files.zip",
"noFilesToDownload": "다운로드 할 수있는 파일이 없습니다",
"singleShareZipName": "{sharename} _files.zip",
"zipDownloadError": "zip 파일을 만들지 못했습니다",
"zipDownloadSuccess": "zip 파일이 성공적으로 다운로드되었습니다"
},
"shares": {
"errors": {
@@ -717,11 +757,14 @@
"editLink": "링크 편집",
"copyLink": "링크 복사",
"notifyRecipients": "받는 사람에게 알림",
"delete": "삭제"
"delete": "삭제",
"downloadShareFiles": "모든 파일을 다운로드하십시오"
},
"bulkActions": {
"delete": "삭제",
"selected": "{count, plural, =1 {1개의 공유가 선택됨} other {#개의 공유가 선택됨}}"
"selected": "{count, plural, =1 {1개의 공유가 선택됨} other {#개의 공유가 선택됨}}",
"actions": "행위",
"download": "선택한 다운로드"
},
"selectAll": "모두 선택",
"selectShare": "공유 {shareName} 선택"
@@ -730,7 +773,15 @@
"title": "스토리지 사용량",
"ariaLabel": "스토리지 사용량 진행 바",
"used": "사용됨",
"available": "사용 가능"
"available": "사용 가능",
"loading": "로딩 중...",
"retry": "다시 시도",
"errors": {
"title": "스토리지 정보를 사용할 수 없음",
"detectionFailed": "디스크 공간을 감지할 수 없습니다. 시스템 구성 문제 또는 권한이 부족한 것이 원인일 수 있습니다.",
"serverError": "스토리지 정보를 검색하는 중에 서버 오류가 발생했습니다. 나중에 다시 시도해 주세요.",
"unknown": "스토리지 정보를 로드하는 중에 예기치 않은 오류가 발생했습니다."
}
},
"theme": {
"toggle": "테마 전환",
@@ -748,6 +799,7 @@
"uploadProgress": "업로드 진행률",
"upload": "업로드",
"startUploads": "업로드 시작",
"retry": "다시 시도",
"finish": "완료",
"success": "파일이 성공적으로 업로드되었습니다",
"allSuccess": "{count, plural, =1 {파일이 성공적으로 업로드되었습니다} other {# 개 파일이 성공적으로 업로드되었습니다}}",
@@ -844,6 +896,7 @@
"passwordLength": "비밀번호는 최소 8자 이상이어야 합니다",
"passwordsMatch": "비밀번호가 일치하지 않습니다",
"emailRequired": "이메일은 필수입니다",
"emailOrUsernameRequired": "이메일 또는 사용자 이름은 필수입니다",
"passwordRequired": "비밀번호는 필수입니다",
"nameRequired": "이름은 필수입니다",
"required": "이 필드는 필수입니다"
@@ -1000,7 +1053,15 @@
"noFilesLimit": "파일 수 제한 없음",
"noSizeLimit": "크기 제한 없음",
"allFileTypes": "모든 파일 유형",
"fileTypesHelp": "확장자를 점 없이 입력하고 공백, 쉼표, 대시 또는 파이프로 구분"
"fileTypesHelp": "확장자를 점 없이 입력하고 공백, 쉼표, 대시 또는 파이프로 구분",
"emailFieldRequired": "이메일 필드",
"fieldOptions": {
"hidden": "숨겨진",
"optional": "선택 과목",
"required": "필수의"
},
"fieldRequirements": "필드 요구 사항",
"nameFieldRequired": "이름 필드"
},
"card": {
"untitled": "제목 없는 링크",
@@ -1145,7 +1206,9 @@
},
"actions": {
"preview": "미리보기",
"download": "다운로드"
"download": "다운로드",
"copyToMyFiles": "내 파일에 복사하십시오",
"copying": "사자..."
},
"uploadedBy": "{name}님이 업로드함",
"anonymous": "익명",
@@ -1153,7 +1216,27 @@
"downloadError": "파일 다운로드 오류",
"editSuccess": "파일이 성공적으로 업데이트됨",
"editError": "파일 업데이트 오류",
"previewNotAvailable": "미리보기 불가"
"previewNotAvailable": "미리보기 불가",
"copyError": "파일에 파일을 복사합니다",
"copySuccess": "파일을 파일에 성공적으로 복사했습니다",
"bulkActions": {
"selected": "{count, plural, =1 {1개의 파일 선택됨} other {#개의 파일 선택됨}}",
"actions": "작업",
"download": "선택한 항목 다운로드",
"copyToMyFiles": "선택한 항목을 내 파일로 복사",
"delete": "선택한 항목 삭제"
},
"bulkCopyProgress": "{count, plural, =1 {1개의 파일} other {#개의 파일}}을(를) 내 파일로 복사하는 중...",
"bulkCopySuccess": "{count, plural, =1 {1개의 파일이} other {#개의 파일이}} 내 파일로 성공적으로 복사됨",
"bulkDeleteConfirmButton": "{count, plural, =1 {파일} other {파일}} 삭제",
"bulkDeleteConfirmMessage": "{count, plural, =1 {이 파일을} other {이 #개의 파일을}} 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
"bulkDeleteConfirmTitle": "선택한 파일 삭제",
"bulkDeleteProgress": "{count, plural, =1 {1개의 파일} other {#개의 파일}} 삭제 중...",
"bulkDeleteSuccess": "{count, plural, =1 {1개의 파일이} other {#개의 파일이}} 성공적으로 삭제됨",
"selectAll": "모두 선택",
"selectFile": "{fileName} 파일 선택",
"deleteError": "파일 삭제 오류",
"deleteSuccess": "파일이 성공적으로 삭제됨"
}
},
"form": {
@@ -1214,7 +1297,19 @@
"passwordHelp": "비밀번호는 최소 4자 이상이어야 합니다",
"passwordPlaceholder": "링크를 보호할 비밀번호 입력"
},
"submit": "수신 링크 생성"
"submit": "수신 링크 생성",
"emailFieldRequired": {
"label": "이메일 필드 요구 사항",
"description": "업 로더 이메일 필드를 표시 해야하는지 확인하고 필요한 경우 구성하십시오."
},
"fieldRequirements": {
"title": "필드 요구 사항",
"description": "업로드 양식으로 표시되는 필드를 구성하십시오"
},
"nameFieldRequired": {
"label": "이름 필드 요구 사항",
"description": "업로더 이름 필드를 표시 해야하는지 확인하고 필요한 경우 구성하십시오."
}
},
"messages": {
"created": "수신 링크가 성공적으로 생성되었습니다!",
@@ -1224,14 +1319,17 @@
"deleteSuccess": "수신 링크가 성공적으로 삭제되었습니다!",
"aliasCreated": "별칭이 성공적으로 생성되었습니다!",
"activateSuccess": "수신 링크가 성공적으로 활성화되었습니다!",
"deactivateSuccess": "수신 링크가 성공적으로 비활성화되었습니다!"
"deactivateSuccess": "수신 링크가 성공적으로 비활성화되었습니다!",
"passwordProtectionDisabled": "비밀번호 보호가 성공적으로 제거되었습니다!",
"passwordProtectionEnabled": "비밀번호 보호가 성공적으로 활성화되었습니다!"
},
"errors": {
"loadFailed": "수신 링크 로드 실패",
"createFailed": "수신 링크 생성 실패. 다시 시도하세요.",
"updateFailed": "수신 링크 업데이트 실패. 다시 시도하세요.",
"deleteFailed": "수신 링크 삭제 실패. 다시 시도하세요.",
"aliasCreateFailed": "별칭 생성 실패. 다시 시도하세요."
"aliasCreateFailed": "별칭 생성 실패. 다시 시도하세요.",
"passwordUpdateFailed": "비밀번호 보호 업데이트 실패"
},
"delete": {
"title": "수신 링크 삭제",
@@ -1269,11 +1367,14 @@
"linkInactive": "이 링크는 비활성 상태입니다.",
"linkExpired": "이 링크는 만료되었습니다.",
"uploadFailed": "파일 업로드 오류",
"retry": "다시 시도",
"fileTooLarge": "파일이 너무 큽니다. 최대 크기: {maxSize}",
"fileTypeNotAllowed": "허용되지 않는 파일 유형입니다. 허용된 유형: {allowedTypes}",
"maxFilesExceeded": "최대 {maxFiles}개의 파일만 허용됩니다",
"selectAtLeastOneFile": "최소 한 개의 파일을 선택하세요",
"provideNameOrEmail": "이름 또는 이메일을 입력하세요"
"provideNameOrEmail": "이름 또는 이메일을 입력하세요",
"provideEmailRequired": "이메일이 필요합니다",
"provideNameRequired": "이름이 필요합니다"
},
"fileDropzone": {
"dragActive": "여기에 파일을 놓으세요",
@@ -1296,7 +1397,9 @@
"descriptionLabel": "설명 (선택사항)",
"descriptionPlaceholder": "파일에 대한 설명 추가...",
"uploadButton": "{count}개 파일 보내기",
"uploading": "보내는 중..."
"uploading": "보내는 중...",
"emailLabelOptional": "이메일 (선택 사항)",
"nameLabelOptional": "이름 (선택 사항)"
},
"success": {
"title": "파일이 성공적으로 보내졌습니다! 🎉",
@@ -1334,12 +1437,15 @@
"cancel": "취소",
"preview": "미리보기",
"download": "다운로드",
"delete": "삭제"
"delete": "삭제",
"copyToMyFiles": "내 파일에 복사하십시오",
"copying": "사자..."
},
"editField": {
"saveChanges": "변경사항 저장",
"cancelEdit": "편집 취소"
}
}
},
"defaultLinkName": "받은 파일"
}
}

View File

@@ -202,6 +202,8 @@
"login": {
"welcome": "Welkom bij",
"signInToContinue": "Log in om door te gaan",
"emailOrUsernameLabel": "E-mail of Gebruikersnaam",
"emailOrUsernamePlaceholder": "Voer je e-mail of gebruikersnaam in",
"emailLabel": "E-mailadres",
"emailPlaceholder": "Voer je e-mail in",
"passwordLabel": "Wachtwoord",
@@ -526,10 +528,30 @@
"oidcAdminEmailDomains": {
"title": "Admin E-mail Domeinen",
"description": "E-mail domeinen die automatisch admin rechten krijgen"
},
"testSmtp": {
"title": "Test SMTP Verbinding",
"description": "Test of de SMTP configuratie geldig is"
},
"smtpNoAuth": {
"title": "Geen Authenticatie",
"description": "Schakel dit in voor interne servers die geen gebruikersnaam/wachtwoord vereisen (verbergt authenticatievelden)"
},
"smtpSecure": {
"title": "Verbindingsbeveiliging",
"description": "SMTP verbindingsbeveiligingsmethode - Auto (aanbevolen), SSL, STARTTLS, of Geen (onveilig)",
"options": {
"auto": "Auto (Aanbevolen)",
"ssl": "SSL (Poort 465)",
"tls": "STARTTLS (Poort 587)",
"none": "Geen (Onveilig)"
}
}
},
"buttons": {
"save": "{group} Opslaan"
"save": "{group} Opslaan",
"testSmtp": "Test Verbinding",
"testing": "Testen..."
},
"errors": {
"loadFailed": "Fout bij het laden van instellingen",
@@ -537,14 +559,22 @@
},
"messages": {
"noChanges": "Geen wijzigingen om op te slaan",
"updateSuccess": "{group} instellingen succesvol bijgewerkt"
"updateSuccess": "{group} instellingen succesvol bijgewerkt",
"smtpTestFailed": "SMTP verbinding mislukt: {error}",
"smtpTestGenericError": "SMTP verbinding testen mislukt. Controleer uw instellingen en probeer het opnieuw.",
"smtpTestSuccess": "SMTP verbinding succesvol! Uw e-mailconfiguratie werkt correct.",
"smtpMissingAuth": "Vul de SMTP gebruikersnaam en wachtwoord in, of schakel de optie 'Geen Authenticatie' in.",
"smtpMissingHostPort": "Vul de SMTP Host en Poort in voordat u test.",
"smtpNotEnabled": "SMTP is niet ingeschakeld. Schakel eerst SMTP in."
},
"title": "Instellingen",
"breadcrumb": "Instellingen",
"pageTitle": "Instellingen",
"tooltips": {
"oidcScope": "Voer een scope in en druk op Enter om toe te voegen",
"oidcAdminEmailDomains": "Voer een domein in en druk op Enter om toe te voegen"
"oidcAdminEmailDomains": "Voer een domein in en druk op Enter om toe te voegen",
"testSmtp": "Test de SMTP verbinding met de waarden die momenteel in het formulier zijn ingevoerd. Om wijzigingen permanent te maken, vergeet niet om uw instellingen op te slaan na het testen.",
"defaultPlaceholder": "Voer in en druk op Enter"
},
"redirectUri": {
"placeholder": "https://mijnsite.com",
@@ -577,7 +607,8 @@
"title": "Delen Niet Gevonden",
"description": "Dit delen is mogelijk verwijderd of verlopen."
},
"pageTitle": "Delen"
"pageTitle": "Delen",
"downloadAll": "Alles Downloaden"
},
"shareActions": {
"deleteTitle": "Delen Verwijderen",
@@ -654,7 +685,16 @@
"securityUpdateError": "Fout bij het bijwerken van beveiligingsinstellingen",
"expirationUpdateError": "Fout bij het bijwerken van verloop instellingen",
"securityUpdateSuccess": "Beveiligingsinstellingen succesvol bijgewerkt",
"expirationUpdateSuccess": "Verloop instellingen succesvol bijgewerkt"
"expirationUpdateSuccess": "Verloop instellingen succesvol bijgewerkt",
"creatingZip": "Zip -bestand maken ...",
"defaultShareName": "Deel",
"downloadError": "Kan niet downloaden Delen -bestanden downloaden",
"downloadSuccess": "Download begonnen met succes",
"multipleSharesZipName": "{count} _shares_files.zip",
"noFilesToDownload": "Geen bestanden beschikbaar om te downloaden",
"singleShareZipName": "{Sharename} _files.zip",
"zipDownloadError": "Kan zip -bestand niet maken",
"zipDownloadSuccess": "Zipbestand met succes gedownload"
},
"shares": {
"errors": {
@@ -717,11 +757,14 @@
"editLink": "Link Bewerken",
"copyLink": "Link Kopiëren",
"notifyRecipients": "Ontvangers Informeren",
"delete": "Verwijderen"
"delete": "Verwijderen",
"downloadShareFiles": "Download alle bestanden"
},
"bulkActions": {
"delete": "Verwijderen",
"selected": "{count, plural, =1 {1 deel geselecteerd} other {# delen geselecteerd}}"
"selected": "{count, plural, =1 {1 deel geselecteerd} other {# delen geselecteerd}}",
"actions": "Acties",
"download": "Download geselecteerd"
},
"selectAll": "Alles selecteren",
"selectShare": "Deel {shareName} selecteren"
@@ -730,7 +773,15 @@
"title": "Opslaggebruik",
"ariaLabel": "Opslaggebruik voortgangsbalk",
"used": "gebruikt",
"available": "beschikbaar"
"available": "beschikbaar",
"loading": "Laden...",
"retry": "Opnieuw proberen",
"errors": {
"title": "Opslaginformatie niet beschikbaar",
"detectionFailed": "Kan schijfruimte niet detecteren. Dit kan komen door systeemconfiguratieproblemen of onvoldoende rechten.",
"serverError": "Er is een serverfout opgetreden bij het ophalen van opslaginformatie. Probeer het later opnieuw.",
"unknown": "Er is een onverwachte fout opgetreden bij het laden van opslaginformatie."
}
},
"theme": {
"toggle": "Thema wisselen",
@@ -748,6 +799,7 @@
"uploadProgress": "Upload voortgang",
"upload": "Uploaden",
"startUploads": "Uploads Starten",
"retry": "Opnieuw Proberen",
"finish": "Voltooien",
"success": "Bestand succesvol geüpload",
"allSuccess": "{count, plural, =1 {Bestand succesvol geüpload} other {# bestanden succesvol geüpload}}",
@@ -843,6 +895,7 @@
"passwordLength": "Wachtwoord moet minimaal 8 tekens zijn",
"passwordsMatch": "Wachtwoorden moeten overeenkomen",
"emailRequired": "E-mail is verplicht",
"emailOrUsernameRequired": "E-mail of gebruikersnaam is verplicht",
"passwordRequired": "Wachtwoord is verplicht",
"passwordMinLength": "Wachtwoord moet minimaal 6 tekens bevatten",
"nameRequired": "Naam is verplicht",
@@ -1000,7 +1053,15 @@
"noFilesLimit": "Geen bestandslimiet",
"noSizeLimit": "Geen groottelimiet",
"allFileTypes": "Alle bestandstypes",
"fileTypesHelp": "Voer extensies in zonder punt, gescheiden door spatie, komma, streepje of verticale streep"
"fileTypesHelp": "Voer extensies in zonder punt, gescheiden door spatie, komma, streepje of verticale streep",
"emailFieldRequired": "E -mailveld",
"fieldOptions": {
"hidden": "Verborgen",
"optional": "Optioneel",
"required": "Vereist"
},
"fieldRequirements": "Veldvereisten",
"nameFieldRequired": "Naamveld"
},
"card": {
"untitled": "Link zonder titel",
@@ -1145,7 +1206,9 @@
},
"actions": {
"preview": "Voorvertoning",
"download": "Downloaden"
"download": "Downloaden",
"copyToMyFiles": "Kopieer naar mijn bestanden",
"copying": "Kopiëren ..."
},
"uploadedBy": "Verzonden door {name}",
"anonymous": "Anoniem",
@@ -1153,7 +1216,27 @@
"downloadError": "Fout bij downloaden bestand",
"editSuccess": "Bestand succesvol bijgewerkt",
"editError": "Fout bij bijwerken bestand",
"previewNotAvailable": "Voorvertoning niet beschikbaar"
"previewNotAvailable": "Voorvertoning niet beschikbaar",
"copyError": "Fout bij het kopiëren van bestand naar uw bestanden",
"copySuccess": "Bestand gekopieerd naar uw bestanden succesvol",
"bulkActions": {
"selected": "{count, plural, =1 {1 bestand geselecteerd} other {# bestanden geselecteerd}}",
"actions": "Acties",
"download": "Geselecteerde Downloaden",
"copyToMyFiles": "Geselecteerde Kopiëren naar Mijn Bestanden",
"delete": "Geselecteerde Verwijderen"
},
"bulkCopyProgress": "{count, plural, =1 {1 bestand} other {# bestanden}} kopiëren naar uw bestanden...",
"bulkCopySuccess": "{count, plural, =1 {1 bestand succesvol gekopieerd naar uw bestanden} other {# bestanden succesvol gekopieerd naar uw bestanden}}",
"bulkDeleteConfirmButton": "{count, plural, =1 {Bestand} other {Bestanden}} Verwijderen",
"bulkDeleteConfirmMessage": "Weet u zeker dat u {count, plural, =1 {dit bestand} other {deze # bestanden}} wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"bulkDeleteConfirmTitle": "Geselecteerde Bestanden Verwijderen",
"bulkDeleteProgress": "{count, plural, =1 {1 bestand} other {# bestanden}} verwijderen...",
"bulkDeleteSuccess": "{count, plural, =1 {1 bestand succesvol verwijderd} other {# bestanden succesvol verwijderd}}",
"selectAll": "Alles selecteren",
"selectFile": "Selecteer bestand {fileName}",
"deleteError": "Fout bij verwijderen bestand",
"deleteSuccess": "Bestand succesvol verwijderd"
}
},
"form": {
@@ -1214,7 +1297,19 @@
"passwordHelp": "Wachtwoord moet minimaal 4 tekens bevatten",
"passwordPlaceholder": "Voer een wachtwoord in om de link te beveiligen"
},
"submit": "Ontvangstlink Aanmaken"
"submit": "Ontvangstlink Aanmaken",
"emailFieldRequired": {
"label": "E -mailveldvereiste",
"description": "Configureer of het veld Uploader e -mail moet worden getoond en of het vereist is"
},
"fieldRequirements": {
"title": "Veldvereisten",
"description": "Configureer welke velden worden weergegeven in het uploadformulier"
},
"nameFieldRequired": {
"label": "Naam veldvereiste",
"description": "Configureer of het veld Uploader Naam moet worden getoond en of het vereist is"
}
},
"messages": {
"created": "Ontvangstlink succesvol aangemaakt!",
@@ -1224,14 +1319,17 @@
"deleteSuccess": "Ontvangstlink succesvol verwijderd!",
"aliasCreated": "Alias succesvol aangemaakt!",
"activateSuccess": "Ontvangstlink succesvol geactiveerd!",
"deactivateSuccess": "Ontvangstlink succesvol gedeactiveerd!"
"deactivateSuccess": "Ontvangstlink succesvol gedeactiveerd!",
"passwordProtectionDisabled": "Wachtwoordbeveiliging succesvol verwijderd!",
"passwordProtectionEnabled": "Wachtwoordbeveiliging succesvol ingeschakeld!"
},
"errors": {
"loadFailed": "Fout bij laden ontvangstlinks",
"createFailed": "Fout bij aanmaken ontvangstlink. Probeer het opnieuw.",
"updateFailed": "Fout bij bijwerken ontvangstlink. Probeer het opnieuw.",
"deleteFailed": "Fout bij verwijderen ontvangstlink. Probeer het opnieuw.",
"aliasCreateFailed": "Fout bij aanmaken alias. Probeer het opnieuw."
"aliasCreateFailed": "Fout bij aanmaken alias. Probeer het opnieuw.",
"passwordUpdateFailed": "Fout bij bijwerken wachtwoordbeveiliging"
},
"delete": {
"title": "Ontvangstlink verwijderen",
@@ -1269,11 +1367,14 @@
"linkInactive": "Deze link is inactief.",
"linkExpired": "Deze link is verlopen.",
"uploadFailed": "Fout bij uploaden bestand",
"retry": "Opnieuw Proberen",
"fileTooLarge": "Bestand te groot. Maximum grootte: {maxSize}",
"fileTypeNotAllowed": "Bestandstype niet toegestaan. Toegestane types: {allowedTypes}",
"maxFilesExceeded": "Maximum van {maxFiles} bestanden toegestaan",
"selectAtLeastOneFile": "Selecteer ten minste één bestand",
"provideNameOrEmail": "Voer uw naam of e-mail in"
"provideNameOrEmail": "Voer uw naam of e-mail in",
"provideEmailRequired": "E -mail is vereist",
"provideNameRequired": "Naam is vereist"
},
"fileDropzone": {
"dragActive": "Laat bestanden hier los",
@@ -1296,7 +1397,9 @@
"descriptionLabel": "Beschrijving (optioneel)",
"descriptionPlaceholder": "Voeg een beschrijving toe aan de bestanden...",
"uploadButton": "{count} bestand(en) verzenden",
"uploading": "Verzenden..."
"uploading": "Verzenden...",
"emailLabelOptional": "E -mail (optioneel)",
"nameLabelOptional": "Naam (optioneel)"
},
"success": {
"title": "Bestanden succesvol verzonden! 🎉",
@@ -1334,12 +1437,15 @@
"cancel": "Annuleren",
"preview": "Voorvertoning",
"download": "Downloaden",
"delete": "Verwijderen"
"delete": "Verwijderen",
"copyToMyFiles": "Kopieer naar mijn bestanden",
"copying": "Kopiëren ..."
},
"editField": {
"saveChanges": "Wijzigingen opslaan",
"cancelEdit": "Bewerken annuleren"
}
}
},
"defaultLinkName": "Ontvangen bestanden"
}
}

View File

@@ -202,6 +202,8 @@
"login": {
"welcome": "Witaj w",
"signInToContinue": "Zaloguj się, aby kontynuować",
"emailOrUsernameLabel": "E-mail lub nazwa użytkownika",
"emailOrUsernamePlaceholder": "Wprowadź swój e-mail lub nazwę użytkownika",
"emailLabel": "Adres e-mail",
"emailPlaceholder": "Wprowadź swój adres e-mail",
"passwordLabel": "Hasło",
@@ -526,10 +528,30 @@
"oidcAdminEmailDomains": {
"title": "Domeny e-mail administratora",
"description": "Domeny e-mail, które automatycznie otrzymują uprawnienia administratora"
},
"testSmtp": {
"title": "Test połączenia SMTP",
"description": "Sprawdź, czy konfiguracja SMTP jest prawidłowa"
},
"smtpNoAuth": {
"title": "Bez uwierzytelniania",
"description": "Włącz tę opcję dla serwerów wewnętrznych, które nie wymagają nazwy użytkownika/hasła (ukrywa pola uwierzytelniania)"
},
"smtpSecure": {
"title": "Bezpieczeństwo połączenia",
"description": "Metoda zabezpieczenia połączenia SMTP - Auto (zalecane), SSL, STARTTLS lub Brak (niezabezpieczone)",
"options": {
"auto": "Auto (Zalecane)",
"ssl": "SSL (Port 465)",
"tls": "STARTTLS (Port 587)",
"none": "Brak (Niezabezpieczone)"
}
}
},
"buttons": {
"save": "Zapisz {group}"
"save": "Zapisz {group}",
"testSmtp": "Testuj połączenie",
"testing": "Testowanie..."
},
"errors": {
"loadFailed": "Nie udało się załadować ustawień",
@@ -537,14 +559,22 @@
},
"messages": {
"noChanges": "Brak zmian do zapisania",
"updateSuccess": "Ustawienia {group} zaktualizowane pomyślnie"
"updateSuccess": "Ustawienia {group} zaktualizowane pomyślnie",
"smtpTestFailed": "Połączenie SMTP nie powiodło się: {error}",
"smtpTestGenericError": "Nie udało się przetestować połączenia SMTP. Sprawdź ustawienia i spróbuj ponownie.",
"smtpTestSuccess": "Połączenie SMTP udane! Twoja konfiguracja poczty e-mail działa poprawnie.",
"smtpMissingAuth": "Proszę wypełnić nazwę użytkownika i hasło SMTP lub włączyć opcję 'Bez uwierzytelniania'.",
"smtpMissingHostPort": "Proszę wypełnić host i port SMTP przed testowaniem.",
"smtpNotEnabled": "SMTP nie jest włączone. Proszę najpierw włączyć SMTP."
},
"title": "Ustawienia",
"breadcrumb": "Ustawienia",
"pageTitle": "Ustawienia",
"tooltips": {
"oidcScope": "Wprowadź zakres i naciśnij Enter, aby dodać",
"oidcAdminEmailDomains": "Wprowadź domenę i naciśnij Enter, aby dodać"
"oidcAdminEmailDomains": "Wprowadź domenę i naciśnij Enter, aby dodać",
"testSmtp": "Testuje połączenie SMTP z obecnie wprowadzonymi wartościami w formularzu. Aby wprowadzić trwałe zmiany, pamiętaj o zapisaniu ustawień po testowaniu.",
"defaultPlaceholder": "Wprowadź i naciśnij Enter"
},
"redirectUri": {
"placeholder": "https://mysite.com",
@@ -577,7 +607,8 @@
"title": "Udostępnienie nie znaleziono",
"description": "To udostępnienie mogło zostać usunięte lub wygasło."
},
"pageTitle": "Udostępnij"
"pageTitle": "Udostępnij",
"downloadAll": "[TO_TRANSLATE] Download All"
},
"shareActions": {
"deleteTitle": "Usuń udostępnienie",
@@ -712,7 +743,16 @@
"notifyError": "Nie udało się powiadomić odbiorców",
"bulkDeleteError": "Nie udało się usunąć udostępnień",
"bulkDeleteLoading": "Usuwanie {count, plural, =1 {1 udostępnienia} other {# udostępnień}}...",
"bulkDeleteSuccess": "{count, plural, =1 {1 udostępnienie usunięte pomyślnie} other {# udostępnień usuniętych pomyślnie}}"
"bulkDeleteSuccess": "{count, plural, =1 {1 udostępnienie usunięte pomyślnie} other {# udostępnień usuniętych pomyślnie}}",
"creatingZip": "Tworzenie pliku zip ...",
"defaultShareName": "Udział",
"downloadError": "Nie udało się pobrać plików udostępniania",
"downloadSuccess": "Pobierz zaczęło się pomyślnie",
"multipleSharesZipName": "{Count} _Shares_files.zip",
"noFilesToDownload": "Brak plików do pobrania",
"singleShareZipName": "{ShaRename} _files.zip",
"zipDownloadError": "Nie udało się utworzyć pliku zip",
"zipDownloadSuccess": "Plik zip pobrany pomyślnie"
},
"shares": {
"errors": {
@@ -775,11 +815,14 @@
"editLink": "Edytuj link",
"copyLink": "Skopiuj link",
"notifyRecipients": "Powiadom odbiorców",
"delete": "Usuń"
"delete": "Usuń",
"downloadShareFiles": "Pobierz wszystkie pliki"
},
"bulkActions": {
"delete": "Usuń",
"selected": "{count, plural, =1 {Wybrano 1 udostępnienie} other {# wybranych udostępnień}}"
"selected": "{count, plural, =1 {Wybrano 1 udostępnienie} other {# wybranych udostępnień}}",
"actions": "Działania",
"download": "Pobierz wybrany"
},
"selectAll": "Zaznacz wszystko",
"selectShare": "Wybierz udostępnienie {shareName}"
@@ -788,7 +831,15 @@
"title": "Użycie pamięci",
"ariaLabel": "Pasek postępu użycia pamięci",
"used": "użyte",
"available": "dostępne"
"available": "dostępne",
"loading": "Ładowanie...",
"retry": "Spróbuj ponownie",
"errors": {
"title": "Informacje o pamięci niedostępne",
"detectionFailed": "Nie można wykryć miejsca na dysku. Może to być spowodowane problemami z konfiguracją systemu lub niewystarczającymi uprawnieniami.",
"serverError": "Wystąpił błąd serwera podczas pobierania informacji o pamięci. Spróbuj ponownie później.",
"unknown": "Wystąpił nieoczekiwany błąd podczas ładowania informacji o pamięci."
}
},
"theme": {
"toggle": "Przełącz motyw",
@@ -806,6 +857,7 @@
"uploadProgress": "Postęp przesyłania",
"upload": "Prześlij",
"startUploads": "Rozpocznij przesyłanie",
"retry": "Spróbuj Ponownie",
"finish": "Zakończ",
"success": "Plik przesłany pomyślnie",
"allSuccess": "{count, plural, =1 {Plik przesłany pomyślnie} other {# plików przesłanych pomyślnie}}",
@@ -901,6 +953,7 @@
"passwordLength": "Hasło musi mieć co najmniej 8 znaków",
"passwordsMatch": "Hasła muszą być zgodne",
"emailRequired": "E-mail jest wymagany",
"emailOrUsernameRequired": "E-mail lub nazwa użytkownika jest wymagana",
"passwordRequired": "Hasło jest wymagane",
"passwordMinLength": "Hasło musi mieć co najmniej 6 znaków",
"nameRequired": "Nazwa jest wymagana",
@@ -1000,7 +1053,15 @@
"noFilesLimit": "Bez limitu plików",
"noSizeLimit": "Bez limitu rozmiaru",
"allFileTypes": "Wszystkie typy plików",
"fileTypesHelp": "Wprowadź rozszerzenia bez kropek, oddzielone spacją, przecinkiem, myślnikiem lub kreską pionową"
"fileTypesHelp": "Wprowadź rozszerzenia bez kropek, oddzielone spacją, przecinkiem, myślnikiem lub kreską pionową",
"emailFieldRequired": "Pole e -mail",
"fieldOptions": {
"hidden": "Ukryty",
"optional": "Fakultatywny",
"required": "Wymagany"
},
"fieldRequirements": "Wymagania terenowe",
"nameFieldRequired": "Pole Nazwa"
},
"card": {
"untitled": "Link bez tytułu",
@@ -1145,7 +1206,9 @@
},
"actions": {
"preview": "Podgląd",
"download": "Pobierz"
"download": "Pobierz",
"copyToMyFiles": "Skopiuj do moich plików",
"copying": "Biurowy..."
},
"uploadedBy": "Przesłane przez {name}",
"anonymous": "Anonimowy",
@@ -1153,7 +1216,27 @@
"downloadError": "Błąd pobierania pliku",
"editSuccess": "Plik zaktualizowany pomyślnie",
"editError": "Błąd aktualizacji pliku",
"previewNotAvailable": "Podgląd niedostępny"
"previewNotAvailable": "Podgląd niedostępny",
"copyError": "Plik kopiowania błędów do plików",
"copySuccess": "Plik skopiowany do plików pomyślnie",
"bulkActions": {
"selected": "{count, plural, =1 {1 plik wybrany} other {# plików wybranych}}",
"actions": "Akcje",
"download": "Pobierz wybrane",
"copyToMyFiles": "Skopiuj wybrane do Moich plików",
"delete": "Usuń wybrane"
},
"bulkCopyProgress": "Kopiowanie {count, plural, =1 {1 pliku} other {# plików}} do twoich plików...",
"bulkCopySuccess": "{count, plural, =1 {1 plik skopiowany pomyślnie do twoich plików} other {# plików skopiowanych pomyślnie do twoich plików}}",
"bulkDeleteConfirmButton": "Usuń {count, plural, =1 {plik} other {pliki}}",
"bulkDeleteConfirmMessage": "Czy na pewno chcesz usunąć {count, plural, =1 {ten plik} other {te # pliki}}? Tej akcji nie można cofnąć.",
"bulkDeleteConfirmTitle": "Usuń wybrane pliki",
"bulkDeleteProgress": "Usuwanie {count, plural, =1 {1 pliku} other {# plików}}...",
"bulkDeleteSuccess": "{count, plural, =1 {1 plik usunięty pomyślnie} other {# plików usuniętych pomyślnie}}",
"selectAll": "Zaznacz wszystko",
"selectFile": "Wybierz plik {fileName}",
"deleteError": "Błąd usuwania pliku",
"deleteSuccess": "Plik usunięty pomyślnie"
}
},
"form": {
@@ -1214,7 +1297,19 @@
"passwordHelp": "Hasło musi mieć co najmniej 4 znaki",
"passwordPlaceholder": "Wprowadź hasło, aby chronić link"
},
"submit": "Utwórz link do odbierania"
"submit": "Utwórz link do odbierania",
"emailFieldRequired": {
"label": "Wymagania pola e -mail",
"description": "Skonfiguruj, czy należy wyświetlić pole e -mail przesyłania, a jeśli jest to wymagane"
},
"fieldRequirements": {
"title": "Wymagania terenowe",
"description": "Skonfiguruj, które pola są pokazane w formularzu przesyłania"
},
"nameFieldRequired": {
"label": "Nazwa Wymagania pola",
"description": "Skonfiguruj, czy należy wyświetlić pole nazwy przesyłania, a jeśli jest to wymagane"
}
},
"messages": {
"created": "Link do odbierania utworzony pomyślnie!",
@@ -1224,14 +1319,17 @@
"deleteSuccess": "Link do odbierania usunięty pomyślnie!",
"aliasCreated": "Alias utworzony pomyślnie!",
"activateSuccess": "Link do odbierania aktywowany pomyślnie!",
"deactivateSuccess": "Link do odbierania dezaktywowany pomyślnie!"
"deactivateSuccess": "Link do odbierania dezaktywowany pomyślnie!",
"passwordProtectionDisabled": "Ochrona hasłem została pomyślnie usunięta!",
"passwordProtectionEnabled": "Ochrona hasłem została pomyślnie włączona!"
},
"errors": {
"loadFailed": "Nie udało się załadować linków do odbierania",
"createFailed": "Nie udało się utworzyć linku do odbierania. Spróbuj ponownie.",
"updateFailed": "Nie udało się zaktualizować linku do odbierania. Spróbuj ponownie.",
"deleteFailed": "Nie udało się usunąć linku do odbierania. Spróbuj ponownie.",
"aliasCreateFailed": "Nie udało się utworzyć aliasu. Spróbuj ponownie."
"aliasCreateFailed": "Nie udało się utworzyć aliasu. Spróbuj ponownie.",
"passwordUpdateFailed": "Nie udało się zaktualizować ochrony hasłem"
},
"delete": {
"title": "Usuń link do odbierania",
@@ -1269,11 +1367,14 @@
"linkInactive": "Ten link jest nieaktywny.",
"linkExpired": "Ten link wygasł.",
"uploadFailed": "Błąd przesyłania pliku",
"retry": "Spróbuj Ponownie",
"fileTooLarge": "Plik za duży. Maksymalny rozmiar: {maxSize}",
"fileTypeNotAllowed": "Typ pliku niedozwolony. Akceptowane typy: {allowedTypes}",
"maxFilesExceeded": "Dozwolono maksymalnie {maxFiles} plików",
"selectAtLeastOneFile": "Wybierz co najmniej jeden plik",
"provideNameOrEmail": "Proszę podać swoje imię lub adres e-mail"
"provideNameOrEmail": "Proszę podać swoje imię lub adres e-mail",
"provideEmailRequired": "Wymagany jest e -mail",
"provideNameRequired": "Nazwa jest wymagana"
},
"fileDropzone": {
"dragActive": "Upuść pliki tutaj",
@@ -1296,7 +1397,9 @@
"descriptionLabel": "Opis (opcjonalnie)",
"descriptionPlaceholder": "Dodaj opis do plików...",
"uploadButton": "Wyślij {count} plik(ów)",
"uploading": "Wysyłanie..."
"uploading": "Wysyłanie...",
"emailLabelOptional": "E -mail (opcjonalnie)",
"nameLabelOptional": "Nazwa (opcjonalnie)"
},
"success": {
"title": "Pliki wysłane pomyślnie! 🎉",
@@ -1334,12 +1437,15 @@
"cancel": "Anuluj",
"preview": "Podgląd",
"download": "Pobierz",
"delete": "Usuń"
"delete": "Usuń",
"copyToMyFiles": "Skopiuj do moich plików",
"copying": "Biurowy..."
},
"editField": {
"saveChanges": "Zapisz zmiany",
"cancelEdit": "Anuluj edycję"
}
}
},
"defaultLinkName": "Otrzymane pliki"
}
}
}

View File

@@ -18,17 +18,17 @@
"click": "Clique para"
},
"createShare": {
"title": "Criar Compartilhamento",
"nameLabel": "Nome do Compartilhamento",
"title": "Criar compartilhamento",
"nameLabel": "Nome do compartilhamento",
"descriptionLabel": "Descrição",
"descriptionPlaceholder": "Digite uma descrição (opcional)",
"expirationLabel": "Data de Expiração",
"expirationLabel": "Data de expiração",
"expirationPlaceholder": "DD/MM/AAAA HH:MM",
"maxViewsLabel": "Máximo de Visualizações",
"maxViewsLabel": "Máximo de visualizações",
"maxViewsPlaceholder": "Deixe vazio para ilimitado",
"passwordProtection": "Protegido por Senha",
"passwordLabel": "Senha",
"create": "Criar Compartilhamento",
"create": "Criar compartilhamento",
"success": "Compartilhamento criado com sucesso",
"error": "Falha ao criar compartilhamento"
},
@@ -44,7 +44,7 @@
},
"emptyState": {
"noFiles": "Nenhum arquivo enviado ainda",
"uploadFile": "Enviar Arquivo"
"uploadFile": "Enviar arquivo"
},
"errors": {
"invalidCredentials": "E-mail ou senha inválidos",
@@ -53,13 +53,13 @@
"unexpectedError": "Ocorreu um erro inesperado. Por favor, tente novamente"
},
"fileActions": {
"editFile": "Editar Arquivo",
"editFile": "Editar arquivo",
"nameLabel": "Nome",
"namePlaceholder": "Digite o novo nome",
"extension": "Extensão",
"descriptionLabel": "Descrição",
"descriptionPlaceholder": "Digite a descrição do arquivo",
"deleteFile": "Excluir Arquivo",
"deleteFile": "Excluir arquivo",
"deleteConfirmation": "Tem certeza que deseja excluir ?",
"deleteWarning": "Esta ação não pode ser desfeita."
},
@@ -154,9 +154,9 @@
"bulkActions": {
"selected": "{count, plural, =1 {1 arquivo selecionado} other {# arquivos selecionados}}",
"actions": "Ações",
"download": "Baixar Selecionados",
"share": "Compartilhar Selecionados",
"delete": "Excluir Selecionados"
"download": "Baixar selecionados",
"share": "Compartilhar selecionados",
"delete": "Excluir selecionados"
}
},
"footer": {
@@ -175,23 +175,23 @@
"pageTitle": "Esqueceu a Senha"
},
"generateShareLink": {
"generateTitle": "Gerar Link de Compartilhamento",
"updateTitle": "Atualizar Link de Compartilhamento",
"generateTitle": "Gerar link de compartilhamento",
"updateTitle": "Atualizar link de compartilhamento",
"generateDescription": "Gere um link para compartilhar seus arquivos",
"updateDescription": "Atualize o alias deste link de compartilhamento",
"aliasPlaceholder": "Digite o alias",
"linkReady": "Seu link de compartilhamento está pronto:",
"generateButton": "Gerar Link",
"updateButton": "Atualizar Link",
"copyButton": "Copiar Link",
"generateButton": "Gerar link",
"updateButton": "Atualizar link",
"copyButton": "Copiar link",
"success": "Link gerado com sucesso",
"error": "Erro ao gerar link",
"copied": "Link copiado para a área de transferência"
},
"shareFile": {
"title": "Compartilhar Arquivo",
"linkTitle": "Gerar Link",
"nameLabel": "Nome do Compartilhamento",
"title": "Compartilhar arquivo",
"linkTitle": "Gerar link",
"nameLabel": "Nome do compartilhamento",
"namePlaceholder": "Digite o nome do compartilhamento",
"descriptionLabel": "Descrição",
"descriptionPlaceholder": "Digite uma descrição (opcional)",
@@ -199,16 +199,16 @@
"expirationPlaceholder": "DD/MM/AAAA HH:MM",
"maxViewsLabel": "Máximo de Visualizações",
"maxViewsPlaceholder": "Deixe vazio para ilimitado",
"passwordProtection": "Protegido por Senha",
"passwordProtection": "Protegido por senha",
"passwordLabel": "Senha",
"passwordPlaceholder": "Digite a senha",
"linkDescription": "Gere um link personalizado para compartilhar o arquivo",
"aliasLabel": "Alias do Link",
"aliasLabel": "Alias do link",
"aliasPlaceholder": "Digite um alias personalizado",
"linkReady": "Seu link de compartilhamento está pronto:",
"createShare": "Criar Compartilhamento",
"generateLink": "Gerar Link",
"copyLink": "Copiar Link"
"createShare": "Criar compartilhamento",
"generateLink": "Gerar link",
"copyLink": "Copiar link"
},
"home": {
"description": "A alternativa open-source ao WeTransfer. Compartilhe arquivos com segurança, sem rastreamento ou limitações.",
@@ -223,7 +223,9 @@
},
"login": {
"welcome": "Bem-vindo ao",
"signInToContinue": "Entre para continuar",
"signInToContinue": "Faça login para continuar",
"emailOrUsernameLabel": "E-mail ou Nome de Usuário",
"emailOrUsernamePlaceholder": "Digite seu e-mail ou nome de usuário",
"emailLabel": "Endereço de E-mail",
"emailPlaceholder": "Digite seu e-mail",
"passwordLabel": "Senha",
@@ -231,18 +233,18 @@
"signIn": "Entrar",
"signingIn": "Entrando...",
"forgotPassword": "Esqueceu a senha?",
"pageTitle": "Entrar",
"pageTitle": "Login",
"or": "ou",
"continueWithSSO": "Continuar com SSO",
"processing": "Processando autenticação..."
},
"logo": {
"labels": {
"appLogo": "Logo do Aplicativo"
"appLogo": "Logo do aplicativo"
},
"buttons": {
"upload": "Enviar Logo",
"remove": "Remover Logo"
"upload": "Enviar logo",
"remove": "Remover logo"
},
"messages": {
"uploadSuccess": "Logo enviado com sucesso",
@@ -254,11 +256,11 @@
}
},
"navbar": {
"logoAlt": "Logo do Aplicativo",
"logoAlt": "Logo do aplicativo",
"profileMenu": "Menu do Perfil",
"profile": "Perfil",
"settings": "Configurações",
"usersManagement": "Gerenciar Usuários",
"usersManagement": "Gerenciar usuários",
"logout": "Sair"
},
"navigation": {
@@ -441,7 +443,9 @@
},
"tooltips": {
"oidcScope": "Digite um escopo e pressione Enter para adicionar",
"oidcAdminEmailDomains": "Digite um domínio e pressione Enter para adicionar"
"oidcAdminEmailDomains": "Digite um domínio e pressione Enter para adicionar",
"testSmtp": "Testa a conexão SMTP com os valores atualmente inseridos no formulário. Para tornar as alterações permanentes, lembre-se de salvar suas configurações após o teste.",
"defaultPlaceholder": "Digite e pressione Enter"
},
"redirectUri": {
"placeholder": "https://meusite.com",
@@ -556,10 +560,30 @@
"oidcAdminEmailDomains": {
"title": "Domínios de E-mail Admin",
"description": "Domínios de e-mail que recebem privilégios de administrador automaticamente"
},
"testSmtp": {
"title": "Testar Conexão SMTP",
"description": "Testa se a configuração SMTP é válida"
},
"smtpNoAuth": {
"title": "Sem Autenticação",
"description": "Ative isso para servidores internos que não exigem nome de usuário/senha (oculta campos de autenticação)"
},
"smtpSecure": {
"title": "Segurança da Conexão",
"description": "Método de segurança da conexão SMTP - Auto (recomendado), SSL, STARTTLS ou Nenhum (inseguro)",
"options": {
"auto": "Auto (Recomendado)",
"ssl": "SSL (Porta 465)",
"tls": "STARTTLS (Porta 587)",
"none": "Nenhum (Inseguro)"
}
}
},
"buttons": {
"save": "Salvar {group}"
"save": "Salvar {group}",
"testSmtp": "Testar Conexão",
"testing": "Testando..."
},
"errors": {
"loadFailed": "Falha ao carregar configurações",
@@ -567,7 +591,13 @@
},
"messages": {
"noChanges": "Nenhuma alteração para salvar",
"updateSuccess": "Configurações de {group} atualizadas com sucesso"
"updateSuccess": "Configurações de {group} atualizadas com sucesso",
"smtpTestFailed": "Falha na conexão SMTP: {error}",
"smtpTestGenericError": "Falha ao testar conexão SMTP. Por favor, verifique suas configurações e tente novamente.",
"smtpTestSuccess": "Conexão SMTP bem-sucedida! Sua configuração de e-mail está funcionando corretamente.",
"smtpMissingAuth": "Por favor, preencha o Nome de Usuário e Senha SMTP, ou ative a opção 'Sem Autenticação'.",
"smtpMissingHostPort": "Por favor, preencha o Host e Porta SMTP antes de testar.",
"smtpNotEnabled": "SMTP não está habilitado. Por favor, habilite o SMTP primeiro."
},
"title": "Configurações",
"breadcrumb": "Configurações",
@@ -599,7 +629,8 @@
"title": "Compartilhamento não Encontrado",
"description": "Este compartilhamento pode ter sido excluído ou expirado."
},
"pageTitle": "Compartilhamento"
"pageTitle": "Compartilhamento",
"downloadAll": "Baixar todos"
},
"shareActions": {
"deleteTitle": "Excluir Compartilhamento",
@@ -676,7 +707,16 @@
"securityUpdateError": "Falha ao atualizar configurações de segurança",
"expirationUpdateError": "Falha ao atualizar configurações de expiração",
"securityUpdateSuccess": "Configurações de segurança atualizadas com sucesso",
"expirationUpdateSuccess": "Configurações de expiração atualizadas com sucesso"
"expirationUpdateSuccess": "Configurações de expiração atualizadas com sucesso",
"creatingZip": "Criando arquivo zip ...",
"defaultShareName": "Compartilhar",
"downloadError": "Falha ao baixar arquivos de compartilhamento",
"downloadSuccess": "Download começou com sucesso",
"multipleSharesZipName": "{count} _shares_files.zip",
"noFilesToDownload": "Nenhum arquivo disponível para download",
"singleShareZipName": "{sharename}.zip",
"zipDownloadError": "Falha ao criar o arquivo zip",
"zipDownloadSuccess": "FILE DE ZIP FILHADO COMBONHADO com sucesso"
},
"shares": {
"errors": {
@@ -711,7 +751,9 @@
"selectShare": "Selecionar compartilhamento {shareName}",
"bulkActions": {
"selected": "{count, plural, =1 {1 compartilhamento selecionado} other {# compartilhamentos selecionados}}",
"delete": "Excluir"
"delete": "Excluir",
"actions": "Ações",
"download": "Download selecionado"
},
"columns": {
"name": "NOME",
@@ -745,14 +787,23 @@
"editLink": "Editar Link",
"copyLink": "Copiar Link",
"notifyRecipients": "Notificar Destinatários",
"delete": "Excluir"
"delete": "Excluir",
"downloadShareFiles": "Baixar todos os arquivos"
}
},
"storageUsage": {
"title": "Uso de Armazenamento",
"ariaLabel": "Barra de progresso do uso de armazenamento",
"used": "usado",
"available": "disponível"
"available": "disponível",
"loading": "Carregando...",
"retry": "Tentar novamente",
"errors": {
"title": "Informações de armazenamento indisponíveis",
"detectionFailed": "Não foi possível detectar o espaço em disco. Isso pode ser devido a problemas de configuração do sistema ou permissões insuficientes.",
"serverError": "Ocorreu um erro no servidor ao recuperar as informações de armazenamento. Por favor, tente novamente mais tarde.",
"unknown": "Ocorreu um erro inesperado ao carregar as informações de armazenamento."
}
},
"theme": {
"toggle": "Alternar tema",
@@ -770,6 +821,7 @@
"uploadProgress": "Progresso do upload",
"upload": "Enviar",
"startUploads": "Iniciar Uploads",
"retry": "Tentar Novamente",
"finish": "Concluir",
"success": "Arquivo enviado com sucesso",
"allSuccess": "{count, plural, =1 {Arquivo enviado com sucesso} other {# arquivos enviados com sucesso}}",
@@ -857,18 +909,19 @@
}
},
"validation": {
"invalidEmail": "Endereço de email inválido",
"passwordMinLength": "A senha deve ter pelo menos 6 caracteres",
"firstNameRequired": "Nome é obrigatório",
"lastNameRequired": "Sobrenome é obrigatório",
"usernameLength": "Nome de usuário deve ter pelo menos 3 caracteres",
"usernameSpaces": "Nome de usuário não pode conter espaços",
"invalidEmail": "Por favor, insira um endereço de e-mail válido",
"passwordLength": "A senha deve ter pelo menos 8 caracteres",
"passwordsMatch": "As senhas não coincidem",
"passwordsMatch": "As senhas devem coincidir",
"emailRequired": "Email é obrigatório",
"emailOrUsernameRequired": "E-mail ou nome de usuário é obrigatório",
"passwordRequired": "Senha é obrigatória",
"passwordMinLength": "A senha deve ter pelo menos 6 caracteres",
"nameRequired": "Nome é obrigatório",
"required": "Este campo é obrigatório",
"nameRequired": "Nome é obrigatório"
"firstNameRequired": "O primeiro nome é necessário",
"lastNameRequired": "O sobrenome é necessário",
"usernameLength": "O nome de usuário deve ter pelo menos 3 caracteres",
"usernameSpaces": "O nome de usuário não pode conter espaços"
},
"bulkDownload": {
"title": "Download em Lote",
@@ -937,8 +990,8 @@
"noExpiration": "Este compartilhamento nunca expirará e permanecerá acessível indefinidamente.",
"title": "Sobre expiração:"
},
"enableExpiration": "Habilitar Expiração",
"title": "Configurações de Expiração do Compartilhamento",
"enableExpiration": "Habilitar expiração",
"title": "Configurações de expiração do compartilhamento",
"subtitle": "Configurar quando este compartilhamento expirará",
"validation": {
"dateMustBeFuture": "A data de expiração deve estar no futuro",
@@ -949,7 +1002,7 @@
"updateFailed": "Falha ao atualizar configurações de expiração"
},
"expires": "Expira:",
"expirationDate": "Data de Expiração"
"expirationDate": "Data de expiração"
},
"auth": {
"errors": {
@@ -961,10 +1014,10 @@
}
},
"reverseShares": {
"pageTitle": "Receber Arquivos",
"pageTitle": "Receber arquivos",
"search": {
"title": "Gerenciar Links de Recebimento",
"createButton": "Criar Link",
"title": "Gerenciar links de recebimento",
"createButton": "Criar link",
"placeholder": "Buscar links de recebimento...",
"results": "Encontrados {filtered} de {total} links de recebimento"
},
@@ -974,13 +1027,13 @@
"status": "status",
"access": "acesso",
"description": "Descrição",
"pageLayout": "Layout da Página",
"pageLayout": "Layout da página",
"security": "Segurança & Status",
"limits": "Limites",
"maxFiles": "Máximo de Arquivos",
"maxFileSize": "Tamanho Máximo",
"allowedTypes": "Tipos Permitidos",
"filesReceived": "Arquivos Recebidos",
"maxFiles": "Máximo de arquivos",
"maxFileSize": "Tamanho máximo",
"allowedTypes": "Tipos permitidos",
"filesReceived": "Arquivos recebidos",
"fileLimit": "Limite de Arquivos",
"noLimit": "Sem limite",
"noLinkCreated": "Nenhum link criado",
@@ -1000,7 +1053,15 @@
"noFilesLimit": "Sem limite de arquivos",
"noSizeLimit": "Sem limite de tamanho",
"allFileTypes": "Todos os tipos de arquivo",
"fileTypesHelp": "Digite as extensões sem ponto, separadas por espaço, vírgula, traço ou pipe"
"fileTypesHelp": "Digite as extensões sem ponto, separadas por espaço, vírgula, traço ou pipe",
"emailFieldRequired": "Campo de e -mail",
"fieldOptions": {
"hidden": "Oculto",
"optional": "Opcional",
"required": "Obrigatório"
},
"fieldRequirements": "Requisitos de campo",
"nameFieldRequired": "Campo de nome"
},
"card": {
"untitled": "Link sem título",
@@ -1145,7 +1206,9 @@
},
"actions": {
"preview": "Visualizar",
"download": "Baixar"
"download": "Baixar",
"copyToMyFiles": "Copiar para meus arquivos",
"copying": "Copiando..."
},
"uploadedBy": "Enviado por {name}",
"anonymous": "Anônimo",
@@ -1153,7 +1216,27 @@
"downloadError": "Erro ao baixar arquivo",
"editSuccess": "Arquivo atualizado com sucesso",
"editError": "Erro ao atualizar arquivo",
"previewNotAvailable": "Visualização não disponível"
"previewNotAvailable": "Visualização não disponível",
"copySuccess": "Arquivo copiado para seus arquivos com sucesso",
"copyError": "Erro ao copiar arquivo para seus arquivos",
"bulkActions": {
"selected": "{count, plural, =1 {1 arquivo selecionado} other {# arquivos selecionados}}",
"actions": "Ações",
"download": "Baixar Selecionados",
"copyToMyFiles": "Copiar Selecionados para Meus Arquivos",
"delete": "Excluir Selecionados"
},
"bulkCopyProgress": "Copiando {count, plural, =1 {1 arquivo} other {# arquivos}} para seus arquivos...",
"bulkCopySuccess": "{count, plural, =1 {1 arquivo copiado para seus arquivos com sucesso} other {# arquivos copiados para seus arquivos com sucesso}}",
"bulkDeleteConfirmButton": "Excluir {count, plural, =1 {Arquivo} other {Arquivos}}",
"bulkDeleteConfirmMessage": "Tem certeza que deseja excluir {count, plural, =1 {este arquivo} other {estes # arquivos}}? Esta ação não pode ser desfeita.",
"bulkDeleteConfirmTitle": "Excluir Arquivos Selecionados",
"bulkDeleteProgress": "Excluindo {count, plural, =1 {1 arquivo} other {# arquivos}}...",
"bulkDeleteSuccess": "{count, plural, =1 {1 arquivo excluído com sucesso} other {# arquivos excluídos com sucesso}}",
"selectAll": "Selecionar todos",
"selectFile": "Selecionar arquivo {fileName}",
"deleteError": "Erro ao excluir arquivo",
"deleteSuccess": "Arquivo excluído com sucesso"
}
},
"form": {
@@ -1214,7 +1297,19 @@
"passwordHelp": "A senha deve ter pelo menos 4 caracteres",
"passwordPlaceholder": "Digite uma senha para proteger o link"
},
"submit": "Criar Link de Recebimento"
"submit": "Criar Link de Recebimento",
"emailFieldRequired": {
"label": "Requisito de campo de e -mail",
"description": "Configure se o campo de email do upload deve ser mostrado e se for necessário"
},
"fieldRequirements": {
"title": "Requisitos de campo",
"description": "Configure quais campos são mostrados no formulário de upload"
},
"nameFieldRequired": {
"label": "Nome Requisito de campo",
"description": "Configure se o campo de nome do upload deve ser mostrado e se for necessário"
}
},
"messages": {
"created": "Link de recebimento criado com sucesso!",
@@ -1224,14 +1319,17 @@
"deleteSuccess": "Link de recebimento excluído com sucesso!",
"aliasCreated": "Alias criado com sucesso!",
"activateSuccess": "Link de recebimento ativado com sucesso!",
"deactivateSuccess": "Link de recebimento desativado com sucesso!"
"deactivateSuccess": "Link de recebimento desativado com sucesso!",
"passwordProtectionDisabled": "Proteção por senha removida com sucesso!",
"passwordProtectionEnabled": "Proteção por senha ativada com sucesso!"
},
"errors": {
"loadFailed": "Falha ao carregar links de recebimento",
"createFailed": "Falha ao criar link de recebimento. Tente novamente.",
"updateFailed": "Falha ao atualizar link de recebimento. Tente novamente.",
"deleteFailed": "Falha ao excluir link de recebimento. Tente novamente.",
"aliasCreateFailed": "Falha ao criar alias. Tente novamente."
"aliasCreateFailed": "Falha ao criar alias. Tente novamente.",
"passwordUpdateFailed": "Falha ao atualizar a proteção por senha"
},
"delete": {
"title": "Excluir link de recebimento",
@@ -1269,11 +1367,14 @@
"linkInactive": "Este link está inativo.",
"linkExpired": "Este link expirou.",
"uploadFailed": "Erro ao enviar arquivo",
"retry": "Tentar Novamente",
"fileTooLarge": "Arquivo muito grande. Tamanho máximo: {maxSize}",
"fileTypeNotAllowed": "Tipo de arquivo não permitido. Tipos aceitos: {allowedTypes}",
"maxFilesExceeded": "Máximo de {maxFiles} arquivos permitidos",
"selectAtLeastOneFile": "Selecione pelo menos um arquivo",
"provideNameOrEmail": "Informe seu nome ou e-mail"
"provideNameOrEmail": "Informe seu nome ou e-mail",
"provideEmailRequired": "O email é necessário",
"provideNameRequired": "O nome é necessário"
},
"fileDropzone": {
"dragActive": "Solte os arquivos aqui",
@@ -1296,7 +1397,9 @@
"descriptionLabel": "Descrição (opcional)",
"descriptionPlaceholder": "Adicione uma descrição aos arquivos...",
"uploadButton": "Enviar {count} arquivo(s)",
"uploading": "Enviando..."
"uploading": "Enviando...",
"emailLabelOptional": "Email (opcional)",
"nameLabelOptional": "Nome (opcional)"
},
"success": {
"title": "Arquivos enviados com sucesso! 🎉",
@@ -1334,12 +1437,15 @@
"cancel": "Cancelar",
"preview": "Visualizar",
"download": "Baixar",
"delete": "Excluir"
"delete": "Excluir",
"copyToMyFiles": "Copiar para meus arquivos",
"copying": "Copiando..."
},
"editField": {
"saveChanges": "Salvar alterações",
"cancelEdit": "Cancelar edição"
}
}
},
"defaultLinkName": "Arquivos recebidos"
}
}

View File

@@ -202,6 +202,8 @@
"login": {
"welcome": "Добро пожаловать в",
"signInToContinue": "Войдите, чтобы продолжить",
"emailOrUsernameLabel": "Электронная почта или имя пользователя",
"emailOrUsernamePlaceholder": "Введите электронную почту или имя пользователя",
"emailLabel": "Адрес электронной почты",
"emailPlaceholder": "Введите вашу электронную почту",
"passwordLabel": "Пароль",
@@ -526,10 +528,30 @@
"oidcAdminEmailDomains": {
"title": "Домены Email Администратора",
"description": "Домены электронной почты, которые автоматически получают права администратора"
},
"testSmtp": {
"title": "Проверить SMTP-соединение",
"description": "Проверить правильность настройки SMTP"
},
"smtpNoAuth": {
"title": "Без аутентификации",
"description": "Включите для внутренних серверов, не требующих имя пользователя/пароль (скрывает поля аутентификации)"
},
"smtpSecure": {
"title": "Безопасность соединения",
"description": "Метод безопасности SMTP-соединения - Авто (рекомендуется), SSL, STARTTLS или Нет (небезопасно)",
"options": {
"auto": "Авто (Рекомендуется)",
"ssl": "SSL (Порт 465)",
"tls": "STARTTLS (Порт 587)",
"none": "Нет (Небезопасно)"
}
}
},
"buttons": {
"save": "Сохранить {group}"
"save": "Сохранить {group}",
"testSmtp": "Проверить соединение",
"testing": "Проверка..."
},
"errors": {
"loadFailed": "Ошибка загрузки настроек",
@@ -537,14 +559,22 @@
},
"messages": {
"noChanges": "Изменений для сохранения нет",
"updateSuccess": "Настройки {group} успешно обновлены"
"updateSuccess": "Настройки {group} успешно обновлены",
"smtpTestFailed": "Ошибка SMTP-соединения: {error}",
"smtpTestGenericError": "Не удалось проверить SMTP-соединение. Пожалуйста, проверьте настройки и попробуйте снова.",
"smtpTestSuccess": "SMTP-соединение успешно установлено! Ваша конфигурация электронной почты работает корректно.",
"smtpMissingAuth": "Пожалуйста, заполните имя пользователя и пароль SMTP или включите опцию 'Без аутентификации'.",
"smtpMissingHostPort": "Пожалуйста, заполните хост и порт SMTP перед тестированием.",
"smtpNotEnabled": "SMTP не включен. Пожалуйста, сначала включите SMTP."
},
"title": "Настройки",
"breadcrumb": "Настройки",
"pageTitle": "Настройки",
"tooltips": {
"oidcScope": "Digite um escopo e pressione Enter para adicionar",
"oidcAdminEmailDomains": "Digite um domínio e pressione Enter para adicionar"
"oidcAdminEmailDomains": "Digite um domínio e pressione Enter para adicionar",
"testSmtp": "Проверяет SMTP-соединение с текущими значениями в форме. Чтобы сохранить изменения, не забудьте сохранить настройки после тестирования.",
"defaultPlaceholder": "Введите и нажмите Enter"
},
"redirectUri": {
"placeholder": "https://meusite.com",
@@ -577,7 +607,8 @@
"title": "Общий доступ не найден",
"description": "Этот общий доступ может быть удален или истек."
},
"pageTitle": "Общий доступ"
"pageTitle": "Общий доступ",
"downloadAll": "Скачать все"
},
"shareActions": {
"deleteTitle": "Удалить Общий Доступ",
@@ -654,7 +685,16 @@
"securityUpdateError": "Не удалось обновить настройки безопасности",
"expirationUpdateError": "Не удалось обновить настройки истечения",
"securityUpdateSuccess": "Настройки безопасности успешно обновлены",
"expirationUpdateSuccess": "Настройки истечения успешно обновлены"
"expirationUpdateSuccess": "Настройки истечения успешно обновлены",
"creatingZip": "Создание файла Zip ...",
"defaultShareName": "Делиться",
"downloadError": "Не удалось скачать общие файлы",
"downloadSuccess": "Скачать началась успешно",
"multipleSharesZipName": "{count} _shares_files.zip",
"noFilesToDownload": "Нет файлов для скачивания",
"singleShareZipName": "{shareme} _files.zip",
"zipDownloadError": "Не удалось создать zip -файл",
"zipDownloadSuccess": "Zip -файл успешно загружен"
},
"shares": {
"errors": {
@@ -717,11 +757,14 @@
"editLink": "Редактировать Ссылку",
"copyLink": "Скопировать Ссылку",
"notifyRecipients": "Уведомить Получателей",
"delete": "Удалить"
"delete": "Удалить",
"downloadShareFiles": "Загрузите все файлы"
},
"bulkActions": {
"delete": "Удалить",
"selected": "{count, plural, =1 {1 общая папка выбрана} other {# общих папок выбрано}}"
"selected": "{count, plural, =1 {1 общая папка выбрана} other {# общих папок выбрано}}",
"actions": "Действия",
"download": "Скачать выбранный"
},
"selectAll": "Выбрать все",
"selectShare": "Выбрать общую папку {shareName}"
@@ -730,7 +773,15 @@
"title": "Использование хранилища",
"ariaLabel": "Индикатор использования хранилища",
"used": "Использовано",
"available": "Доступно"
"available": "Доступно",
"loading": "Загрузка...",
"retry": "Повторить",
"errors": {
"title": "Информация о хранилище недоступна",
"detectionFailed": "Не удалось определить свободное место на диске. Это может быть связано с проблемами конфигурации системы или недостаточными правами доступа.",
"serverError": "Произошла ошибка сервера при получении информации о хранилище. Пожалуйста, повторите попытку позже.",
"unknown": "Произошла непредвиденная ошибка при загрузке информации о хранилище."
}
},
"theme": {
"toggle": "Переключить тему",
@@ -748,6 +799,7 @@
"uploadProgress": "Прогресс загрузки",
"upload": "Загрузить",
"startUploads": "Начать Загрузку",
"retry": "Повторить",
"finish": "Завершить",
"success": "Файл успешно загружен",
"allSuccess": "{count, plural, =1 {Файл успешно загружен} other {# файлов успешно загружено}}",
@@ -844,6 +896,7 @@
"passwordLength": "Пароль должен содержать не менее 8 символов",
"passwordsMatch": "Пароли не совпадают",
"emailRequired": "Требуется электронная почта",
"emailOrUsernameRequired": "Электронная почта или имя пользователя обязательно",
"passwordRequired": "Требуется пароль",
"nameRequired": "Требуется имя",
"required": "Это поле обязательно"
@@ -1000,7 +1053,15 @@
"noFilesLimit": "Без ограничения файлов",
"noSizeLimit": "Без ограничения размера",
"allFileTypes": "Все типы файлов",
"fileTypesHelp": "Введите расширения без точки, разделенные пробелом, запятой, дефисом или вертикальной чертой"
"fileTypesHelp": "Введите расширения без точки, разделенные пробелом, запятой, дефисом или вертикальной чертой",
"emailFieldRequired": "Поле электронной почты",
"fieldOptions": {
"hidden": "Скрытый",
"optional": "Необязательный",
"required": "Необходимый"
},
"fieldRequirements": "Полевые требования",
"nameFieldRequired": "Имя Поле"
},
"card": {
"untitled": "Ссылка без названия",
@@ -1145,7 +1206,9 @@
},
"actions": {
"preview": "Предпросмотр",
"download": "Скачать"
"download": "Скачать",
"copyToMyFiles": "Скопируйте в мои файлы",
"copying": "Копирование ..."
},
"uploadedBy": "Загружено {name}",
"anonymous": "Аноним",
@@ -1153,7 +1216,27 @@
"downloadError": "Ошибка при загрузке файла",
"editSuccess": "Файл успешно обновлен",
"editError": "Ошибка при обновлении файла",
"previewNotAvailable": "Предпросмотр недоступен"
"previewNotAvailable": "Предпросмотр недоступен",
"copyError": "Ошибка копирования файла в ваши файлы",
"copySuccess": "Файл успешно скопирован в ваши файлы",
"bulkActions": {
"selected": "{count, plural, =1 {1 файл выбран} other {# файлов выбрано}}",
"actions": "Действия",
"download": "Скачать выбранные",
"copyToMyFiles": "Копировать выбранные в мои файлы",
"delete": "Удалить выбранные"
},
"bulkCopyProgress": "Копирование {count, plural, =1 {1 файла} other {# файлов}} в ваши файлы...",
"bulkCopySuccess": "{count, plural, =1 {1 файл успешно скопирован в ваши файлы} other {# файлов успешно скопировано в ваши файлы}}",
"bulkDeleteConfirmButton": "Удалить {count, plural, =1 {файл} other {файлы}}",
"bulkDeleteConfirmMessage": "Вы уверены, что хотите удалить {count, plural, =1 {этот файл} other {эти # файлов}}? Это действие нельзя отменить.",
"bulkDeleteConfirmTitle": "Удалить выбранные файлы",
"bulkDeleteProgress": "Удаление {count, plural, =1 {1 файла} other {# файлов}}...",
"bulkDeleteSuccess": "{count, plural, =1 {1 файл успешно удален} other {# файлов успешно удалено}}",
"selectAll": "Выбрать все",
"selectFile": "Выбрать файл {fileName}",
"deleteError": "Ошибка при удалении файла",
"deleteSuccess": "Файл успешно удален"
}
},
"form": {
@@ -1214,7 +1297,19 @@
"passwordHelp": "Пароль должен содержать минимум 4 символа",
"passwordPlaceholder": "Введите пароль для защиты ссылки"
},
"submit": "Создать ссылку для получения"
"submit": "Создать ссылку для получения",
"emailFieldRequired": {
"label": "Требование поля электронной почты",
"description": "Настройка, если следует отобразить поле электронной почты загрузчика и если это требуется"
},
"fieldRequirements": {
"title": "Полевые требования",
"description": "Настройте, какие поля показаны в форме загрузки"
},
"nameFieldRequired": {
"label": "Требование поля имени",
"description": "Настройка, если должно быть показано поле имени загрузчика и если оно требуется"
}
},
"messages": {
"created": "Ссылка для получения успешно создана!",
@@ -1224,14 +1319,17 @@
"deleteSuccess": "Ссылка для получения успешно удалена!",
"aliasCreated": "Псевдоним успешно создан!",
"activateSuccess": "Ссылка для получения успешно активирована!",
"deactivateSuccess": "Ссылка для получения успешно деактивирована!"
"deactivateSuccess": "Ссылка для получения успешно деактивирована!",
"passwordProtectionDisabled": "Защита паролем успешно отключена!",
"passwordProtectionEnabled": "Защита паролем успешно включена!"
},
"errors": {
"loadFailed": "Не удалось загрузить ссылки для получения",
"createFailed": "Не удалось создать ссылку для получения. Попробуйте снова.",
"updateFailed": "Не удалось обновить ссылку для получения. Попробуйте снова.",
"deleteFailed": "Не удалось удалить ссылку для получения. Попробуйте снова.",
"aliasCreateFailed": "Не удалось создать псевдоним. Попробуйте снова."
"aliasCreateFailed": "Не удалось создать псевдоним. Попробуйте снова.",
"passwordUpdateFailed": "Не удалось обновить защиту паролем"
},
"delete": {
"title": "Удалить ссылку для получения",
@@ -1269,11 +1367,14 @@
"linkInactive": "Эта ссылка неактивна.",
"linkExpired": "Срок действия этой ссылки истек.",
"uploadFailed": "Ошибка при загрузке файла",
"retry": "Повторить",
"fileTooLarge": "Файл слишком большой. Максимальный размер: {maxSize}",
"fileTypeNotAllowed": "Тип файла не разрешен. Разрешенные типы: {allowedTypes}",
"maxFilesExceeded": "Максимально разрешено {maxFiles} файлов",
"selectAtLeastOneFile": "Выберите хотя бы один файл",
"provideNameOrEmail": "Укажите ваше имя или email"
"provideNameOrEmail": "Укажите ваше имя или email",
"provideEmailRequired": "Электронная почта требуется",
"provideNameRequired": "Имя требуется"
},
"fileDropzone": {
"dragActive": "Отпустите файлы здесь",
@@ -1296,7 +1397,9 @@
"descriptionLabel": "Описание (необязательно)",
"descriptionPlaceholder": "Добавьте описание к файлам...",
"uploadButton": "Отправить {count} файл(ов)",
"uploading": "Отправка..."
"uploading": "Отправка...",
"emailLabelOptional": "Электронная почта (необязательно)",
"nameLabelOptional": "Имя (необязательно)"
},
"success": {
"title": "Файлы успешно отправлены! 🎉",
@@ -1334,12 +1437,15 @@
"cancel": "Отмена",
"preview": "Предпросмотр",
"download": "Скачать",
"delete": "Удалить"
"delete": "Удалить",
"copyToMyFiles": "Скопируйте в мои файлы",
"copying": "Копирование ..."
},
"editField": {
"saveChanges": "Сохранить изменения",
"cancelEdit": "Отменить редактирование"
}
}
},
"defaultLinkName": "Полученные файлы"
}
}

View File

@@ -202,6 +202,8 @@
"login": {
"welcome": "Hoş geldiniz'e",
"signInToContinue": "Devam etmek için oturum açın",
"emailOrUsernameLabel": "E-posta veya Kullanıcı Adı",
"emailOrUsernamePlaceholder": "E-posta veya kullanıcı adınızı girin",
"emailLabel": "E-posta Adresi",
"emailPlaceholder": "E-posta adresinizi girin",
"passwordLabel": "Şifre",
@@ -526,10 +528,30 @@
"oidcAdminEmailDomains": {
"title": "Yönetici E-posta Alanları",
"description": "Otomatik olarak yönetici ayrıcalıkları alan e-posta alanları"
},
"testSmtp": {
"title": "SMTP Bağlantısını Test Et",
"description": "SMTP yapılandırmasının geçerli olup olmadığını test et"
},
"smtpNoAuth": {
"title": "Kimlik Doğrulama Yok",
"description": "Kullanıcı adı/şifre gerektirmeyen dahili sunucular için bunu etkinleştirin (kimlik doğrulama alanlarını gizler)"
},
"smtpSecure": {
"title": "Bağlantı Güvenliği",
"description": "SMTP bağlantı güvenlik yöntemi - Otomatik (önerilen), SSL, STARTTLS veya Hiçbiri (güvensiz)",
"options": {
"auto": "Otomatik (Önerilen)",
"ssl": "SSL (Port 465)",
"tls": "STARTTLS (Port 587)",
"none": "Hiçbiri (Güvensiz)"
}
}
},
"buttons": {
"save": "{group} Kaydet"
"save": "{group} Kaydet",
"testSmtp": "Bağlantıyı Test Et",
"testing": "Test Ediliyor..."
},
"errors": {
"loadFailed": "Ayarlar yüklenemedi",
@@ -537,14 +559,22 @@
},
"messages": {
"noChanges": "Kaydedilecek değişiklik yok",
"updateSuccess": "{group} ayarları başarıyla güncellendi"
"updateSuccess": "{group} ayarları başarıyla güncellendi",
"smtpTestFailed": "SMTP bağlantısı başarısız: {error}",
"smtpTestGenericError": "SMTP bağlantısı test edilemedi. Lütfen ayarlarınızı kontrol edin ve tekrar deneyin.",
"smtpTestSuccess": "SMTP bağlantısı başarılı! E-posta yapılandırmanız doğru çalışıyor.",
"smtpMissingAuth": "Lütfen SMTP Kullanıcı Adı ve Şifresini doldurun veya 'Kimlik Doğrulama Yok' seçeneğini etkinleştirin.",
"smtpMissingHostPort": "Lütfen test etmeden önce SMTP Ana Bilgisayar ve Bağlantı Noktasını doldurun.",
"smtpNotEnabled": "SMTP etkin değil. Lütfen önce SMTP'yi etkinleştirin."
},
"title": "Ayarlar",
"breadcrumb": "Ayarlar",
"pageTitle": "Ayarlar",
"tooltips": {
"oidcScope": "Bir kapsam girin ve eklemek için Enter'a basın",
"oidcAdminEmailDomains": "Bir alan girin ve eklemek için Enter'a basın"
"oidcAdminEmailDomains": "Bir alan girin ve eklemek için Enter'a basın",
"testSmtp": "Formda şu anda girilen değerlerle SMTP bağlantısını test eder. Değişiklikleri kalıcı yapmak için test ettikten sonra ayarlarınızı kaydetmeyi unutmayın.",
"defaultPlaceholder": "Girin ve Enter'a basın"
},
"redirectUri": {
"placeholder": "https://sitem.com",
@@ -577,7 +607,8 @@
"title": "Paylaşım Bulunamadı",
"description": "Bu paylaşım silinmiş veya süresi dolmuş olabilir."
},
"pageTitle": "Paylaşım"
"pageTitle": "Paylaşım",
"downloadAll": "Tümünü İndir"
},
"shareActions": {
"deleteTitle": "Paylaşımı Sil",
@@ -654,7 +685,16 @@
"securityUpdateError": "Güvenlik ayarlarını güncelleme başarısız",
"expirationUpdateError": "Son kullanma ayarlarını güncelleme başarısız",
"securityUpdateSuccess": "Güvenlik ayarları başarıyla güncellendi",
"expirationUpdateSuccess": "Son kullanma ayarları başarıyla güncellendi"
"expirationUpdateSuccess": "Son kullanma ayarları başarıyla güncellendi",
"creatingZip": "Zip dosyası oluşturma ...",
"defaultShareName": "Paylaşmak",
"downloadError": "Paylaşım dosyalarını indiremedi",
"downloadSuccess": "İndir başarıyla başladı",
"multipleSharesZipName": "{Count} _Shares_files.zip",
"noFilesToDownload": "İndirilebilecek dosya yok",
"singleShareZipName": "{Sharename} _files.zip",
"zipDownloadError": "Zip dosyası oluşturulamadı",
"zipDownloadSuccess": "Zip dosyası başarıyla indirildi"
},
"shares": {
"errors": {
@@ -717,11 +757,14 @@
"editLink": "Bağlantıyı Düzenle",
"copyLink": "Bağlantıyı Kopyala",
"notifyRecipients": "Alıcıları Bilgilendir",
"delete": "Sil"
"delete": "Sil",
"downloadShareFiles": "Tüm dosyaları indirin"
},
"bulkActions": {
"delete": "Sil",
"selected": "{count, plural, =1 {1 paylaşım seçildi} other {# paylaşım seçildi}}"
"selected": "{count, plural, =1 {1 paylaşım seçildi} other {# paylaşım seçildi}}",
"actions": "Eylem",
"download": "Seçili indir"
},
"selectAll": "Tümünü seç",
"selectShare": "Paylaşım {shareName} seç"
@@ -730,7 +773,15 @@
"title": "Depolama Kullanımı",
"ariaLabel": "Depolama kullanım ilerleme çubuğu",
"used": "kullanıldı",
"available": "kullanılabilir"
"available": "kullanılabilir",
"loading": "Yükleniyor...",
"retry": "Tekrar Dene",
"errors": {
"title": "Depolama bilgisi kullanılamıyor",
"detectionFailed": "Disk alanı tespit edilemiyor. Bu, sistem yapılandırma sorunlarından veya yetersiz izinlerden kaynaklanıyor olabilir.",
"serverError": "Depolama bilgisi alınırken sunucu hatası oluştu. Lütfen daha sonra tekrar deneyin.",
"unknown": "Depolama bilgisi yüklenirken beklenmeyen bir hata oluştu."
}
},
"theme": {
"toggle": "Temayı değiştir",
@@ -748,6 +799,7 @@
"uploadProgress": "Yükleme ilerlemesi",
"upload": "Yükle",
"startUploads": "Yüklemeleri Başlat",
"retry": "Tekrar Dene",
"finish": "Bitir",
"success": "Dosya başarıyla yüklendi",
"allSuccess": "{count, plural, =1 {Dosya başarıyla yüklendi} other {# dosya başarıyla yüklendi}}",
@@ -844,6 +896,7 @@
"passwordLength": "Şifre en az 8 karakter olmalıdır",
"passwordsMatch": "Şifreler eşleşmiyor",
"emailRequired": "E-posta gerekli",
"emailOrUsernameRequired": "E-posta veya kullanıcı adı gereklidir",
"passwordRequired": "Şifre gerekli",
"nameRequired": "İsim gereklidir",
"required": "Bu alan zorunludur"
@@ -1000,7 +1053,15 @@
"noFilesLimit": "Dosya sınırı yok",
"noSizeLimit": "Boyut sınırı yok",
"allFileTypes": "Tüm dosya türleri",
"fileTypesHelp": "Uzantıları nokta olmadan, boşluk, virgül, tire veya dikey çizgi ile ayırarak girin"
"fileTypesHelp": "Uzantıları nokta olmadan, boşluk, virgül, tire veya dikey çizgi ile ayırarak girin",
"emailFieldRequired": "E -posta alanı",
"fieldOptions": {
"hidden": "Gizlenmiş",
"optional": "İsteğe bağlı",
"required": "Gerekli"
},
"fieldRequirements": "Saha Gereksinimleri",
"nameFieldRequired": "İsim alanı"
},
"card": {
"untitled": "Başlıksız bağlantı",
@@ -1145,7 +1206,9 @@
},
"actions": {
"preview": "Önizle",
"download": "İndir"
"download": "İndir",
"copyToMyFiles": "Dosyalarımı kopyala",
"copying": "Kopyalama ..."
},
"uploadedBy": "{name} tarafından gönderildi",
"anonymous": "Anonim",
@@ -1153,7 +1216,27 @@
"downloadError": "Dosya indirilirken hata oluştu",
"editSuccess": "Dosya başarıyla güncellendi",
"editError": "Dosya güncellenirken hata oluştu",
"previewNotAvailable": "Önizleme mevcut değil"
"previewNotAvailable": "Önizleme mevcut değil",
"copyError": "Dosyalarınıza dosyayı kopyalama hatası",
"copySuccess": "Dosyalarınıza başarılı bir şekilde kopyalanan dosya",
"bulkActions": {
"selected": "{count, plural, =1 {1 dosya seçildi} other {# dosya seçildi}}",
"actions": "İşlemler",
"download": "Seçilenleri İndir",
"copyToMyFiles": "Seçilenleri Dosyalarıma Kopyala",
"delete": "Seçilenleri Sil"
},
"bulkCopyProgress": "Dosyalarınıza {count, plural, =1 {1 dosya} other {# dosya}} kopyalanıyor...",
"bulkCopySuccess": "{count, plural, =1 {1 dosya dosyalarınıza başarıyla kopyalandı} other {# dosya dosyalarınıza başarıyla kopyalandı}}",
"bulkDeleteConfirmButton": "{count, plural, =1 {Dosyayı} other {Dosyaları}} Sil",
"bulkDeleteConfirmMessage": "{count, plural, =1 {Bu dosyayı} other {Bu # dosyayı}} silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
"bulkDeleteConfirmTitle": "Seçili Dosyaları Sil",
"bulkDeleteProgress": "{count, plural, =1 {1 dosya} other {# dosya}} siliniyor...",
"bulkDeleteSuccess": "{count, plural, =1 {1 dosya başarıyla silindi} other {# dosya başarıyla silindi}}",
"selectAll": "Tümünü seç",
"selectFile": "{fileName} dosyasını seç",
"deleteError": "Dosya silinirken hata oluştu",
"deleteSuccess": "Dosya başarıyla silindi"
}
},
"form": {
@@ -1214,7 +1297,19 @@
"passwordHelp": "Şifre en az 4 karakter olmalıdır",
"passwordPlaceholder": "Bağlantıyı korumak için şifre girin"
},
"submit": "Alma Bağlantısı Oluştur"
"submit": "Alma Bağlantısı Oluştur",
"emailFieldRequired": {
"label": "E -posta alanı gereksinimi",
"description": "Yükleyici e -posta alanının gösterilmesi gerekip gerekmediğini ve gerekli olup olmadığını yapılandırın"
},
"fieldRequirements": {
"title": "Saha Gereksinimleri",
"description": "Hangi alanların yükleme formunda gösterildiğini yapılandırın"
},
"nameFieldRequired": {
"label": "İsim alanı gereksinimi",
"description": "Yükleyici adı alanının gösterilmesi gerekip gerekmediğini ve gerekli olup olmadığını yapılandırın"
}
},
"messages": {
"created": "Alma bağlantısı başarıyla oluşturuldu!",
@@ -1224,14 +1319,17 @@
"deleteSuccess": "Alma bağlantısı başarıyla silindi!",
"aliasCreated": "Takma ad başarıyla oluşturuldu!",
"activateSuccess": "Alma bağlantısı başarıyla etkinleştirildi!",
"deactivateSuccess": "Alma bağlantısı başarıyla devre dışı bırakıldı!"
"deactivateSuccess": "Alma bağlantısı başarıyla devre dışı bırakıldı!",
"passwordProtectionDisabled": "Şifre koruması başarıyla kaldırıldı!",
"passwordProtectionEnabled": "Şifre koruması başarıyla etkinleştirildi!"
},
"errors": {
"loadFailed": "Alma bağlantıları yüklenemedi",
"createFailed": "Alma bağlantısı oluşturulamadı. Lütfen tekrar deneyin.",
"updateFailed": "Alma bağlantısı güncellenemedi. Lütfen tekrar deneyin.",
"deleteFailed": "Alma bağlantısı silinemedi. Lütfen tekrar deneyin.",
"aliasCreateFailed": "Takma ad oluşturulamadı. Lütfen tekrar deneyin."
"aliasCreateFailed": "Takma ad oluşturulamadı. Lütfen tekrar deneyin.",
"passwordUpdateFailed": "Şifre koruması güncellenemedi"
},
"delete": {
"title": "Alma bağlantısını sil",
@@ -1269,11 +1367,14 @@
"linkInactive": "Bu bağlantı pasif durumda.",
"linkExpired": "Bu bağlantının süresi doldu.",
"uploadFailed": "Dosya yüklenirken hata oluştu",
"retry": "Tekrar Dene",
"fileTooLarge": "Dosya çok büyük. Maksimum boyut: {maxSize}",
"fileTypeNotAllowed": "Dosya türüne izin verilmiyor. İzin verilen türler: {allowedTypes}",
"maxFilesExceeded": "Maksimum {maxFiles} dosyaya izin veriliyor",
"selectAtLeastOneFile": "En az bir dosya seçin",
"provideNameOrEmail": "Adınızı veya e-postanızı girin"
"provideNameOrEmail": "Adınızı veya e-postanızı girin",
"provideEmailRequired": "E -posta gerekli",
"provideNameRequired": "İsim gerekli"
},
"fileDropzone": {
"dragActive": "Dosyaları buraya bırakın",
@@ -1296,7 +1397,9 @@
"descriptionLabel": "Açıklama (isteğe bağlı)",
"descriptionPlaceholder": "Dosyalara açıklama ekleyin...",
"uploadButton": "{count} dosya gönder",
"uploading": "Gönderiliyor..."
"uploading": "Gönderiliyor...",
"emailLabelOptional": "E -posta (isteğe bağlı)",
"nameLabelOptional": "İsim (isteğe bağlı)"
},
"success": {
"title": "Dosyalar başarıyla gönderildi! 🎉",
@@ -1334,12 +1437,15 @@
"cancel": "İptal",
"preview": "Önizle",
"download": "İndir",
"delete": "Sil"
"delete": "Sil",
"copyToMyFiles": "Dosyalarımı kopyala",
"copying": "Kopyalama ..."
},
"editField": {
"saveChanges": "Değişiklikleri kaydet",
"cancelEdit": "Düzenlemeyi iptal et"
}
}
},
"defaultLinkName": "Alınan Dosyalar"
}
}

View File

@@ -0,0 +1,342 @@
#!/usr/bin/env python3
"""
Script para traduzir automaticamente strings marcadas com [TO_TRANSLATE]
usando Google Translate gratuito.
"""
import json
import time
import re
from pathlib import Path
from typing import Dict, Any, List, Tuple, Optional
import argparse
import sys
# Mapeamento de códigos de idioma dos arquivos para códigos do Google Translate
LANGUAGE_MAPPING = {
'pt-BR.json': 'pt', # Português (Brasil) -> Português
'es-ES.json': 'es', # Espanhol (Espanha) -> Espanhol
'fr-FR.json': 'fr', # Francês (França) -> Francês
'de-DE.json': 'de', # Alemão -> Alemão
'it-IT.json': 'it', # Italiano -> Italiano
'ru-RU.json': 'ru', # Russo -> Russo
'ja-JP.json': 'ja', # Japonês -> Japonês
'ko-KR.json': 'ko', # Coreano -> Coreano
'zh-CN.json': 'zh-cn', # Chinês (Simplificado) -> Chinês Simplificado
'ar-SA.json': 'ar', # Árabe -> Árabe
'hi-IN.json': 'hi', # Hindi -> Hindi
'nl-NL.json': 'nl', # Holandês -> Holandês
'tr-TR.json': 'tr', # Turco -> Turco
'pl-PL.json': 'pl', # Polonês -> Polonês
}
# Prefixo para identificar strings não traduzidas
TO_TRANSLATE_PREFIX = '[TO_TRANSLATE] '
def load_json_file(file_path: Path) -> Dict[str, Any]:
"""Carrega um arquivo JSON."""
try:
with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"❌ Erro ao carregar {file_path}: {e}")
return {}
def save_json_file(file_path: Path, data: Dict[str, Any], indent: int = 2) -> bool:
"""Salva um arquivo JSON com formatação consistente."""
try:
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=indent, separators=(',', ': '))
f.write('\n') # Adiciona nova linha no final
return True
except Exception as e:
print(f"❌ Erro ao salvar {file_path}: {e}")
return False
def get_nested_value(data: Dict[str, Any], key_path: str) -> Any:
"""Obtém um valor aninhado usando uma chave com pontos como separador."""
keys = key_path.split('.')
current = data
for key in keys:
if isinstance(current, dict) and key in current:
current = current[key]
else:
return None
return current
def set_nested_value(data: Dict[str, Any], key_path: str, value: Any) -> None:
"""Define um valor aninhado usando uma chave com pontos como separador."""
keys = key_path.split('.')
current = data
# Navega até o penúltimo nível, criando dicionários conforme necessário
for key in keys[:-1]:
if key not in current:
current[key] = {}
elif not isinstance(current[key], dict):
current[key] = {}
current = current[key]
# Define o valor no último nível
current[keys[-1]] = value
def find_untranslated_strings(data: Dict[str, Any], prefix: str = '') -> List[Tuple[str, str]]:
"""Encontra todas as strings marcadas com [TO_TRANSLATE] recursivamente."""
untranslated = []
for key, value in data.items():
current_key = f"{prefix}.{key}" if prefix else key
if isinstance(value, str) and value.startswith(TO_TRANSLATE_PREFIX):
# Remove o prefixo para obter o texto original
original_text = value[len(TO_TRANSLATE_PREFIX):].strip()
untranslated.append((current_key, original_text))
elif isinstance(value, dict):
untranslated.extend(find_untranslated_strings(value, current_key))
return untranslated
def install_googletrans():
"""Instala a biblioteca googletrans se não estiver disponível."""
try:
import googletrans
return True
except ImportError:
print("📦 Biblioteca 'googletrans' não encontrada. Tentando instalar...")
import subprocess
try:
subprocess.check_call([sys.executable, "-m", "pip", "install", "googletrans==4.0.0rc1"])
print("✅ googletrans instalada com sucesso!")
return True
except subprocess.CalledProcessError:
print("❌ Falha ao instalar googletrans. Instale manualmente com:")
print("pip install googletrans==4.0.0rc1")
return False
def translate_text(text: str, target_language: str, max_retries: int = 3) -> Optional[str]:
"""Traduz um texto usando Google Translate gratuito."""
try:
from googletrans import Translator
translator = Translator()
for attempt in range(max_retries):
try:
# Traduz do inglês para o idioma alvo
result = translator.translate(text, src='en', dest=target_language)
if result and result.text:
return result.text.strip()
except Exception as e:
if attempt < max_retries - 1:
print(f" ⚠️ Tentativa {attempt + 1} falhou: {str(e)[:50]}... Reentando em 2s...")
time.sleep(2)
else:
print(f" ❌ Falha após {max_retries} tentativas: {str(e)[:50]}...")
return None
except ImportError:
print("❌ Biblioteca googletrans não disponível")
return None
def translate_file(file_path: Path, target_language: str, dry_run: bool = False,
delay_between_requests: float = 1.0) -> Tuple[int, int, int]:
"""
Traduz todas as strings [TO_TRANSLATE] em um arquivo.
Retorna: (total_found, successful_translations, failed_translations)
"""
print(f"🔍 Processando: {file_path.name}")
# Carrega o arquivo
data = load_json_file(file_path)
if not data:
return 0, 0, 0
# Encontra strings não traduzidas
untranslated_strings = find_untranslated_strings(data)
if not untranslated_strings:
print(f" ✅ Nenhuma string para traduzir")
return 0, 0, 0
print(f" 📝 Encontradas {len(untranslated_strings)} strings para traduzir")
if dry_run:
print(f" 🔍 [DRY RUN] Strings que seriam traduzidas:")
for key, text in untranslated_strings[:3]:
print(f" - {key}: \"{text[:50]}{'...' if len(text) > 50 else ''}\"")
if len(untranslated_strings) > 3:
print(f" ... e mais {len(untranslated_strings) - 3}")
return len(untranslated_strings), 0, 0
# Traduz cada string
successful = 0
failed = 0
updated_data = data.copy()
for i, (key_path, original_text) in enumerate(untranslated_strings, 1):
print(f" 📍 ({i}/{len(untranslated_strings)}) Traduzindo: {key_path}")
# Traduz o texto
translated_text = translate_text(original_text, target_language)
if translated_text and translated_text != original_text:
# Atualiza no dicionário
set_nested_value(updated_data, key_path, translated_text)
successful += 1
print(f"\"{original_text[:30]}...\"\"{translated_text[:30]}...\"")
else:
failed += 1
print(f" ❌ Falha na tradução")
# Delay entre requisições para evitar rate limiting
if i < len(untranslated_strings): # Não espera após a última
time.sleep(delay_between_requests)
# Salva o arquivo atualizado
if successful > 0:
if save_json_file(file_path, updated_data):
print(f" 💾 Arquivo salvo com {successful} traduções")
else:
print(f" ❌ Erro ao salvar arquivo")
failed += successful # Conta como falha se não conseguiu salvar
successful = 0
return len(untranslated_strings), successful, failed
def translate_all_files(messages_dir: Path, delay_between_requests: float = 1.0,
dry_run: bool = False, skip_languages: List[str] = None) -> None:
"""Traduz todos os arquivos de idioma que têm strings [TO_TRANSLATE]."""
if not install_googletrans():
return
skip_languages = skip_languages or []
# Encontra arquivos JSON de idioma
language_files = []
for file_name, lang_code in LANGUAGE_MAPPING.items():
file_path = messages_dir / file_name
if file_path.exists() and file_name not in skip_languages:
language_files.append((file_path, lang_code))
if not language_files:
print("❌ Nenhum arquivo de idioma encontrado")
return
print(f"🌍 Traduzindo {len(language_files)} idiomas...")
print(f"⏱️ Delay entre requisições: {delay_between_requests}s")
if dry_run:
print("🔍 MODO DRY RUN - Nenhuma alteração será feita")
print("-" * 60)
total_found = 0
total_successful = 0
total_failed = 0
for i, (file_path, lang_code) in enumerate(language_files, 1):
print(f"\n[{i}/{len(language_files)}] 🌐 Idioma: {lang_code.upper()}")
found, successful, failed = translate_file(
file_path, lang_code, dry_run, delay_between_requests
)
total_found += found
total_successful += successful
total_failed += failed
# Pausa entre arquivos (exceto o último)
if i < len(language_files) and not dry_run:
print(f" ⏸️ Pausando {delay_between_requests * 2}s antes do próximo idioma...")
time.sleep(delay_between_requests * 2)
# Sumário final
print("\n" + "=" * 60)
print("📊 SUMÁRIO FINAL")
print("=" * 60)
if dry_run:
print(f"🔍 MODO DRY RUN:")
print(f"{total_found} strings seriam traduzidas")
else:
print(f"✅ Traduções realizadas:")
print(f"{total_successful} sucessos")
print(f"{total_failed} falhas")
print(f"{total_found} total processadas")
if total_successful > 0:
success_rate = (total_successful / total_found) * 100
print(f" • Taxa de sucesso: {success_rate:.1f}%")
print("\n💡 DICAS:")
print("• Execute 'python3 check_translations.py' para verificar o resultado")
print("• Strings que falharam na tradução mantêm o prefixo [TO_TRANSLATE]")
print("• Considere revisar as traduções automáticas para garantir qualidade")
def main():
parser = argparse.ArgumentParser(
description='Traduz automaticamente strings marcadas com [TO_TRANSLATE]'
)
parser.add_argument(
'--messages-dir',
type=Path,
default=Path(__file__).parent,
help='Diretório contendo os arquivos de mensagem (padrão: diretório atual)'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Apenas mostra o que seria traduzido, sem fazer alterações'
)
parser.add_argument(
'--delay',
type=float,
default=1.0,
help='Delay em segundos entre requisições de tradução (padrão: 1.0)'
)
parser.add_argument(
'--skip-languages',
nargs='*',
default=[],
help='Lista de idiomas para pular (ex: pt-BR.json fr-FR.json)'
)
args = parser.parse_args()
if not args.messages_dir.exists():
print(f"❌ Diretório não encontrado: {args.messages_dir}")
return 1
print(f"📁 Diretório: {args.messages_dir}")
print(f"🔍 Dry run: {args.dry_run}")
print(f"⏱️ Delay: {args.delay}s")
if args.skip_languages:
print(f"⏭️ Ignorando: {', '.join(args.skip_languages)}")
print("-" * 60)
translate_all_files(
messages_dir=args.messages_dir,
delay_between_requests=args.delay,
dry_run=args.dry_run,
skip_languages=args.skip_languages
)
return 0
if __name__ == '__main__':
exit(main())

View File

@@ -202,6 +202,8 @@
"login": {
"welcome": "欢迎您",
"signInToContinue": "请登录以继续",
"emailOrUsernameLabel": "电子邮件或用户名",
"emailOrUsernamePlaceholder": "请输入您的电子邮件或用户名",
"emailLabel": "电子邮件地址",
"emailPlaceholder": "请输入您的电子邮件",
"passwordLabel": "密码",
@@ -526,10 +528,30 @@
"oidcAdminEmailDomains": {
"title": "管理员电子邮件域名",
"description": "自动获得管理员权限的电子邮件域名"
},
"testSmtp": {
"title": "测试SMTP连接",
"description": "测试SMTP配置是否有效"
},
"smtpNoAuth": {
"title": "无需认证",
"description": "为不需要用户名/密码的内部服务器启用此选项(隐藏认证字段)"
},
"smtpSecure": {
"title": "连接安全性",
"description": "SMTP连接安全方法 - 自动推荐、SSL、STARTTLS或无不安全",
"options": {
"auto": "自动(推荐)",
"ssl": "SSL端口465",
"tls": "STARTTLS端口587",
"none": "无(不安全)"
}
}
},
"buttons": {
"save": "保存 {group}"
"save": "保存 {group}",
"testSmtp": "测试连接",
"testing": "测试中..."
},
"errors": {
"loadFailed": "加载设置失败",
@@ -537,14 +559,22 @@
},
"messages": {
"noChanges": "没有需要保存的更改",
"updateSuccess": "{group}设置更新成功"
"updateSuccess": "{group}设置更新成功",
"smtpTestFailed": "SMTP连接失败{error}",
"smtpTestGenericError": "SMTP连接测试失败。请检查您的设置并重试。",
"smtpTestSuccess": "SMTP连接成功您的电子邮件配置工作正常。",
"smtpMissingAuth": "请填写SMTP用户名和密码或启用“无需认证”选项。",
"smtpMissingHostPort": "测试前请填写SMTP主机和端口。",
"smtpNotEnabled": "SMTP未启用。请先启用SMTP。"
},
"title": "设置",
"breadcrumb": "设置",
"pageTitle": "设置",
"tooltips": {
"oidcScope": "Digite um escopo e pressione Enter para adicionar",
"oidcAdminEmailDomains": "Digite um domínio e pressione Enter para adicionar"
"oidcAdminEmailDomains": "Digite um domínio e pressione Enter para adicionar",
"testSmtp": "使用当前在表单中输入的值测试SMTP连接。要使更改永久生效请记得在测试后保存您的设置。",
"defaultPlaceholder": "输入并按回车键"
},
"redirectUri": {
"placeholder": "https://meusite.com",
@@ -577,7 +607,8 @@
"title": "未找到共享",
"description": "该共享可能已被删除或已过期。"
},
"pageTitle": "共享"
"pageTitle": "共享",
"downloadAll": "下载所有"
},
"shareActions": {
"deleteTitle": "删除共享",
@@ -654,7 +685,16 @@
"securityUpdateError": "更新安全设置失败",
"expirationUpdateError": "更新过期设置失败",
"securityUpdateSuccess": "安全设置更新成功",
"expirationUpdateSuccess": "过期设置更新成功"
"expirationUpdateSuccess": "过期设置更新成功",
"creatingZip": "创建zip文件...",
"defaultShareName": "分享",
"downloadError": "无法下载共享文件",
"downloadSuccess": "下载成功开始",
"multipleSharesZipName": "{count} _shares_files.zip",
"noFilesToDownload": "无需下载文件",
"singleShareZipName": "{sharename} _files.zip",
"zipDownloadError": "无法创建zip文件",
"zipDownloadSuccess": "zip文件成功下载了"
},
"shares": {
"errors": {
@@ -717,11 +757,14 @@
"editLink": "编辑链接",
"copyLink": "复制链接",
"notifyRecipients": "通知收件人",
"delete": "删除"
"delete": "删除",
"downloadShareFiles": "下载所有文件"
},
"bulkActions": {
"delete": "删除",
"selected": "{count, plural, =1 {已选择1个共享} other {已选择#个共享}}"
"selected": "{count, plural, =1 {已选择1个共享} other {已选择#个共享}}",
"actions": "动作",
"download": "选择下载"
},
"selectAll": "全选",
"selectShare": "选择共享 {shareName}"
@@ -730,7 +773,15 @@
"title": "存储使用情况",
"ariaLabel": "存储使用进度条",
"used": "已使用:",
"available": "可用"
"available": "可用",
"loading": "加载中...",
"retry": "重试",
"errors": {
"title": "存储信息不可用",
"detectionFailed": "无法检测磁盘空间。这可能是由于系统配置问题或权限不足。",
"serverError": "检索存储信息时发生服务器错误。请稍后重试。",
"unknown": "加载存储信息时发生意外错误。"
}
},
"theme": {
"toggle": "切换主题",
@@ -748,6 +799,7 @@
"uploadProgress": "上传进度",
"upload": "上传",
"startUploads": "开始上传",
"retry": "重试",
"finish": "完成",
"success": "文件上传成功",
"allSuccess": "{count, plural, =1 {文件上传成功} other {# 个文件上传成功}}",
@@ -844,6 +896,7 @@
"passwordLength": "密码至少需要8个字符",
"passwordsMatch": "密码不匹配",
"emailRequired": "电子邮件为必填项",
"emailOrUsernameRequired": "电子邮件或用户名是必填项",
"passwordRequired": "密码为必填项",
"nameRequired": "名称为必填项",
"required": "此字段为必填项"
@@ -1000,7 +1053,15 @@
"noFilesLimit": "无文件数限制",
"noSizeLimit": "无大小限制",
"allFileTypes": "所有文件类型",
"fileTypesHelp": "输入不带点的扩展名,用空格、逗号、横线或竖线分隔"
"fileTypesHelp": "输入不带点的扩展名,用空格、逗号、横线或竖线分隔",
"emailFieldRequired": "电子邮件字段",
"fieldOptions": {
"hidden": "隐",
"optional": "选修的",
"required": "必需的"
},
"fieldRequirements": "现场要求",
"nameFieldRequired": "名称字段"
},
"card": {
"untitled": "无标题链接",
@@ -1145,7 +1206,9 @@
},
"actions": {
"preview": "预览",
"download": "下载"
"download": "下载",
"copyToMyFiles": "复制到我的文件",
"copying": "复制..."
},
"uploadedBy": "由 {name} 上传",
"anonymous": "匿名",
@@ -1153,7 +1216,27 @@
"downloadError": "下载文件时出错",
"editSuccess": "文件更新成功",
"editError": "更新文件时出错",
"previewNotAvailable": "预览不可用"
"previewNotAvailable": "预览不可用",
"copyError": "错误将文件复制到您的文件",
"copySuccess": "文件已成功复制到您的文件",
"bulkActions": {
"selected": "{count, plural, =1 {已选择1个文件} other {已选择#个文件}}",
"actions": "操作",
"download": "下载所选",
"copyToMyFiles": "复制所选到我的文件",
"delete": "删除所选"
},
"bulkCopyProgress": "正在将{count, plural, =1 {1个文件} other {#个文件}}复制到您的文件...",
"bulkCopySuccess": "{count, plural, =1 {1个文件已成功复制到您的文件} other {#个文件已成功复制到您的文件}}",
"bulkDeleteConfirmButton": "删除{count, plural, =1 {文件} other {文件}}",
"bulkDeleteConfirmMessage": "您确定要删除{count, plural, =1 {这个文件} other {这些#个文件}}吗?此操作无法撤消。",
"bulkDeleteConfirmTitle": "删除所选文件",
"bulkDeleteProgress": "正在删除{count, plural, =1 {1个文件} other {#个文件}}...",
"bulkDeleteSuccess": "{count, plural, =1 {1个文件已成功删除} other {#个文件已成功删除}}",
"selectAll": "全选",
"selectFile": "选择文件 {fileName}",
"deleteError": "删除文件时出错",
"deleteSuccess": "文件已成功删除"
}
},
"form": {
@@ -1214,7 +1297,19 @@
"passwordHelp": "密码至少需要4个字符",
"passwordPlaceholder": "输入密码以保护链接"
},
"submit": "创建接收链接"
"submit": "创建接收链接",
"emailFieldRequired": {
"label": "电子邮件字段要求",
"description": "配置是否应显示上传器电子邮件字段以及是否需要"
},
"fieldRequirements": {
"title": "现场要求",
"description": "配置在上传表单中显示哪些字段"
},
"nameFieldRequired": {
"label": "名称字段要求",
"description": "配置是否应显示上传器名称字段以及是否需要"
}
},
"messages": {
"created": "接收链接创建成功!",
@@ -1224,14 +1319,17 @@
"deleteSuccess": "接收链接删除成功!",
"aliasCreated": "别名创建成功!",
"activateSuccess": "接收链接启用成功!",
"deactivateSuccess": "接收链接停用成功!"
"deactivateSuccess": "接收链接停用成功!",
"passwordProtectionDisabled": "密码保护已成功移除!",
"passwordProtectionEnabled": "密码保护已成功启用!"
},
"errors": {
"loadFailed": "加载接收链接失败",
"createFailed": "创建接收链接失败。请重试。",
"updateFailed": "更新接收链接失败。请重试。",
"deleteFailed": "删除接收链接失败。请重试。",
"aliasCreateFailed": "创建别名失败。请重试。"
"aliasCreateFailed": "创建别名失败。请重试。",
"passwordUpdateFailed": "更新密码保护失败"
},
"delete": {
"title": "删除接收链接",
@@ -1269,11 +1367,14 @@
"linkInactive": "此链接已停用。",
"linkExpired": "此链接已过期。",
"uploadFailed": "上传文件时出错",
"retry": "重试",
"fileTooLarge": "文件太大。最大大小:{maxSize}",
"fileTypeNotAllowed": "不允许的文件类型。允许的类型:{allowedTypes}",
"maxFilesExceeded": "最多允许 {maxFiles} 个文件",
"selectAtLeastOneFile": "请至少选择一个文件",
"provideNameOrEmail": "请提供您的姓名或电子邮件"
"provideNameOrEmail": "请提供您的姓名或电子邮件",
"provideEmailRequired": "需要电子邮件",
"provideNameRequired": "需要名称"
},
"fileDropzone": {
"dragActive": "将文件拖放到此处",
@@ -1296,7 +1397,9 @@
"descriptionLabel": "描述(可选)",
"descriptionPlaceholder": "为文件添加描述...",
"uploadButton": "上传 {count} 个文件",
"uploading": "上传中..."
"uploading": "上传中...",
"emailLabelOptional": "电子邮件(可选)",
"nameLabelOptional": "名称(可选)"
},
"success": {
"title": "文件上传成功!🎉",
@@ -1334,12 +1437,15 @@
"cancel": "取消",
"preview": "预览",
"download": "下载",
"delete": "删除"
"delete": "删除",
"copyToMyFiles": "复制到我的文件",
"copying": "复制..."
},
"editField": {
"saveChanges": "保存更改",
"cancelEdit": "取消编辑"
}
}
},
"defaultLinkName": "已接收文件"
}
}

View File

@@ -20,7 +20,12 @@
"lint": "eslint \"src/**/*.+(ts|tsx)\"",
"lint:fix": "eslint \"src/**/*.+(ts|tsx)\" --fix",
"format": "prettier . --write",
"format:check": "prettier . --check"
"format:check": "prettier . --check",
"translations": "python3 scripts/run_translations.py all",
"translations:check": "python3 scripts/run_translations.py check",
"translations:sync": "python3 scripts/run_translations.py sync",
"translations:dry-run": "python3 scripts/run_translations.py all --dry-run",
"translations:help": "python3 scripts/run_translations.py help"
},
"dependencies": {
"@hookform/resolvers": "^5.0.1",

View File

@@ -0,0 +1,231 @@
#!/usr/bin/env python3
"""
Script to check translation status and identify strings that need translation.
"""
import json
from pathlib import Path
from typing import Dict, Any, List, Tuple
import argparse
def load_json_file(file_path: Path) -> Dict[str, Any]:
"""Load a JSON file."""
try:
with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"Error loading {file_path}: {e}")
return {}
def get_all_string_values(data: Dict[str, Any], prefix: str = '') -> List[Tuple[str, str]]:
"""Extract all strings from nested JSON with their keys."""
strings = []
for key, value in data.items():
current_key = f"{prefix}.{key}" if prefix else key
if isinstance(value, str):
strings.append((current_key, value))
elif isinstance(value, dict):
strings.extend(get_all_string_values(value, current_key))
return strings
def check_untranslated_strings(file_path: Path) -> Tuple[int, int, List[str]]:
"""Check for untranslated strings in a file."""
data = load_json_file(file_path)
if not data:
return 0, 0, []
all_strings = get_all_string_values(data)
untranslated = []
for key, value in all_strings:
if value.startswith('[TO_TRANSLATE]'):
untranslated.append(key)
return len(all_strings), len(untranslated), untranslated
def compare_languages(reference_file: Path, target_file: Path) -> Dict[str, Any]:
"""Compare two language files."""
reference_data = load_json_file(reference_file)
target_data = load_json_file(target_file)
if not reference_data or not target_data:
return {}
reference_strings = dict(get_all_string_values(reference_data))
target_strings = dict(get_all_string_values(target_data))
# Find common keys
common_keys = set(reference_strings.keys()) & set(target_strings.keys())
# Check identical strings (possibly untranslated)
identical_strings = []
for key in common_keys:
if reference_strings[key] == target_strings[key] and len(reference_strings[key]) > 3:
identical_strings.append(key)
return {
'total_reference': len(reference_strings),
'total_target': len(target_strings),
'common_keys': len(common_keys),
'identical_strings': identical_strings
}
def generate_translation_report(messages_dir: Path, reference_file: str = 'en-US.json'):
"""Generate complete translation report."""
reference_path = messages_dir / reference_file
if not reference_path.exists():
print(f"Reference file not found: {reference_path}")
return
# Load reference data
reference_data = load_json_file(reference_path)
reference_strings = dict(get_all_string_values(reference_data))
total_reference_strings = len(reference_strings)
print(f"📊 TRANSLATION REPORT")
print(f"Reference: {reference_file} ({total_reference_strings} strings)")
print("=" * 80)
# Find all JSON files
json_files = [f for f in messages_dir.glob('*.json') if f.name != reference_file]
if not json_files:
print("No translation files found")
return
reports = []
for json_file in sorted(json_files):
total_strings, untranslated_count, untranslated_keys = check_untranslated_strings(json_file)
comparison = compare_languages(reference_path, json_file)
# Calculate percentages
completion_percentage = (total_strings / total_reference_strings) * 100 if total_reference_strings > 0 else 0
untranslated_percentage = (untranslated_count / total_strings) * 100 if total_strings > 0 else 0
reports.append({
'file': json_file.name,
'total_strings': total_strings,
'untranslated_count': untranslated_count,
'untranslated_keys': untranslated_keys,
'completion_percentage': completion_percentage,
'untranslated_percentage': untranslated_percentage,
'identical_strings': comparison.get('identical_strings', [])
})
# Sort by completion percentage
reports.sort(key=lambda x: x['completion_percentage'], reverse=True)
print(f"{'LANGUAGE':<15} {'COMPLETENESS':<12} {'STRINGS':<15} {'UNTRANSLATED':<15} {'POSSIBLE MATCHES'}")
print("-" * 80)
for report in reports:
language = report['file'].replace('.json', '')
completion = f"{report['completion_percentage']:.1f}%"
strings_info = f"{report['total_strings']}/{total_reference_strings}"
untranslated_info = f"{report['untranslated_count']} ({report['untranslated_percentage']:.1f}%)"
identical_count = len(report['identical_strings'])
# Choose icon based on completeness
if report['completion_percentage'] >= 100:
icon = "" if report['untranslated_count'] == 0 else "⚠️"
elif report['completion_percentage'] >= 90:
icon = "🟡"
else:
icon = "🔴"
print(f"{icon} {language:<13} {completion:<12} {strings_info:<15} {untranslated_info:<15} {identical_count}")
print("\n" + "=" * 80)
# Show details of problematic files
problematic_files = [r for r in reports if r['untranslated_count'] > 0 or r['completion_percentage'] < 100]
if problematic_files:
print("📋 DETAILS OF FILES THAT NEED ATTENTION:")
print()
for report in problematic_files:
language = report['file'].replace('.json', '')
print(f"🔍 {language.upper()}:")
if report['completion_percentage'] < 100:
missing_count = total_reference_strings - report['total_strings']
print(f" • Missing {missing_count} strings ({100 - report['completion_percentage']:.1f}%)")
if report['untranslated_count'] > 0:
print(f"{report['untranslated_count']} strings marked as [TO_TRANSLATE]")
if report['untranslated_count'] <= 10:
print(" • Untranslated keys:")
for key in report['untranslated_keys']:
print(f" - {key}")
else:
print(" • First 10 untranslated keys:")
for key in report['untranslated_keys'][:10]:
print(f" - {key}")
print(f" ... and {report['untranslated_count'] - 10} more")
if report['identical_strings']:
identical_count = len(report['identical_strings'])
print(f"{identical_count} strings identical to English (possibly untranslated)")
if identical_count <= 5:
for key in report['identical_strings']:
value = reference_strings.get(key, '')[:50]
print(f" - {key}: \"{value}...\"")
else:
for key in report['identical_strings'][:5]:
value = reference_strings.get(key, '')[:50]
print(f" - {key}: \"{value}...\"")
print(f" ... and {identical_count - 5} more")
print()
else:
print("🎉 All translations are complete!")
print("=" * 80)
print("💡 TIPS:")
print("• Use 'python3 sync_translations.py --dry-run' to see what would be added")
print("• Use 'python3 sync_translations.py' to synchronize all translations")
print("• Strings marked with [TO_TRANSLATE] need manual translation")
print("• Strings identical to English may need translation")
def main():
parser = argparse.ArgumentParser(
description='Check translation status and identify strings that need translation'
)
parser.add_argument(
'--messages-dir',
type=Path,
default=Path(__file__).parent.parent / 'messages',
help='Directory containing message files (default: ../messages)'
)
parser.add_argument(
'--reference',
default='en-US.json',
help='Reference file (default: en-US.json)'
)
args = parser.parse_args()
if not args.messages_dir.exists():
print(f"Directory not found: {args.messages_dir}")
return 1
generate_translation_report(args.messages_dir, args.reference)
return 0
if __name__ == '__main__':
exit(main())

View File

@@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""
Script to clean up translation files that have multiple [TO_TRANSLATE] prefixes.
This fixes the issue where sync_translations.py added multiple prefixes.
"""
import json
import os
import re
from pathlib import Path
from typing import Dict, Any
import argparse
def load_json_file(file_path: Path) -> Dict[str, Any]:
"""Load a JSON file."""
try:
with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"Error loading {file_path}: {e}")
return {}
def save_json_file(file_path: Path, data: Dict[str, Any], indent: int = 2) -> bool:
"""Save a JSON file with consistent formatting."""
try:
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=indent, separators=(',', ': '))
f.write('\n') # Add newline at the end
return True
except Exception as e:
print(f"Error saving {file_path}: {e}")
return False
def clean_translate_prefixes(value: Any) -> Any:
"""Clean multiple [TO_TRANSLATE] prefixes from a value."""
if isinstance(value, str):
# Remove multiple [TO_TRANSLATE] prefixes, keeping only one
# Pattern matches multiple [TO_TRANSLATE] followed by optional spaces
pattern = r'(\[TO_TRANSLATE\]\s*)+'
cleaned = re.sub(pattern, '[TO_TRANSLATE] ', value)
# If the original value had [TO_TRANSLATE] prefixes, ensure it starts with exactly one
if '[TO_TRANSLATE]' in value:
# Remove any leading [TO_TRANSLATE] first
without_prefix = re.sub(r'^\[TO_TRANSLATE\]\s*', '', cleaned)
# Add exactly one prefix
cleaned = f'[TO_TRANSLATE] {without_prefix}'
return cleaned
elif isinstance(value, dict):
return {k: clean_translate_prefixes(v) for k, v in value.items()}
elif isinstance(value, list):
return [clean_translate_prefixes(item) for item in value]
else:
return value
def clean_translation_file(file_path: Path, dry_run: bool = False) -> Dict[str, int]:
"""Clean a single translation file and return statistics."""
print(f"Processing: {file_path.name}")
# Load the file
data = load_json_file(file_path)
if not data:
print(f" ❌ Error loading file")
return {'errors': 1, 'cleaned': 0, 'unchanged': 0}
# Clean the data
cleaned_data = clean_translate_prefixes(data)
# Count changes by comparing JSON strings
original_str = json.dumps(data, sort_keys=True)
cleaned_str = json.dumps(cleaned_data, sort_keys=True)
if original_str == cleaned_str:
print(f" ✅ No changes needed")
return {'errors': 0, 'cleaned': 0, 'unchanged': 1}
# Count how many strings were affected
def count_translate_strings(obj, prefix_count=0):
if isinstance(obj, str):
return prefix_count + (1 if '[TO_TRANSLATE]' in obj else 0)
elif isinstance(obj, dict):
return sum(count_translate_strings(v, prefix_count) for v in obj.values())
elif isinstance(obj, list):
return sum(count_translate_strings(item, prefix_count) for item in obj)
return prefix_count
original_count = count_translate_strings(data)
cleaned_count = count_translate_strings(cleaned_data)
if dry_run:
print(f" 📝 [DRY RUN] Would clean {original_count - cleaned_count} strings with multiple prefixes")
return {'errors': 0, 'cleaned': 1, 'unchanged': 0}
else:
# Save the cleaned data
if save_json_file(file_path, cleaned_data):
print(f" 🔄 Cleaned {original_count - cleaned_count} strings with multiple prefixes")
return {'errors': 0, 'cleaned': 1, 'unchanged': 0}
else:
print(f" ❌ Error saving file")
return {'errors': 1, 'cleaned': 0, 'unchanged': 0}
def clean_translations(messages_dir: Path, exclude_reference: str = 'en-US.json',
dry_run: bool = False) -> None:
"""Clean all translation files in the directory."""
# Find all JSON files except the reference file
json_files = [f for f in messages_dir.glob('*.json') if f.name != exclude_reference]
if not json_files:
print("No translation files found")
return
print(f"Found {len(json_files)} translation files to process\n")
stats = {'errors': 0, 'cleaned': 0, 'unchanged': 0}
for json_file in sorted(json_files):
file_stats = clean_translation_file(json_file, dry_run)
for key in stats:
stats[key] += file_stats[key]
print()
# Show summary
print("=" * 60)
print("SUMMARY")
print("=" * 60)
if dry_run:
print("🔍 DRY RUN MODE - No changes were made\n")
print(f"✅ Files unchanged: {stats['unchanged']}")
print(f"🔄 Files cleaned: {stats['cleaned']}")
print(f"❌ Files with errors: {stats['errors']}")
if stats['cleaned'] > 0 and not dry_run:
print(f"\n🎉 Successfully cleaned {stats['cleaned']} files!")
def main():
parser = argparse.ArgumentParser(
description='Clean up translation files with multiple [TO_TRANSLATE] prefixes'
)
parser.add_argument(
'--messages-dir',
type=Path,
default=Path(__file__).parent.parent / 'messages',
help='Directory containing message files (default: ../messages)'
)
parser.add_argument(
'--exclude-reference',
default='en-US.json',
help='Reference file to exclude from cleaning (default: en-US.json)'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Only show what would be changed without making modifications'
)
args = parser.parse_args()
if not args.messages_dir.exists():
print(f"Directory not found: {args.messages_dir}")
return 1
print(f"Directory: {args.messages_dir}")
print(f"Exclude: {args.exclude_reference}")
print(f"Dry run: {args.dry_run}")
print("-" * 60)
clean_translations(
messages_dir=args.messages_dir,
exclude_reference=args.exclude_reference,
dry_run=args.dry_run
)
return 0
if __name__ == '__main__':
exit(main())

View File

@@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""
Main script to run Palmr translation management operations.
Makes it easy to run scripts without remembering specific names.
"""
import sys
import subprocess
from pathlib import Path
import argparse
def run_command(script_name: str, args: list) -> int:
"""Execute a script with the provided arguments."""
script_path = Path(__file__).parent / script_name
cmd = [sys.executable, str(script_path)] + args
return subprocess.run(cmd).returncode
def filter_args_for_script(script_name: str, args: list) -> list:
"""Filter arguments based on what each script accepts."""
# Arguments that check_translations.py accepts
check_args = ['--messages-dir', '--reference']
# Arguments that sync_translations.py accepts
sync_args = ['--messages-dir', '--reference', '--no-mark-untranslated', '--dry-run']
if script_name == 'check_translations.py':
filtered = []
skip_next = False
for i, arg in enumerate(args):
if skip_next:
skip_next = False
continue
if arg in check_args:
filtered.append(arg)
# Add the value for the argument if it exists
if i + 1 < len(args) and not args[i + 1].startswith('--'):
filtered.append(args[i + 1])
skip_next = True
return filtered
elif script_name == 'sync_translations.py':
filtered = []
skip_next = False
for i, arg in enumerate(args):
if skip_next:
skip_next = False
continue
if arg in sync_args:
filtered.append(arg)
# Add the value for the argument if it exists
if i + 1 < len(args) and not args[i + 1].startswith('--'):
filtered.append(args[i + 1])
skip_next = True
return filtered
return args
def main():
parser = argparse.ArgumentParser(
description='Main script to manage Palmr translations',
epilog='Examples:\n'
' python3 run_translations.py check\n'
' python3 run_translations.py sync --dry-run\n'
' python3 run_translations.py all --dry-run\n',
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument(
'command',
choices=['check', 'sync', 'all', 'help'],
help='Command to execute:\n'
'check - Check translation status\n'
'sync - Synchronize missing keys\n'
'all - Run complete workflow (sync + check)\n'
'help - Show detailed help'
)
# Capture remaining arguments to pass to scripts
args, remaining_args = parser.parse_known_args()
if args.command == 'help':
print("🌍 PALMR TRANSLATION MANAGER")
print("=" * 50)
print()
print("📋 AVAILABLE COMMANDS:")
print()
print("🔍 check - Check translation status")
print(" python3 run_translations.py check")
print(" python3 run_translations.py check --reference pt-BR.json")
print()
print("🔄 sync - Synchronize missing keys")
print(" python3 run_translations.py sync")
print(" python3 run_translations.py sync --dry-run")
print(" python3 run_translations.py sync --no-mark-untranslated")
print()
print("⚡ all - Complete workflow (sync + check)")
print(" python3 run_translations.py all")
print(" python3 run_translations.py all --dry-run")
print()
print("📁 STRUCTURE:")
print(" apps/web/scripts/ - Management scripts")
print(" apps/web/messages/ - Translation files")
print()
print("💡 TIPS:")
print("• Use --dry-run on sync or all commands to test")
print("• Use --help on any command for specific options")
print("• Manually translate strings marked with [TO_TRANSLATE]")
print("• Read documentation for complete translation guidelines")
return 0
elif args.command == 'check':
print("🔍 Checking translation status...")
filtered_args = filter_args_for_script('check_translations.py', remaining_args)
return run_command('check_translations.py', filtered_args)
elif args.command == 'sync':
print("🔄 Synchronizing translation keys...")
filtered_args = filter_args_for_script('sync_translations.py', remaining_args)
return run_command('sync_translations.py', filtered_args)
elif args.command == 'all':
print("⚡ Running complete translation workflow...")
print()
# Determine if it's dry-run based on arguments
is_dry_run = '--dry-run' in remaining_args
# 1. Initial check
print("1⃣ Checking initial status...")
check_args = filter_args_for_script('check_translations.py', remaining_args)
result = run_command('check_translations.py', check_args)
if result != 0:
print("❌ Error in initial check")
return result
print("\n" + "="*50)
# 2. Sync
print("2⃣ Synchronizing missing keys...")
sync_args = filter_args_for_script('sync_translations.py', remaining_args)
result = run_command('sync_translations.py', sync_args)
if result != 0:
print("❌ Error in synchronization")
return result
print("\n" + "="*50)
# 3. Final check
print("3⃣ Final check...")
check_args = filter_args_for_script('check_translations.py', remaining_args)
result = run_command('check_translations.py', check_args)
if result != 0:
print("❌ Error in final check")
return result
print("\n🎉 Complete workflow executed successfully!")
if is_dry_run:
print("💡 Run without --dry-run to apply changes")
else:
print("💡 Review strings marked with [TO_TRANSLATE] and translate them manually")
return 0
return 0
if __name__ == '__main__':
exit(main())

View File

@@ -0,0 +1,273 @@
#!/usr/bin/env python3
"""
Script to synchronize translations using en-US.json as reference.
Adds missing keys to other language files.
"""
import json
import os
from pathlib import Path
from typing import Dict, Any, Set, List
import argparse
def load_json_file(file_path: Path) -> Dict[str, Any]:
"""Load a JSON file."""
try:
with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"Error loading {file_path}: {e}")
return {}
def save_json_file(file_path: Path, data: Dict[str, Any], indent: int = 2) -> bool:
"""Save a JSON file with consistent formatting."""
try:
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=indent, separators=(',', ': '))
f.write('\n') # Add newline at the end
return True
except Exception as e:
print(f"Error saving {file_path}: {e}")
return False
def get_all_keys(data: Dict[str, Any], prefix: str = '') -> Set[str]:
"""Extract all keys from nested JSON recursively."""
keys = set()
for key, value in data.items():
current_key = f"{prefix}.{key}" if prefix else key
keys.add(current_key)
if isinstance(value, dict):
keys.update(get_all_keys(value, current_key))
return keys
def get_nested_value(data: Dict[str, Any], key_path: str) -> Any:
"""Get a nested value using a key with dots as separator."""
keys = key_path.split('.')
current = data
for key in keys:
if isinstance(current, dict) and key in current:
current = current[key]
else:
return None
return current
def set_nested_value(data: Dict[str, Any], key_path: str, value: Any) -> None:
"""Set a nested value using a key with dots as separator."""
keys = key_path.split('.')
current = data
# Navigate to the second-to-last level, creating dictionaries as needed
for key in keys[:-1]:
if key not in current:
current[key] = {}
elif not isinstance(current[key], dict):
current[key] = {}
current = current[key]
# Set the value at the last level
current[keys[-1]] = value
def find_missing_keys(reference_data: Dict[str, Any], target_data: Dict[str, Any]) -> List[str]:
"""Find keys that are in reference but not in target."""
reference_keys = get_all_keys(reference_data)
target_keys = get_all_keys(target_data)
missing_keys = reference_keys - target_keys
return sorted(list(missing_keys))
def add_missing_keys(reference_data: Dict[str, Any], target_data: Dict[str, Any],
missing_keys: List[str], mark_as_untranslated: bool = True) -> Dict[str, Any]:
"""Add missing keys to target_data using reference values."""
updated_data = target_data.copy()
for key_path in missing_keys:
reference_value = get_nested_value(reference_data, key_path)
if reference_value is not None:
# Check if the key already exists in target with a [TO_TRANSLATE] prefix
existing_value = get_nested_value(target_data, key_path)
if mark_as_untranslated and isinstance(reference_value, str):
# If the existing value already starts with [TO_TRANSLATE], don't add another prefix
if existing_value and isinstance(existing_value, str) and existing_value.startswith("[TO_TRANSLATE]"):
translated_value = existing_value
else:
translated_value = f"[TO_TRANSLATE] {reference_value}"
else:
translated_value = reference_value
set_nested_value(updated_data, key_path, translated_value)
return updated_data
def sync_translations(messages_dir: Path, reference_file: str = 'en-US.json',
mark_as_untranslated: bool = True, dry_run: bool = False) -> None:
"""Synchronize all translations using a reference file."""
# Load reference file
reference_path = messages_dir / reference_file
if not reference_path.exists():
print(f"Reference file not found: {reference_path}")
return
print(f"Loading reference file: {reference_file}")
reference_data = load_json_file(reference_path)
if not reference_data:
print("Error loading reference file")
return
# Find all JSON files in the folder
json_files = [f for f in messages_dir.glob('*.json') if f.name != reference_file]
if not json_files:
print("No translation files found")
return
total_keys_reference = len(get_all_keys(reference_data))
print(f"Reference file contains {total_keys_reference} keys")
print(f"Processing {len(json_files)} translation files...\n")
summary = []
for json_file in sorted(json_files):
print(f"Processing: {json_file.name}")
# Load translation file
translation_data = load_json_file(json_file)
if not translation_data:
print(f" ❌ Error loading {json_file.name}")
continue
# Find missing keys
missing_keys = find_missing_keys(reference_data, translation_data)
current_keys = len(get_all_keys(translation_data))
if not missing_keys:
print(f" ✅ Complete ({current_keys}/{total_keys_reference} keys)")
summary.append({
'file': json_file.name,
'status': 'complete',
'missing': 0,
'total': current_keys
})
continue
print(f" 🔍 Found {len(missing_keys)} missing keys")
if dry_run:
print(f" 📝 [DRY RUN] Keys that would be added:")
for key in missing_keys[:5]: # Show only first 5
print(f" - {key}")
if len(missing_keys) > 5:
print(f" ... and {len(missing_keys) - 5} more")
else:
# Add missing keys
updated_data = add_missing_keys(reference_data, translation_data,
missing_keys, mark_as_untranslated)
# Save updated file
if save_json_file(json_file, updated_data):
print(f" ✅ Updated successfully ({current_keys + len(missing_keys)}/{total_keys_reference} keys)")
summary.append({
'file': json_file.name,
'status': 'updated',
'missing': len(missing_keys),
'total': current_keys + len(missing_keys)
})
else:
print(f" ❌ Error saving {json_file.name}")
summary.append({
'file': json_file.name,
'status': 'error',
'missing': len(missing_keys),
'total': current_keys
})
print()
# Show summary
print("=" * 60)
print("SUMMARY")
print("=" * 60)
if dry_run:
print("🔍 DRY RUN MODE - No changes were made\n")
for item in summary:
status_icon = {
'complete': '',
'updated': '🔄',
'error': ''
}.get(item['status'], '')
print(f"{status_icon} {item['file']:<15} - {item['total']}/{total_keys_reference} keys", end='')
if item['missing'] > 0:
print(f" (+{item['missing']} added)" if item['status'] == 'updated' else f" ({item['missing']} missing)")
else:
print()
def main():
parser = argparse.ArgumentParser(
description='Synchronize translations using en-US.json as reference'
)
parser.add_argument(
'--messages-dir',
type=Path,
default=Path(__file__).parent.parent / 'messages',
help='Directory containing message files (default: ../messages)'
)
parser.add_argument(
'--reference',
default='en-US.json',
help='Reference file (default: en-US.json)'
)
parser.add_argument(
'--no-mark-untranslated',
action='store_true',
help='Don\'t mark added keys as [TO_TRANSLATE]'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Only show what would be changed without making modifications'
)
args = parser.parse_args()
if not args.messages_dir.exists():
print(f"Directory not found: {args.messages_dir}")
return 1
print(f"Directory: {args.messages_dir}")
print(f"Reference: {args.reference}")
print(f"Mark untranslated: {not args.no_mark_untranslated}")
print(f"Dry run: {args.dry_run}")
print("-" * 60)
sync_translations(
messages_dir=args.messages_dir,
reference_file=args.reference,
mark_as_untranslated=not args.no_mark_untranslated,
dry_run=args.dry_run
)
return 0
if __name__ == '__main__':
exit(main())

View File

@@ -156,13 +156,11 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
const { file } = fileWithProgress;
try {
// Start upload
updateFileStatus(index, {
status: FILE_STATUS.UPLOADING,
progress: UPLOAD_PROGRESS.INITIAL,
});
// Generate object name and get presigned URL
const objectName = generateObjectName(file.name);
const presignedResponse = await getPresignedUrlForUploadByAlias(
alias,
@@ -170,16 +168,12 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
password ? { password } : undefined
);
// Upload to storage
await uploadFileToStorage(file, presignedResponse.data.url);
// Update progress
updateFileStatus(index, { progress: UPLOAD_PROGRESS.COMPLETE });
// Register file upload
await registerUploadedFile(file, objectName);
// Mark as successful
updateFileStatus(index, { status: FILE_STATUS.SUCCESS });
} catch (error: any) {
console.error("Upload error:", error);
@@ -200,11 +194,27 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
return false;
}
if (!uploaderName.trim() && !uploaderEmail.trim()) {
toast.error(t("reverseShares.upload.errors.provideNameOrEmail"));
// Check if either name or email is required
const nameRequired = reverseShare.nameFieldRequired === "REQUIRED";
const emailRequired = reverseShare.emailFieldRequired === "REQUIRED";
if (nameRequired && !uploaderName.trim()) {
toast.error(t("reverseShares.upload.errors.provideNameRequired"));
return false;
}
if (emailRequired && !uploaderEmail.trim()) {
toast.error(t("reverseShares.upload.errors.provideEmailRequired"));
return false;
}
if (reverseShare.nameFieldRequired === "OPTIONAL" && reverseShare.emailFieldRequired === "OPTIONAL") {
if (!uploaderName.trim() && !uploaderEmail.trim()) {
toast.error(t("reverseShares.upload.errors.provideNameOrEmail"));
return false;
}
}
return true;
};
@@ -237,13 +247,32 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
}
};
const canUpload = files.length > 0 && (uploaderName.trim() || uploaderEmail.trim()) && !isUploading;
const getCanUpload = (): boolean => {
if (files.length === 0 || isUploading) return false;
const nameRequired = reverseShare.nameFieldRequired === "REQUIRED";
const emailRequired = reverseShare.emailFieldRequired === "REQUIRED";
const nameHidden = reverseShare.nameFieldRequired === "HIDDEN";
const emailHidden = reverseShare.emailFieldRequired === "HIDDEN";
if (nameHidden && emailHidden) return true;
if (nameRequired && !uploaderName.trim()) return false;
if (emailRequired && !uploaderEmail.trim()) return false;
if (reverseShare.nameFieldRequired === "OPTIONAL" && reverseShare.emailFieldRequired === "OPTIONAL") {
return !!(uploaderName.trim() || uploaderEmail.trim());
}
return true;
};
const canUpload = getCanUpload();
const allFilesProcessed = files.every(
(file) => file.status === FILE_STATUS.SUCCESS || file.status === FILE_STATUS.ERROR
);
const hasSuccessfulUploads = files.some((file) => file.status === FILE_STATUS.SUCCESS);
// Call onUploadSuccess when all files are processed and there are successful uploads
useEffect(() => {
if (allFilesProcessed && hasSuccessfulUploads && files.length > 0) {
onUploadSuccess?.();
@@ -266,7 +295,6 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
};
const renderFileRestrictions = () => {
// Calculate remaining files that can be uploaded
const calculateRemainingFiles = (): number => {
if (!reverseShare.maxFiles) return 0;
const currentTotal = reverseShare.currentFileCount + files.length;
@@ -339,13 +367,34 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
<IconX className="h-4 w-4" />
</Button>
)}
{fileWithProgress.status === FILE_STATUS.ERROR && (
<div className="flex gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => {
setFiles((prev) =>
prev.map((file, i) =>
i === index ? { ...file, status: FILE_STATUS.PENDING, error: undefined } : file
)
);
}}
disabled={isUploading}
title={t("reverseShares.upload.retry")}
>
<IconUpload className="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" onClick={() => removeFile(index)} disabled={isUploading}>
<IconX className="h-4 w-4" />
</Button>
</div>
)}
</div>
</div>
);
return (
<div className="space-y-6">
{/* File Drop Zone */}
<div {...getRootProps()} className={getDropzoneStyles()}>
<input {...getInputProps()} />
<IconUpload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
@@ -357,7 +406,6 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
{renderFileRestrictions()}
</div>
{/* File List */}
{files.length > 0 && (
<div className="space-y-2">
<h4 className="font-medium text-gray-900 dark:text-white">{t("reverseShares.upload.fileList.title")}</h4>
@@ -365,36 +413,47 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
</div>
)}
{/* User Information */}
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<Label htmlFor="name">
<IconUser className="inline h-4 w-4" />
{t("reverseShares.upload.form.nameLabel")}
</Label>
<Input
id="name"
placeholder={t("reverseShares.upload.form.namePlaceholder")}
value={uploaderName}
onChange={(e) => setUploaderName(e.target.value)}
disabled={isUploading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">
<IconMail className="inline h-4 w-4" />
{t("reverseShares.upload.form.emailLabel")}
</Label>
<Input
id="email"
type="email"
placeholder={t("reverseShares.upload.form.emailPlaceholder")}
value={uploaderEmail}
onChange={(e) => setUploaderEmail(e.target.value)}
disabled={isUploading}
/>
</div>
{reverseShare.nameFieldRequired !== "HIDDEN" && (
<div className="space-y-2">
<Label htmlFor="name">
<IconUser className="inline h-4 w-4" />
{reverseShare.nameFieldRequired === "OPTIONAL"
? t("reverseShares.upload.form.nameLabelOptional")
: t("reverseShares.upload.form.nameLabel")}
{reverseShare.nameFieldRequired === "REQUIRED" && <span className="text-red-500 ml-1">*</span>}
</Label>
<Input
id="name"
placeholder={t("reverseShares.upload.form.namePlaceholder")}
value={uploaderName}
onChange={(e) => setUploaderName(e.target.value)}
disabled={isUploading}
required={reverseShare.nameFieldRequired === "REQUIRED"}
/>
</div>
)}
{reverseShare.emailFieldRequired !== "HIDDEN" && (
<div className="space-y-2">
<Label htmlFor="email">
<IconMail className="inline h-4 w-4" />
{reverseShare.emailFieldRequired === "OPTIONAL"
? t("reverseShares.upload.form.emailLabelOptional")
: t("reverseShares.upload.form.emailLabel")}
{reverseShare.emailFieldRequired === "REQUIRED" && <span className="text-red-500 ml-1">*</span>}
</Label>
<Input
id="email"
type="email"
placeholder={t("reverseShares.upload.form.emailPlaceholder")}
value={uploaderEmail}
onChange={(e) => setUploaderEmail(e.target.value)}
disabled={isUploading}
required={reverseShare.emailFieldRequired === "REQUIRED"}
/>
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="description">{t("reverseShares.upload.form.descriptionLabel")}</Label>
@@ -409,14 +468,12 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
</div>
</div>
{/* Upload Button */}
<Button onClick={handleUpload} disabled={!canUpload} className="w-full text-white" size="lg" variant="default">
{isUploading
? t("reverseShares.upload.form.uploading")
: t("reverseShares.upload.form.uploadButton", { count: files.length })}
</Button>
{/* Success Message */}
{allFilesProcessed && hasSuccessfulUploads && (
<div className="text-center p-4 bg-green-50 dark:bg-green-950/20 rounded-lg">
<p className="text-green-800 dark:text-green-200 font-medium">{t("reverseShares.upload.success.title")}</p>

View File

@@ -66,7 +66,6 @@ export function WeTransferStatusMessage({
}: WeTransferStatusMessageProps) {
const t = useTranslations();
// Map message types to variants
const getVariant = (): "success" | "warning" | "error" | "info" | "neutral" => {
switch (type) {
case MESSAGE_TYPES.SUCCESS:

View File

@@ -12,13 +12,11 @@ import { FileUploadSection } from "./file-upload-section";
import { WeTransferStatusMessage } from "./shared/status-message";
import { TransparentFooter } from "./transparent-footer";
// Função para escolher uma imagem aleatória
const getRandomBackgroundImage = (): string => {
const randomIndex = Math.floor(Math.random() * BACKGROUND_IMAGES.length);
return BACKGROUND_IMAGES[randomIndex];
};
// Hook para gerenciar a imagem de background
const useBackgroundImage = () => {
const [selectedImage, setSelectedImage] = useState<string>("");
const [imageLoaded, setImageLoaded] = useState(false);
@@ -42,7 +40,6 @@ const useBackgroundImage = () => {
return { selectedImage, imageLoaded };
};
// Componente para controles do header
const HeaderControls = () => (
<div className="absolute top-4 right-4 md:top-6 md:right-6 z-40 flex items-center gap-2">
<div className="bg-white/10 dark:bg-black/20 backdrop-blur-xs border border-white/20 dark:border-white/10 rounded-lg p-1">
@@ -54,7 +51,6 @@ const HeaderControls = () => (
</div>
);
// Componente para o fundo com imagem
const BackgroundLayer = ({ selectedImage, imageLoaded }: { selectedImage: string; imageLoaded: boolean }) => (
<>
<div className="absolute inset-0 z-0 bg-background" />
@@ -162,18 +158,15 @@ export function WeTransferLayout({
<BackgroundLayer selectedImage={selectedImage} imageLoaded={imageLoaded} />
<HeaderControls />
{/* Loading indicator */}
{!imageLoaded && (
<div className="absolute inset-0 z-30 flex items-center justify-center">
<div className="animate-pulse text-white/70 text-sm">{t("reverseShares.upload.layout.loading")}</div>
</div>
)}
{/* Main Content */}
<div className="relative z-30 min-h-screen flex items-center justify-start p-4 md:p-8 lg:p-12 xl:p-16">
<div className="w-full max-w-md lg:max-w-lg xl:max-w-xl">
<div className="bg-white dark:bg-black rounded-2xl shadow-2xl p-6 md:p-8 backdrop-blur-sm border border-white/20">
{/* Header */}
<div className="text-left mb-6 md:mb-8">
<h1 className="text-xl md:text-2xl lg:text-3xl font-bold text-gray-900 dark:text-white mb-2">
{reverseShare?.name || t("reverseShares.upload.layout.defaultTitle")}
@@ -183,7 +176,6 @@ export function WeTransferLayout({
)}
</div>
{/* Upload Section */}
{getUploadSectionContent()}
</div>
</div>

View File

@@ -1,4 +1,3 @@
// HTTP Status Constants
export const HTTP_STATUS = {
UNAUTHORIZED: 401,
FORBIDDEN: 403,
@@ -6,13 +5,11 @@ export const HTTP_STATUS = {
GONE: 410,
} as const;
// Error Messages
export const ERROR_MESSAGES = {
PASSWORD_REQUIRED: "Password required",
INVALID_PASSWORD: "Invalid password",
} as const;
// Error types
export type ErrorType = "inactive" | "notFound" | "expired" | "generic" | null;
export const STATUS_VARIANTS = {

View File

@@ -17,7 +17,6 @@ export function useReverseShareUpload({ alias }: UseReverseShareUploadProps) {
const router = useRouter();
const t = useTranslations();
// States
const [reverseShare, setReverseShare] = useState<ReverseShareInfo | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false);
@@ -25,7 +24,6 @@ export function useReverseShareUpload({ alias }: UseReverseShareUploadProps) {
const [hasUploadedSuccessfully, setHasUploadedSuccessfully] = useState(false);
const [error, setError] = useState<{ type: ErrorType }>({ type: null });
// Utility functions
const redirectToHome = () => router.push("/");
const checkIfMaxFilesReached = (reverseShareData: ReverseShareInfo): boolean => {
@@ -109,23 +107,19 @@ export function useReverseShareUpload({ alias }: UseReverseShareUploadProps) {
}
}, [alias]);
// Computed values
const isMaxFilesReached = reverseShare ? checkIfMaxFilesReached(reverseShare) : false;
const isWeTransferLayout = reverseShare?.pageLayout === "WETRANSFER";
const hasError = error.type !== null || (!reverseShare && !isLoading && !isPasswordModalOpen);
// Error state booleans for backward compatibility
const isLinkInactive = error.type === "inactive";
const isLinkNotFound = error.type === "notFound" || (!reverseShare && !isLoading && !isPasswordModalOpen);
const isLinkExpired = error.type === "expired";
return {
// Data
reverseShare,
currentPassword,
alias,
// States
isLoading,
isPasswordModalOpen,
hasUploadedSuccessfully,
@@ -134,12 +128,10 @@ export function useReverseShareUpload({ alias }: UseReverseShareUploadProps) {
isWeTransferLayout,
hasError,
// Error states (for backward compatibility)
isLinkInactive,
isLinkNotFound,
isLinkExpired,
// Actions
handlePasswordSubmit,
handlePasswordModalClose,
handleUploadSuccess,

View File

@@ -27,19 +27,16 @@ export default function ReverseShareUploadPage() {
handleUploadSuccess,
} = useReverseShareUpload({ alias: shareAlias });
// Loading state
if (isLoading) {
return <LoadingScreen />;
}
// Password required state
if (isPasswordModalOpen) {
return (
<PasswordModal isOpen={isPasswordModalOpen} onSubmit={handlePasswordSubmit} onClose={handlePasswordModalClose} />
);
}
// Error states or missing data - always use DefaultLayout for simplicity
if (hasError) {
return (
<DefaultLayout
@@ -56,7 +53,6 @@ export default function ReverseShareUploadPage() {
);
}
// Render appropriate layout for normal states
if (isWeTransferLayout) {
return (
<WeTransferLayout

View File

@@ -8,8 +8,10 @@ import {
IconFile,
IconFiles,
IconLock,
IconMail,
IconSettings,
IconUpload,
IconUser,
} from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
@@ -55,9 +57,12 @@ interface CreateReverseShareFormData {
allowedFileTypes?: string;
password?: string;
pageLayout?: "DEFAULT" | "WETRANSFER";
nameFieldRequired: "HIDDEN" | "OPTIONAL" | "REQUIRED";
emailFieldRequired: "HIDDEN" | "OPTIONAL" | "REQUIRED";
isPasswordProtected: boolean;
hasExpiration: boolean;
hasFileLimits: boolean;
hasFieldRequirements: boolean;
noFilesLimit: boolean;
noSizeLimit: boolean;
allFileTypes: boolean;
@@ -79,9 +84,12 @@ const DEFAULT_FORM_VALUES: CreateReverseShareFormData = {
allowedFileTypes: "",
password: "",
pageLayout: "DEFAULT",
nameFieldRequired: "OPTIONAL",
emailFieldRequired: "OPTIONAL",
isPasswordProtected: false,
hasExpiration: false,
hasFileLimits: false,
hasFieldRequirements: false,
noFilesLimit: true,
noSizeLimit: true,
allFileTypes: true,
@@ -103,6 +111,7 @@ export function CreateReverseShareModal({
isPasswordProtected: form.watch("isPasswordProtected"),
hasExpiration: form.watch("hasExpiration"),
hasFileLimits: form.watch("hasFileLimits"),
hasFieldRequirements: form.watch("hasFieldRequirements"),
noFilesLimit: form.watch("noFilesLimit"),
noSizeLimit: form.watch("noSizeLimit"),
allFileTypes: form.watch("allFileTypes"),
@@ -112,6 +121,8 @@ export function CreateReverseShareModal({
const payload: CreateReverseShareBody = {
name: formData.name,
pageLayout: formData.pageLayout || "DEFAULT",
nameFieldRequired: formData.nameFieldRequired,
emailFieldRequired: formData.emailFieldRequired,
};
if (formData.description?.trim()) {
@@ -466,6 +477,126 @@ export function CreateReverseShareModal({
)}
</div>
<Separator />
{/* Field Requirements */}
<div className="space-y-4">
{renderSectionToggle(
watchedValues.hasFieldRequirements,
<IconUser size={ICON_SIZES.medium} />,
t("reverseShares.form.fieldRequirements.title"),
toggleSection("hasFieldRequirements")
)}
{watchedValues.hasFieldRequirements && (
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="nameFieldRequired"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2 font-medium">
<IconUser size={ICON_SIZES.small} />
{t("reverseShares.form.nameFieldRequired.label")}
</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="bg-white dark:bg-gray-900">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="HIDDEN">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-gray-400" />
{t("reverseShares.labels.fieldOptions.hidden")}
</div>
</SelectItem>
<SelectItem value="OPTIONAL">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-blue-500" />
{t("reverseShares.labels.fieldOptions.optional")}
</div>
</SelectItem>
<SelectItem value="REQUIRED">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-red-500" />
{t("reverseShares.labels.fieldOptions.required")}
</div>
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="emailFieldRequired"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2 font-medium">
<IconUser size={ICON_SIZES.small} />
{t("reverseShares.form.emailFieldRequired.label")}
</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="bg-white dark:bg-gray-900">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="HIDDEN">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-gray-400" />
{t("reverseShares.labels.fieldOptions.hidden")}
</div>
</SelectItem>
<SelectItem value="OPTIONAL">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-blue-500" />
{t("reverseShares.labels.fieldOptions.optional")}
</div>
</SelectItem>
<SelectItem value="REQUIRED">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-red-500" />
{t("reverseShares.labels.fieldOptions.required")}
</div>
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="text-xs text-muted-foreground bg-blue-50 dark:bg-blue-950/20 p-3 rounded-md border border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-2">
<IconSettings size={12} className="mt-0.5 text-blue-600 dark:text-blue-400" />
<div className="space-y-1">
<p className="font-medium text-blue-900 dark:text-blue-100">Field Configuration:</p>
<ul className="space-y-0.5 text-blue-800 dark:text-blue-200">
<li>
<strong>Hidden:</strong> Field won't appear in the upload form
</li>
<li>
• <strong>Optional:</strong> Field appears but isn't required
</li>
<li>
<strong>Required:</strong> Field appears and must be filled
</li>
</ul>
</div>
</div>
</div>
</div>
)}
</div>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" onClick={handleClose} disabled={isCreating}>
{t("common.cancel")}

View File

@@ -73,14 +73,16 @@ export function EditPasswordModal({
});
toast.success(
data.hasPassword ? "Proteção por senha ativada com sucesso!" : "Proteção por senha removida com sucesso!"
data.hasPassword
? t("reverseShares.messages.passwordProtectionEnabled")
: t("reverseShares.messages.passwordProtectionDisabled")
);
onClose();
form.reset();
} catch (error) {
console.error("Failed to update password:", error);
toast.error("Erro ao atualizar proteção por senha");
toast.error(t("reverseShares.errors.passwordUpdateFailed"));
}
};

View File

@@ -11,6 +11,7 @@ import {
IconFiles,
IconLock,
IconSettings,
IconUser,
} from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
@@ -37,20 +38,12 @@ import { ReverseShare } from "../hooks/use-reverse-shares";
import { FileSizeInput } from "./file-size-input";
import { FileTypesTagsInput } from "./file-types-tags-input";
// Constants
const DEFAULT_VALUES = {
EMPTY_STRING: "",
ZERO_STRING: "0",
PAGE_LAYOUT: "DEFAULT" as const,
} as const;
const FORM_SECTIONS = {
BASIC_INFO: "basicInfo",
EXPIRATION: "expiration",
FILE_LIMITS: "fileLimits",
PASSWORD: "password",
} as const;
interface EditReverseShareFormData {
name: string;
description?: string;
@@ -59,8 +52,11 @@ interface EditReverseShareFormData {
maxFileSize?: string;
allowedFileTypes?: string;
pageLayout?: "DEFAULT" | "WETRANSFER";
nameFieldRequired: "HIDDEN" | "OPTIONAL" | "REQUIRED";
emailFieldRequired: "HIDDEN" | "OPTIONAL" | "REQUIRED";
hasExpiration: boolean;
hasFileLimits: boolean;
hasFieldRequirements: boolean;
hasPassword: boolean;
password?: string;
isActive: boolean;
@@ -93,6 +89,7 @@ export function EditReverseShareModal({
const watchedValues = {
hasExpiration: form.watch("hasExpiration"),
hasFileLimits: form.watch("hasFileLimits"),
hasFieldRequirements: form.watch("hasFieldRequirements"),
noFilesLimit: form.watch("noFilesLimit"),
noSizeLimit: form.watch("noSizeLimit"),
allFileTypes: form.watch("allFileTypes"),
@@ -144,6 +141,8 @@ export function EditReverseShareModal({
/>
<Separator />
<PasswordSection form={form} t={t} hasPassword={watchedValues.hasPassword} />
<Separator />
<FieldRequirementsSection form={form} t={t} hasFieldRequirements={watchedValues.hasFieldRequirements} />
<DialogFooter className="gap-2">
<Button type="button" variant="outline" onClick={onClose} disabled={isUpdating}>
@@ -168,7 +167,6 @@ export function EditReverseShareModal({
);
}
// Helper functions
function getFormDefaultValues(): EditReverseShareFormData {
return {
name: DEFAULT_VALUES.EMPTY_STRING,
@@ -178,8 +176,11 @@ function getFormDefaultValues(): EditReverseShareFormData {
maxFileSize: DEFAULT_VALUES.EMPTY_STRING,
allowedFileTypes: DEFAULT_VALUES.EMPTY_STRING,
pageLayout: DEFAULT_VALUES.PAGE_LAYOUT,
nameFieldRequired: "OPTIONAL",
emailFieldRequired: "OPTIONAL",
hasExpiration: false,
hasFileLimits: false,
hasFieldRequirements: false,
hasPassword: false,
password: DEFAULT_VALUES.EMPTY_STRING,
isActive: true,
@@ -205,8 +206,11 @@ function mapReverseShareToFormData(reverseShare: ReverseShare): EditReverseShare
maxFileSize: maxFileSizeValue,
allowedFileTypes: allowedFileTypesValue,
pageLayout: (reverseShare.pageLayout as "DEFAULT" | "WETRANSFER") || DEFAULT_VALUES.PAGE_LAYOUT,
nameFieldRequired: (reverseShare.nameFieldRequired as "HIDDEN" | "OPTIONAL" | "REQUIRED") || "OPTIONAL",
emailFieldRequired: (reverseShare.emailFieldRequired as "HIDDEN" | "OPTIONAL" | "REQUIRED") || "OPTIONAL",
hasExpiration: false,
hasFileLimits: false,
hasFieldRequirements: false,
hasPassword: false,
password: DEFAULT_VALUES.EMPTY_STRING,
isActive: reverseShare.isActive,
@@ -222,21 +226,20 @@ function buildUpdatePayload(data: EditReverseShareFormData, id: string): UpdateR
name: data.name,
pageLayout: data.pageLayout || DEFAULT_VALUES.PAGE_LAYOUT,
isActive: data.isActive,
nameFieldRequired: data.nameFieldRequired,
emailFieldRequired: data.emailFieldRequired,
};
// Add optional fields
if (data.description?.trim()) {
payload.description = data.description.trim();
}
// Handle expiration
if (data.hasExpiration && data.expiration) {
payload.expiration = new Date(data.expiration).toISOString();
} else if (!data.hasExpiration) {
payload.expiration = undefined;
}
// Handle file limits
if (data.hasFileLimits) {
payload.maxFiles = parsePositiveIntegerOrNull(data.maxFiles);
payload.maxFileSize = parsePositiveIntegerOrNull(data.maxFileSize);
@@ -245,10 +248,8 @@ function buildUpdatePayload(data: EditReverseShareFormData, id: string): UpdateR
payload.maxFileSize = null;
}
// Handle allowed file types
payload.allowedFileTypes = data.allowedFileTypes?.trim() || null;
// Handle password
if (data.hasPassword && data.password) {
payload.password = data.password;
} else if (!data.hasPassword) {
@@ -289,7 +290,6 @@ function createLimitCheckbox(id: string, checked: boolean, onChange: (checked: b
);
}
// Section Components
function BasicInfoSection({ form, t }: { form: any; t: any }) {
return (
<div className="space-y-4">
@@ -442,7 +442,6 @@ function FileLimitsSection({
{hasFileLimits && (
<div className="space-y-4">
{/* Max Files Field */}
<FormField
control={form.control}
name="maxFiles"
@@ -479,7 +478,6 @@ function FileLimitsSection({
)}
/>
{/* Max File Size Field */}
<FormField
control={form.control}
name="maxFileSize"
@@ -515,7 +513,6 @@ function FileLimitsSection({
)}
/>
{/* Allowed File Types Field */}
<FormField
control={form.control}
name="allowedFileTypes"
@@ -590,3 +587,137 @@ function PasswordSection({ form, t, hasPassword }: { form: any; t: any; hasPassw
</div>
);
}
function FieldRequirementsSection({
form,
t,
hasFieldRequirements,
}: {
form: any;
t: any;
hasFieldRequirements: boolean;
}) {
const toggleFieldRequirements = () => {
const newValue = !hasFieldRequirements;
form.setValue("hasFieldRequirements", newValue);
};
return (
<div className="space-y-4">
{createToggleButton(
hasFieldRequirements,
toggleFieldRequirements,
<IconUser size={16} />,
t("reverseShares.form.fieldRequirements.title")
)}
{hasFieldRequirements && (
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="nameFieldRequired"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2 font-medium">
<IconUser size={14} />
{t("reverseShares.form.nameFieldRequired.label")}
</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger className="bg-white dark:bg-gray-900">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="HIDDEN">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-gray-400" />
{t("reverseShares.labels.fieldOptions.hidden")}
</div>
</SelectItem>
<SelectItem value="OPTIONAL">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-blue-500" />
{t("reverseShares.labels.fieldOptions.optional")}
</div>
</SelectItem>
<SelectItem value="REQUIRED">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-red-500" />
{t("reverseShares.labels.fieldOptions.required")}
</div>
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="emailFieldRequired"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2 font-medium">
<IconUser size={14} />
{t("reverseShares.form.emailFieldRequired.label")}
</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger className="bg-white dark:bg-gray-900">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="HIDDEN">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-gray-400" />
{t("reverseShares.labels.fieldOptions.hidden")}
</div>
</SelectItem>
<SelectItem value="OPTIONAL">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-blue-500" />
{t("reverseShares.labels.fieldOptions.optional")}
</div>
</SelectItem>
<SelectItem value="REQUIRED">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-red-500" />
{t("reverseShares.labels.fieldOptions.required")}
</div>
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="text-xs text-muted-foreground bg-blue-50 dark:bg-blue-950/20 p-3 rounded-md border border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-2">
<IconSettings size={12} className="mt-0.5 text-blue-600 dark:text-blue-400" />
<div className="space-y-1">
<p className="font-medium text-blue-900 dark:text-blue-100">Field Configuration:</p>
<ul className="space-y-0.5 text-blue-800 dark:text-blue-200">
<li>
<strong>Hidden:</strong> Field won't appear in the upload form
</li>
<li>
• <strong>Optional:</strong> Field appears but isn't required
</li>
<li>
<strong>Required:</strong> Field appears and must be filled
</li>
</ul>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -34,10 +34,8 @@ function bytesToHumanReadable(bytes: string): { value: string; unit: Unit } {
const value = numBytes / multiplier;
if (value >= 1) {
// Se o valor é >= 1 nesta unidade, usar ela
const rounded = Math.round(value * 100) / 100; // Arredonda para 2 casas decimais
const rounded = Math.round(value * 100) / 100;
// Se está muito próximo de um inteiro, usar inteiro
if (Math.abs(rounded - Math.round(rounded)) < 0.01) {
return { value: Math.round(rounded).toString(), unit };
} else {
@@ -46,7 +44,6 @@ function bytesToHumanReadable(bytes: string): { value: string; unit: Unit } {
}
}
// Fallback para MB
const mbValue = numBytes / UNIT_MULTIPLIERS.MB;
return { value: mbValue.toFixed(2), unit: "MB" as Unit };
}
@@ -92,7 +89,6 @@ export function FileSizeInput({ value, onChange, disabled = false, error, placeh
};
const handleUnitChange = (newUnit: Unit) => {
// Ignorar valores vazios ou inválidos que podem vir do Select quando atualizado programaticamente
if (!newUnit || !["MB", "GB", "TB", "PB"].includes(newUnit)) {
return;
}

View File

@@ -24,7 +24,6 @@ export function FileTypesTagsInput({
const [inputValue, setInputValue] = useState("");
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
// Separadores: Enter, espaço, vírgula, pipe, traço
if (e.key === "Enter" || e.key === " " || e.key === "," || e.key === "|" || e.key === "-") {
e.preventDefault();
addTag();
@@ -32,7 +31,6 @@ export function FileTypesTagsInput({
e.preventDefault();
removeTag(value.length - 1);
} else if (e.key === ".") {
// Impedir pontos
e.preventDefault();
}
};
@@ -51,7 +49,6 @@ export function FileTypesTagsInput({
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// Remover pontos e forçar minúsculo
const sanitizedValue = e.target.value.replace(/\./g, "").toLowerCase();
setInputValue(sanitizedValue);
};

View File

@@ -54,14 +54,11 @@ export function GenerateAliasModal({
},
});
// Atualiza o valor padrão quando o reverseShare muda
React.useEffect(() => {
if (reverseShare) {
if (reverseShare.alias?.alias) {
// Se já tem alias, usa o existente
form.setValue("alias", reverseShare.alias.alias);
} else {
// Se não tem alias, gera um novo valor padrão
form.setValue("alias", generateDefaultAlias());
}
}
@@ -75,7 +72,6 @@ export function GenerateAliasModal({
await onCreateAlias(reverseShare.id, data.alias);
onClose();
} catch (error) {
// Erro já é tratado no hook
} finally {
setIsSubmitting(false);
}
@@ -150,17 +146,15 @@ export function GenerateAliasModal({
className="max-w-full"
{...field}
onChange={(e) => {
// Converter espaços em hífens e remover caracteres não permitidos
const value = e.target.value
.replace(/\s+/g, "-") // espaços viram hífens
.replace(/[^a-zA-Z0-9-_]/g, "") // remove caracteres não permitidos
.toLowerCase(); // converte para minúsculo
.replace(/\s+/g, "-")
.replace(/[^a-zA-Z0-9-_]/g, "")
.toLowerCase();
field.onChange(value);
}}
/>
</FormControl>
{/* Preview do link */}
{field.value && field.value.length >= 3 && (
<div className="mt-2 p-2 bg-primary/5 border border-primary/20 rounded-md overflow-hidden">
<label className="text-xs text-muted-foreground block mb-1">

View File

@@ -1,7 +1,17 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { IconCheck, IconDownload, IconEdit, IconEye, IconFile, IconTrash, IconX } from "@tabler/icons-react";
import {
IconCheck,
IconChevronDown,
IconClipboardCopy,
IconDownload,
IconEdit,
IconEye,
IconFile,
IconTrash,
IconX,
} from "@tabler/icons-react";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useTranslations } from "next-intl";
@@ -10,22 +20,37 @@ import { toast } from "sonner";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
copyReverseShareFileToUserFiles,
deleteReverseShareFile,
downloadReverseShareFile,
updateReverseShareFile,
} from "@/http/endpoints/reverse-shares";
import type { ReverseShareFile } from "@/http/endpoints/reverse-shares/types";
import { getFileIcon } from "@/utils/file-icons";
import { truncateFileName } from "@/utils/file-utils";
import { ReverseShare } from "../hooks/use-reverse-shares";
import { ReverseShareFilePreviewModal } from "./reverse-share-file-preview-modal";
// Types
interface EditingState {
fileId: string;
field: string;
@@ -36,7 +61,6 @@ interface HoverState {
field: string;
}
// Custom Hooks
function useFileEdit() {
const [editingFile, setEditingFile] = useState<EditingState | null>(null);
const [editValue, setEditValue] = useState("");
@@ -74,7 +98,6 @@ function useFileEdit() {
};
}
// Utility Functions
const formatFileSize = (sizeString: string) => {
const sizeInBytes = parseInt(sizeString);
if (sizeInBytes === 0) return "0 B";
@@ -102,6 +125,9 @@ const getFileNameWithoutExtension = (fileName: string) => {
};
const getSenderDisplay = (file: ReverseShareFile, t: any) => {
if (file.uploaderName && file.uploaderEmail) {
return `${file.uploaderName} (${file.uploaderEmail})`;
}
if (file.uploaderName) return file.uploaderName;
if (file.uploaderEmail) return file.uploaderEmail;
return t("reverseShares.components.fileRow.anonymous");
@@ -122,7 +148,6 @@ const getSenderInitials = (file: ReverseShareFile) => {
return "?";
};
// Components
interface EditableFieldProps {
file: ReverseShareFile;
field: "name" | "description";
@@ -252,6 +277,8 @@ interface FileRowProps {
editValue: string;
inputRef: React.RefObject<HTMLInputElement | null>;
hoveredFile: HoverState | null;
copyingFile: string | null;
isSelected: boolean;
onStartEdit: (fileId: string, field: string, currentValue: string) => void;
onSaveEdit: () => void;
onCancelEdit: () => void;
@@ -261,6 +288,8 @@ interface FileRowProps {
onPreview: (file: ReverseShareFile) => void;
onDownload: (file: ReverseShareFile) => void;
onDelete: (file: ReverseShareFile) => void;
onCopy: (file: ReverseShareFile) => void;
onSelectFile: (fileId: string, checked: boolean) => void;
}
function FileRow({
@@ -269,6 +298,8 @@ function FileRow({
editValue,
inputRef,
hoveredFile,
copyingFile,
isSelected,
onStartEdit,
onSaveEdit,
onCancelEdit,
@@ -278,12 +309,21 @@ function FileRow({
onPreview,
onDownload,
onDelete,
onCopy,
onSelectFile,
}: FileRowProps) {
const t = useTranslations();
const { icon: FileIcon, color } = getFileIcon(file.name);
return (
<TableRow key={file.id}>
<TableCell>
<Checkbox
checked={isSelected}
onCheckedChange={(checked: boolean) => onSelectFile(file.id, checked)}
aria-label={t("reverseShares.modals.receivedFiles.selectFile", { fileName: file.name })}
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<FileIcon className={`h-8 w-8 ${color} flex-shrink-0`} />
@@ -331,12 +371,12 @@ function FileRow({
</div>
</TableCell>
<TableCell className="font-mono text-sm">{formatFileSize(file.size)}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Avatar className="h-6 w-6">
<TableCell className="max-w-[200px]">
<div className="flex items-center gap-2 min-w-0">
<Avatar className="h-6 w-6 flex-shrink-0">
<AvatarFallback className="text-xs">{getSenderInitials(file)}</AvatarFallback>
</Avatar>
<span className="text-sm truncate" title={getSenderDisplay(file, t)}>
<span className="text-sm truncate min-w-0" title={getSenderDisplay(file, t)}>
{getSenderDisplay(file, t)}
</span>
</div>
@@ -352,6 +392,24 @@ function FileRow({
>
<IconEye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onCopy(file)}
disabled={copyingFile === file.id}
title={
copyingFile === file.id
? t("reverseShares.components.fileActions.copying")
: t("reverseShares.components.fileActions.copyToMyFiles")
}
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50 disabled:opacity-50"
>
{copyingFile === file.id ? (
<div className="animate-spin rounded-full h-4 w-4 border-2 border-blue-600 border-t-transparent"></div>
) : (
<IconClipboardCopy className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
@@ -393,9 +451,20 @@ export function ReceivedFilesModal({
const t = useTranslations();
const [previewFile, setPreviewFile] = useState<ReverseShareFile | null>(null);
const [hoveredFile, setHoveredFile] = useState<HoverState | null>(null);
const [copyingFile, setCopyingFile] = useState<string | null>(null);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [bulkCopying, setBulkCopying] = useState(false);
const [bulkDeleting, setBulkDeleting] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [filesToDeleteBulk, setFilesToDeleteBulk] = useState<ReverseShareFile[]>([]);
const { editingFile, editValue, setEditValue, inputRef, startEdit, cancelEdit } = useFileEdit();
// Clear selections when files change
useEffect(() => {
setSelectedFiles(new Set());
}, [reverseShare?.files]);
const getTotalSize = () => {
if (!reverseShare?.files) return "0 B";
const totalBytes = reverseShare.files.reduce((acc, file) => acc + parseInt(file.size), 0);
@@ -474,10 +543,33 @@ export function ReceivedFilesModal({
await onRefresh();
}
toast.success("Arquivo excluído com sucesso");
toast.success(t("reverseShares.modals.receivedFiles.deleteSuccess"));
} catch (error) {
console.error("Error deleting file:", error);
toast.error("Erro ao excluir arquivo");
toast.error(t("reverseShares.modals.receivedFiles.deleteError"));
}
};
const handleCopyFile = async (file: ReverseShareFile) => {
try {
setCopyingFile(file.id);
await copyReverseShareFileToUserFiles(file.id);
toast.success(t("reverseShares.modals.receivedFiles.copySuccess"));
} catch (error: any) {
console.error("Error copying file:", error);
if (error.response?.data?.error) {
const errorMessage = error.response.data.error;
if (errorMessage.includes("File size exceeds") || errorMessage.includes("Insufficient storage")) {
toast.error(errorMessage);
} else {
toast.error(t("reverseShares.modals.receivedFiles.copyError"));
}
} else {
toast.error(t("reverseShares.modals.receivedFiles.copyError"));
}
} finally {
setCopyingFile(null);
}
};
@@ -493,6 +585,176 @@ export function ReceivedFilesModal({
const files = reverseShare.files || [];
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedFiles(new Set(files.map((file) => file.id)));
} else {
setSelectedFiles(new Set());
}
};
const handleSelectFile = (fileId: string, checked: boolean) => {
const newSelected = new Set(selectedFiles);
if (checked) {
newSelected.add(fileId);
} else {
newSelected.delete(fileId);
}
setSelectedFiles(newSelected);
};
const getSelectedFileObjects = () => {
return files.filter((file) => selectedFiles.has(file.id));
};
const isAllSelected = files.length > 0 && selectedFiles.size === files.length;
const handleBulkDownload = async () => {
const selectedFileObjects = getSelectedFileObjects();
if (selectedFileObjects.length === 0) return;
try {
toast.promise(
(async () => {
const JSZip = (await import("jszip")).default;
const zip = new JSZip();
const downloadPromises = selectedFileObjects.map(async (file) => {
try {
const response = await downloadReverseShareFile(file.id);
const downloadUrl = response.data.url;
const fileResponse = await fetch(downloadUrl);
if (!fileResponse.ok) {
throw new Error(`Failed to download ${file.name}`);
}
const blob = await fileResponse.blob();
zip.file(file.name, blob);
} catch (error) {
console.error(`Error downloading file ${file.name}:`, error);
throw error;
}
});
await Promise.all(downloadPromises);
const zipBlob = await zip.generateAsync({ type: "blob" });
const zipName = `${reverseShare.name || t("reverseShares.defaultLinkName")}_files.zip`;
const url = URL.createObjectURL(zipBlob);
const a = document.createElement("a");
a.href = url;
a.download = zipName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// Clear selections after successful download
setSelectedFiles(new Set());
})(),
{
loading: t("shareManager.creatingZip"),
success: t("shareManager.zipDownloadSuccess"),
error: t("shareManager.zipDownloadError"),
}
);
} catch (error) {
console.error("Error creating ZIP:", error);
}
};
const handleBulkCopyToMyFiles = async () => {
const selectedFileObjects = getSelectedFileObjects();
if (selectedFileObjects.length === 0) return;
toast.promise(
(async () => {
setBulkCopying(true);
try {
const copyPromises = selectedFileObjects.map(async (file) => {
try {
await copyReverseShareFileToUserFiles(file.id);
} catch (error: any) {
console.error(`Error copying file ${file.name}:`, error);
throw new Error(`Failed to copy ${file.name}: ${error.response?.data?.error || error.message}`);
}
});
await Promise.all(copyPromises);
// Clear selections after successful copy
setSelectedFiles(new Set());
} finally {
setBulkCopying(false);
}
})(),
{
loading: t("reverseShares.modals.receivedFiles.bulkCopyProgress", { count: selectedFileObjects.length }),
success: t("reverseShares.modals.receivedFiles.bulkCopySuccess", { count: selectedFileObjects.length }),
error: (error: any) => {
if (error.message.includes("File size exceeds") || error.message.includes("Insufficient storage")) {
return error.message;
} else {
return t("reverseShares.modals.receivedFiles.copyError");
}
},
}
);
};
const handleBulkDelete = () => {
const selectedFileObjects = getSelectedFileObjects();
if (selectedFileObjects.length === 0) return;
setFilesToDeleteBulk(selectedFileObjects);
setShowDeleteConfirm(true);
};
const confirmBulkDelete = async () => {
if (filesToDeleteBulk.length === 0) return;
setShowDeleteConfirm(false);
toast.promise(
(async () => {
setBulkDeleting(true);
try {
const deletePromises = filesToDeleteBulk.map(async (file) => {
try {
await deleteReverseShareFile(file.id);
} catch (error) {
console.error(`Error deleting file ${file.name}:`, error);
throw new Error(`Failed to delete ${file.name}`);
}
});
await Promise.all(deletePromises);
// Clear selections and refresh data
setSelectedFiles(new Set());
setFilesToDeleteBulk([]);
if (onRefresh) {
await onRefresh();
}
if (refreshReverseShare) {
await refreshReverseShare(reverseShare.id);
}
} finally {
setBulkDeleting(false);
}
})(),
{
loading: t("reverseShares.modals.receivedFiles.bulkDeleteProgress", { count: filesToDeleteBulk.length }),
success: t("reverseShares.modals.receivedFiles.bulkDeleteSuccess", { count: filesToDeleteBulk.length }),
error: "Error deleting selected files",
}
);
};
const showBulkActions = selectedFiles.size > 0;
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
@@ -519,6 +781,59 @@ export function ReceivedFilesModal({
<Separator />
{showBulkActions && (
<div className="flex items-center justify-between p-4 bg-muted/30 border rounded-lg">
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-foreground">
{t("reverseShares.modals.receivedFiles.bulkActions.selected", { count: selectedFiles.size })}
</span>
</div>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="default" size="sm" className="gap-2">
{t("reverseShares.modals.receivedFiles.bulkActions.actions")}
<IconChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuItem className="cursor-pointer py-2" onClick={handleBulkDownload}>
<IconDownload className="h-4 w-4" />
{t("reverseShares.modals.receivedFiles.bulkActions.download")}
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={handleBulkCopyToMyFiles}
disabled={bulkCopying}
>
{bulkCopying ? (
<div className="animate-spin rounded-full h-4 w-4 border-2 border-blue-600 border-t-transparent"></div>
) : (
<IconClipboardCopy className="h-4 w-4" />
)}
{t("reverseShares.modals.receivedFiles.bulkActions.copyToMyFiles")}
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer py-2 text-destructive focus:text-destructive"
onClick={handleBulkDelete}
disabled={bulkDeleting}
>
{bulkDeleting ? (
<div className="animate-spin rounded-full h-4 w-4 border-2 border-red-600 border-t-transparent"></div>
) : (
<IconTrash className="h-4 w-4" />
)}
{t("reverseShares.modals.receivedFiles.bulkActions.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button variant="outline" size="sm" onClick={() => setSelectedFiles(new Set())}>
{t("common.cancel")}
</Button>
</div>
</div>
)}
{files.length === 0 ? (
<div className="flex flex-col items-center justify-center flex-1 gap-4 py-12">
<div className="w-16 h-16 bg-muted rounded-full flex items-center justify-center">
@@ -536,6 +851,13 @@ export function ReceivedFilesModal({
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]">
<Checkbox
checked={isAllSelected}
onCheckedChange={handleSelectAll}
aria-label={t("reverseShares.modals.receivedFiles.selectAll")}
/>
</TableHead>
<TableHead>{t("reverseShares.modals.receivedFiles.columns.file")}</TableHead>
<TableHead>{t("reverseShares.modals.receivedFiles.columns.size")}</TableHead>
<TableHead>{t("reverseShares.modals.receivedFiles.columns.sender")}</TableHead>
@@ -554,6 +876,8 @@ export function ReceivedFilesModal({
editValue={editValue}
inputRef={inputRef}
hoveredFile={hoveredFile}
copyingFile={copyingFile}
isSelected={selectedFiles.has(file.id)}
onStartEdit={startEdit}
onSaveEdit={saveEdit}
onCancelEdit={cancelEdit}
@@ -563,6 +887,8 @@ export function ReceivedFilesModal({
onPreview={handlePreview}
onDownload={handleDownload}
onDelete={handleDeleteFile}
onCopy={handleCopyFile}
onSelectFile={handleSelectFile}
/>
))}
</TableBody>
@@ -573,6 +899,47 @@ export function ReceivedFilesModal({
</DialogContent>
</Dialog>
{/* Delete Confirmation Modal */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("reverseShares.modals.receivedFiles.bulkDeleteConfirmTitle")}</DialogTitle>
<DialogDescription>
{t("reverseShares.modals.receivedFiles.bulkDeleteConfirmMessage", { count: filesToDeleteBulk.length })}
</DialogDescription>
</DialogHeader>
<div className="max-h-48 overflow-y-auto border rounded-lg p-2">
<div className="space-y-1">
{filesToDeleteBulk.map((file) => {
const { icon: FileIcon, color } = getFileIcon(file.name);
const displayName = truncateFileName(file.name);
return (
<div key={file.id} className="flex items-center gap-2 p-2 bg-muted/20 rounded text-sm min-w-0">
<FileIcon className={`h-4 w-4 ${color} flex-shrink-0`} />
<span className="flex-1 break-all" title={file.name}>
{displayName}
</span>
</div>
);
})}
</div>
</div>
<DialogFooter className="flex gap-2">
<Button variant="outline" onClick={() => setShowDeleteConfirm(false)}>
{t("common.cancel")}
</Button>
<Button variant="destructive" onClick={confirmBulkDelete} disabled={bulkDeleting}>
{bulkDeleting ? (
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent mr-2" />
) : null}
{t("reverseShares.modals.receivedFiles.bulkDeleteConfirmButton", { count: filesToDeleteBulk.length })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{previewFile && (
<ReverseShareFilePreviewModal
isOpen={!!previewFile}

View File

@@ -32,6 +32,15 @@ export function ReceivedFilesSection({ files, onFileDeleted }: ReceivedFilesSect
const t = useTranslations();
const [previewFile, setPreviewFile] = useState<ReverseShareFile | null>(null);
const getSenderDisplay = (file: ReverseShareFile) => {
if (file.uploaderName && file.uploaderEmail) {
return `${file.uploaderName}(${file.uploaderEmail})`;
}
if (file.uploaderName) return file.uploaderName;
if (file.uploaderEmail) return file.uploaderEmail;
return t("reverseShares.components.fileRow.anonymous");
};
const formatFileSize = (size: string | number | null) => {
if (!size) return "0 B";
const sizeInBytes = typeof size === "string" ? parseInt(size) : size;
@@ -119,10 +128,10 @@ export function ReceivedFilesSection({ files, onFileDeleted }: ReceivedFilesSect
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{formatFileSize(file.size)}</span>
{file.uploaderEmail && (
{(file.uploaderName || file.uploaderEmail) && (
<>
<span></span>
<span title={file.uploaderEmail}>{file.uploaderName || file.uploaderEmail}</span>
<span title={getSenderDisplay(file)}>{getSenderDisplay(file)}</span>
</>
)}
<span></span>

View File

@@ -103,7 +103,6 @@ export function ReverseShareCard({
const { field } = editingField;
let processedValue: string | number | null | boolean = editValue;
// Processar valores específicos
if (field === "isActive") {
processedValue = editValue === "true";
}
@@ -244,6 +243,16 @@ export function ReverseShareCard({
<IconEye className="h-3 w-3" />
</Button>
<Button
variant="outline"
size="sm"
className="h-6 w-6 p-0 hover:bg-background/80 rounded-sm"
onClick={() => onEdit(reverseShare)}
title={t("reverseShares.actions.edit")}
>
<IconEdit className="h-3 w-3" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-6 w-6 p-0 hover:bg-background/80 rounded-sm">
@@ -271,7 +280,7 @@ export function ReverseShareCard({
)}
<DropdownMenuItem onClick={() => onEdit(reverseShare)}>
<IconFileText className="h-4 w-4 mr-2" />
<IconEdit className="h-4 w-4 mr-2" />
{t("reverseShares.actions.edit")}
</DropdownMenuItem>

View File

@@ -1,17 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { IconDownload } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { CustomAudioPlayer } from "@/components/audio/custom-audio-player";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { downloadReverseShareFile } from "@/http/endpoints/reverse-shares";
import { getFileIcon } from "@/utils/file-icons";
import { FilePreviewModal } from "@/components/modals/file-preview-modal";
interface ReverseShareFilePreviewModalProps {
isOpen: boolean;
@@ -21,326 +10,18 @@ interface ReverseShareFilePreviewModalProps {
name: string;
objectName: string;
extension?: string;
};
} | null;
}
export function ReverseShareFilePreviewModal({ isOpen, onClose, file }: ReverseShareFilePreviewModalProps) {
const t = useTranslations();
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [videoBlob, setVideoBlob] = useState<string | null>(null);
const [pdfAsBlob, setPdfAsBlob] = useState(false);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [pdfLoadFailed, setPdfLoadFailed] = useState(false);
const [isLoadingPreview, setIsLoadingPreview] = useState(false);
if (!file) return null;
useEffect(() => {
if (isOpen && file.id && !isLoadingPreview) {
setIsLoading(true);
setPreviewUrl(null);
setVideoBlob(null);
setPdfAsBlob(false);
setDownloadUrl(null);
setPdfLoadFailed(false);
loadPreview();
}
}, [file.id, isOpen]);
useEffect(() => {
return () => {
if (previewUrl && previewUrl.startsWith("blob:")) {
URL.revokeObjectURL(previewUrl);
}
if (videoBlob && videoBlob.startsWith("blob:")) {
URL.revokeObjectURL(videoBlob);
}
};
}, [previewUrl, videoBlob]);
useEffect(() => {
if (!isOpen) {
if (previewUrl && previewUrl.startsWith("blob:")) {
URL.revokeObjectURL(previewUrl);
setPreviewUrl(null);
}
if (videoBlob && videoBlob.startsWith("blob:")) {
URL.revokeObjectURL(videoBlob);
setVideoBlob(null);
}
}
}, [isOpen]);
const loadPreview = async () => {
if (!file.id || isLoadingPreview) return;
setIsLoadingPreview(true);
try {
const response = await downloadReverseShareFile(file.id);
const url = response.data.url;
setDownloadUrl(url);
const fileType = getFileType();
if (fileType === "video") {
await loadVideoPreview(url);
} else if (fileType === "audio") {
await loadAudioPreview(url);
} else if (fileType === "pdf") {
await loadPdfPreview(url);
} else {
setPreviewUrl(url);
}
} catch (error) {
console.error("Failed to load preview:", error);
toast.error(t("filePreview.loadError"));
} finally {
setIsLoading(false);
setIsLoadingPreview(false);
}
const adaptedFile = {
name: file.name,
objectName: file.objectName,
type: file.extension,
id: file.id,
};
const loadVideoPreview = async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
setVideoBlob(blobUrl);
} catch (error) {
console.error("Failed to load video as blob:", error);
setPreviewUrl(url);
}
};
const loadAudioPreview = async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
setPreviewUrl(blobUrl);
} catch (error) {
console.error("Failed to load audio as blob:", error);
setPreviewUrl(url);
}
};
const loadPdfPreview = async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
const finalBlob = new Blob([blob], { type: "application/pdf" });
const blobUrl = URL.createObjectURL(finalBlob);
setPreviewUrl(blobUrl);
setPdfAsBlob(true);
} catch (error) {
console.error("Failed to load PDF as blob:", error);
setPreviewUrl(url);
setTimeout(() => {
if (!pdfLoadFailed && !pdfAsBlob) {
handlePdfLoadError();
}
}, 4000);
}
};
const handlePdfLoadError = async () => {
if (pdfLoadFailed || pdfAsBlob) return;
setPdfLoadFailed(true);
if (downloadUrl) {
setTimeout(() => {
loadPdfPreview(downloadUrl);
}, 500);
}
};
const handleDownload = async () => {
try {
let downloadUrlToUse = downloadUrl;
if (!downloadUrlToUse) {
const response = await downloadReverseShareFile(file.id);
downloadUrlToUse = response.data.url;
}
const fileResponse = await fetch(downloadUrlToUse);
if (!fileResponse.ok) {
throw new Error(`Download failed: ${fileResponse.status} - ${fileResponse.statusText}`);
}
const blob = await fileResponse.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error) {
toast.error(t("filePreview.downloadError"));
console.error("Download error:", error);
}
};
const getFileType = () => {
const extension = file.extension?.toLowerCase() || file.name.split(".").pop()?.toLowerCase();
if (extension === "pdf") return "pdf";
if (["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "tiff"].includes(extension || "")) return "image";
if (["mp3", "wav", "ogg", "m4a", "aac", "flac"].includes(extension || "")) return "audio";
if (["mp4", "webm", "ogg", "mov", "avi", "mkv", "wmv", "flv", "m4v"].includes(extension || "")) return "video";
return "other";
};
const renderPreview = () => {
const fileType = getFileType();
const { icon: FileIcon, color } = getFileIcon(file.name);
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
<p className="text-muted-foreground">{t("filePreview.loading")}</p>
</div>
);
}
const mediaUrl = fileType === "video" ? videoBlob : previewUrl;
if (!mediaUrl && (fileType === "video" || fileType === "audio")) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<FileIcon className={`h-12 w-12 ${color}`} />
<p className="text-muted-foreground">{t("filePreview.notAvailable")}</p>
<p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
</div>
);
}
if (!previewUrl && fileType !== "video") {
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<FileIcon className={`h-12 w-12 ${color}`} />
<p className="text-muted-foreground">{t("filePreview.notAvailable")}</p>
<p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
</div>
);
}
switch (fileType) {
case "pdf":
return (
<ScrollArea className="w-full">
<div className="w-full min-h-[600px] border rounded-lg overflow-hidden bg-card">
{pdfAsBlob ? (
<iframe
src={`${previewUrl!}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
className="w-full h-full min-h-[600px]"
title={file.name}
style={{ border: "none" }}
/>
) : pdfLoadFailed ? (
<div className="flex items-center justify-center h-full min-h-[600px]">
<div className="flex flex-col items-center gap-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
<p className="text-muted-foreground">{t("filePreview.loadingAlternative")}</p>
</div>
</div>
) : (
<div className="w-full h-full min-h-[600px] relative">
<object
data={`${previewUrl!}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
type="application/pdf"
className="w-full h-full min-h-[600px]"
onError={handlePdfLoadError}
>
<iframe
src={`${previewUrl!}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
className="w-full h-full min-h-[600px]"
title={file.name}
style={{ border: "none" }}
onError={handlePdfLoadError}
/>
</object>
</div>
)}
</div>
</ScrollArea>
);
case "image":
return (
<AspectRatio ratio={16 / 9} className="bg-muted">
<img src={previewUrl!} alt={file.name} className="object-contain w-full h-full rounded-md" />
</AspectRatio>
);
case "audio":
return (
<div className="flex flex-col items-center justify-center gap-6 py-4">
<CustomAudioPlayer src={mediaUrl!} />
</div>
);
case "video":
return (
<div className="flex flex-col items-center justify-center gap-4 py-6">
<div className="w-full max-w-4xl">
<video controls className="w-full rounded-lg" preload="metadata" style={{ maxHeight: "70vh" }}>
<source src={mediaUrl!} />
{t("filePreview.videoNotSupported")}
</video>
</div>
</div>
);
default:
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<FileIcon className={`text-6xl ${color}`} />
<p className="text-muted-foreground">{t("filePreview.notAvailable")}</p>
<p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
</div>
);
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{(() => {
const FileIcon = getFileIcon(file.name).icon;
return <FileIcon size={24} />;
})()}
<span className="truncate">{file.name}</span>
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto">{renderPreview()}</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{t("common.close")}
</Button>
<Button onClick={handleDownload}>
<IconDownload className="h-4 w-4" />
{t("common.download")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
return <FilePreviewModal isOpen={isOpen} onClose={onClose} file={adaptedFile} isReverseShare={true} />;
}

View File

@@ -18,7 +18,6 @@ import type {
UpdateReverseShareBody,
} from "@/http/endpoints/reverse-shares/types";
// Tipo baseado na resposta da API
export type ReverseShare = ListUserReverseSharesResult["data"]["reverseShares"][0];
export function useReverseShares() {
@@ -62,7 +61,6 @@ export function useReverseShares() {
setReverseShares(sortedReverseShares);
// Atualiza o reverseShare específico que está sendo visualizado
const updatedReverseShare = allReverseShares.find((rs) => rs.id === id);
if (updatedReverseShare) {
if (reverseShareToViewFiles && reverseShareToViewFiles.id === id) {
@@ -83,13 +81,11 @@ export function useReverseShares() {
const response = await createReverseShare(data);
const newReverseShare = response.data.reverseShare;
// Adiciona ao estado local
setReverseShares((prev) => [newReverseShare as ReverseShare, ...prev]);
toast.success(t("reverseShares.messages.createSuccess"));
setIsCreateModalOpen(false);
// Automaticamente abre o modal de alias para o reverse share criado
setReverseShareToGenerateLink(newReverseShare as ReverseShare);
return newReverseShare;
@@ -113,7 +109,6 @@ export function useReverseShares() {
updatedAt: new Date().toISOString(),
};
// Atualiza o estado local
setReverseShares((prev) =>
prev.map((rs) =>
rs.id === reverseShareId
@@ -125,7 +120,6 @@ export function useReverseShares() {
)
);
// Atualiza o reverseShare que está sendo visualizado no modal de detalhes
if (reverseShareToViewDetails && reverseShareToViewDetails.id === reverseShareId) {
setReverseShareToViewDetails({
...reverseShareToViewDetails,
@@ -145,7 +139,6 @@ export function useReverseShares() {
try {
await deleteReverseShare(reverseShare.id);
// Remove do estado local
setReverseShares((prev) => prev.filter((rs) => rs.id !== reverseShare.id));
toast.success(t("reverseShares.messages.deleteSuccess"));
@@ -163,7 +156,6 @@ export function useReverseShares() {
const response = await updateReverseShare(data);
const updatedReverseShare = response.data.reverseShare;
// Atualiza o estado local
setReverseShares((prev) =>
prev.map((rs) => (rs.id === data.id ? ({ ...rs, ...updatedReverseShare } as ReverseShare) : rs))
);
@@ -186,12 +178,10 @@ export function useReverseShares() {
const response = await updateReverseSharePassword(id, payload);
const updatedReverseShare = response.data.reverseShare;
// Atualiza o estado local
setReverseShares((prev) =>
prev.map((rs) => (rs.id === id ? ({ ...rs, ...updatedReverseShare } as ReverseShare) : rs))
);
// Atualiza o reverseShare que está sendo visualizado no modal de detalhes
if (reverseShareToViewDetails && reverseShareToViewDetails.id === id) {
setReverseShareToViewDetails({ ...reverseShareToViewDetails, ...updatedReverseShare } as ReverseShare);
}
@@ -208,12 +198,10 @@ export function useReverseShares() {
const response = await updateReverseShare(payload);
const updatedReverseShare = response.data.reverseShare;
// Atualiza o estado local
setReverseShares((prev) =>
prev.map((rs) => (rs.id === id ? ({ ...rs, ...updatedReverseShare } as ReverseShare) : rs))
);
// Atualiza o reverseShare que está sendo visualizado no modal de detalhes
if (reverseShareToViewDetails && reverseShareToViewDetails.id === id) {
setReverseShareToViewDetails({ ...reverseShareToViewDetails, ...updatedReverseShare } as ReverseShare);
}
@@ -232,12 +220,10 @@ export function useReverseShares() {
const response = await updateReverseShare(payload);
const updatedReverseShare = response.data.reverseShare;
// Atualiza o estado local
setReverseShares((prev) =>
prev.map((rs) => (rs.id === id ? ({ ...rs, ...updatedReverseShare } as ReverseShare) : rs))
);
// Atualiza o reverseShare que está sendo visualizado no modal de detalhes
if (reverseShareToViewDetails && reverseShareToViewDetails.id === id) {
setReverseShareToViewDetails({ ...reverseShareToViewDetails, ...updatedReverseShare } as ReverseShare);
}
@@ -256,7 +242,6 @@ export function useReverseShares() {
loadReverseShares();
}, []);
// Sincroniza o reverseShareToViewDetails com a lista atualizada
useEffect(() => {
if (reverseShareToViewDetails) {
const updatedReverseShare = reverseShares.find((rs) => rs.id === reverseShareToViewDetails.id);
@@ -266,7 +251,6 @@ export function useReverseShares() {
}
}, [reverseShares, reverseShareToViewDetails?.id]);
// Sincroniza o reverseShareToViewFiles com a lista atualizada
useEffect(() => {
if (reverseShareToViewFiles) {
const updatedReverseShare = reverseShares.find((rs) => rs.id === reverseShareToViewFiles.id);

View File

@@ -1,22 +1,33 @@
import { IconShare } from "@tabler/icons-react";
import { IconDownload, IconShare } from "@tabler/icons-react";
import { format } from "date-fns";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { ShareDetailsProps } from "../types";
import { ShareFilesTable } from "./files-table";
export function ShareDetails({ share, onDownload }: ShareDetailsProps) {
export function ShareDetails({ share, onDownload, onBulkDownload }: ShareDetailsProps) {
const t = useTranslations();
const hasMultipleFiles = share.files && share.files.length > 1;
return (
<Card>
<CardContent>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<IconShare className="w-6 h-6 text-muted-foreground" />
<h1 className="text-2xl font-semibold">{share.name || t("share.details.untitled")}</h1>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<IconShare className="w-6 h-6 text-muted-foreground" />
<h1 className="text-2xl font-semibold">{share.name || t("share.details.untitled")}</h1>
</div>
{hasMultipleFiles && (
<Button onClick={onBulkDownload} className="flex items-center gap-2">
<IconDownload className="w-4 h-4" />
{t("share.downloadAll")}
</Button>
)}
</div>
{share.description && <p className="text-muted-foreground">{share.description}</p>}
<div className="flex gap-4 text-sm text-muted-foreground">

View File

@@ -1,5 +1,6 @@
import Image from "next/image";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { LanguageSwitcher } from "@/components/general/language-switcher";
import { ModeToggle } from "@/components/general/mode-toggle";
@@ -7,12 +8,13 @@ import { useAppInfo } from "@/contexts/app-info-context";
export function ShareHeader() {
const { appName, appLogo } = useAppInfo();
const t = useTranslations();
return (
<header className="w-full px-6 border-b border-default-200/50 bg-background/70 backdrop-blur-sm">
<div className="mx-auto max-w-5xl sm:p-0 h-16 flex items-center justify-between">
<Link className="flex items-center gap-2" href="/">
{appLogo && <img alt="App Logo" className="h-8 w-8 object-contain rounded" src={appLogo} />}
{appLogo && <img alt={t("logo.labels.appLogo")} className="h-8 w-8 object-contain rounded" src={appLogo} />}
<p className="font-bold text-2xl text-foreground">{appName}</p>
</Link>
<div className="flex items-center gap-2">

View File

@@ -69,6 +69,62 @@ export function usePublicShare() {
}
};
const handleBulkDownload = async () => {
if (!share || !share.files || share.files.length === 0) {
toast.error(t("shareManager.noFilesToDownload"));
return;
}
try {
toast.promise(
(async () => {
const JSZip = (await import("jszip")).default;
const zip = new JSZip();
const downloadPromises = share.files.map(async (file) => {
try {
const encodedObjectName = encodeURIComponent(file.objectName);
const downloadResponse = await getDownloadUrl(encodedObjectName);
const downloadUrl = downloadResponse.data.url;
const response = await fetch(downloadUrl);
if (!response.ok) {
throw new Error(`Failed to download ${file.name}`);
}
const blob = await response.blob();
zip.file(file.name, blob);
} catch (error) {
console.error(`Error downloading file ${file.name}:`, error);
throw error;
}
});
await Promise.all(downloadPromises);
const zipBlob = await zip.generateAsync({ type: "blob" });
const zipName = `${share.name || t("shareManager.defaultShareName")}.zip`;
const url = URL.createObjectURL(zipBlob);
const a = document.createElement("a");
a.href = url;
a.download = zipName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
})(),
{
loading: t("shareManager.creatingZip"),
success: t("shareManager.zipDownloadSuccess"),
error: t("shareManager.zipDownloadError"),
}
);
} catch (error) {
console.error("Error creating ZIP:", error);
}
};
const downloadFile = async (url: string, fileName: string) => {
const fileResponse = await fetch(url);
const blob = await fileResponse.blob();
@@ -97,5 +153,6 @@ export function usePublicShare() {
setPassword,
handlePasswordSubmit,
handleDownload,
handleBulkDownload,
};
}

View File

@@ -18,6 +18,7 @@ export default function PublicSharePage() {
setPassword,
handlePasswordSubmit,
handleDownload,
handleBulkDownload,
} = usePublicShare();
if (isLoading) {
@@ -31,7 +32,7 @@ export default function PublicSharePage() {
<main className="flex-1 container mx-auto px-6 py-8">
<div className="max-w-5xl mx-auto space-y-6">
{!isPasswordModalOpen && !share && <ShareNotFound />}
{share && <ShareDetails share={share} onDownload={handleDownload} />}
{share && <ShareDetails share={share} onDownload={handleDownload} onBulkDownload={handleBulkDownload} />}
</div>
</main>

View File

@@ -24,4 +24,5 @@ export interface PasswordModalProps {
export interface ShareDetailsProps {
share: GetShareByAlias200Share;
onDownload: (objectName: string, fileName: string) => Promise<void>;
onBulkDownload?: () => Promise<void>;
}

View File

@@ -7,10 +7,8 @@ import { GenerateShareLinkModal } from "@/components/modals/generate-share-link-
import { ShareActionsModals } from "@/components/modals/share-actions-modals";
import { ShareDetailsModal } from "@/components/modals/share-details-modal";
import { ShareExpirationModal } from "@/components/modals/share-expiration-modal";
import { ShareFileModal } from "@/components/modals/share-file-modal";
import { ShareMultipleFilesModal } from "@/components/modals/share-multiple-files-modal";
import { ShareSecurityModal } from "@/components/modals/share-security-modal";
import { UploadFileModal } from "@/components/modals/upload-file-modal";
import { SharesModalsProps } from "../types";
export function SharesModals({
@@ -107,12 +105,6 @@ export function SharesModals({
onSuccess();
}}
/>
<UploadFileModal
isOpen={!!shareManager.shareToEdit}
onClose={() => shareManager.setShareToEdit(null)}
onSuccess={onSuccess}
/>
</>
);
}

View File

@@ -9,6 +9,8 @@ export function SharesTableContainer({ shares, onCopyLink, onCreateShare, shareM
onCopyLink={onCopyLink}
onDelete={shareManager.setShareToDelete}
onBulkDelete={shareManager.handleBulkDelete}
onBulkDownload={shareManager.handleBulkDownload}
onDownloadShareFiles={shareManager.handleDownloadShareFiles}
onEdit={shareManager.setShareToEdit}
onUpdateName={shareManager.handleUpdateName}
onUpdateDescription={shareManager.handleUpdateDescription}

View File

@@ -15,26 +15,6 @@ export interface SharesTableContainerProps {
shareManager: any;
}
export interface ShareManager {
shareToDelete: ListUserShares200SharesItem | null;
shareToEdit: ListUserShares200SharesItem | null;
shareToManageFiles: ListUserShares200SharesItem | null;
shareToManageRecipients: 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 interface SharesModalsProps {
isCreateModalOpen: boolean;
onCloseCreateModal: () => void;

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
const cookieHeader = req.headers.get("cookie");
const body = await req.text();
const apiRes = await fetch(`${process.env.API_BASE_URL}/app/test-smtp`, {
method: "POST",
headers: {
cookie: cookieHeader || "",
"Content-Type": "application/json",
},
body: body || "{}",
redirect: "manual",
});
const resBody = await apiRes.text();
const res = new NextResponse(resBody, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
const setCookie = apiRes.headers.getSetCookie?.() || [];
if (setCookie.length > 0) {
res.headers.set("Set-Cookie", setCookie.join(","));
}
return res;
}

View File

@@ -4,7 +4,6 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ obje
const { objectPath } = await params;
const cookieHeader = req.headers.get("cookie");
// Reconstruct the full objectName from the path segments
const objectName = objectPath.join("/");
const url = `${process.env.API_BASE_URL}/files/${encodeURIComponent(objectName)}/download`;
@@ -17,12 +16,23 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ obje
redirect: "manual",
});
const resBody = await apiRes.text();
if (!apiRes.ok) {
const resBody = await apiRes.text();
return new NextResponse(resBody, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
}
const res = new NextResponse(resBody, {
const res = new NextResponse(apiRes.body, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
"Content-Type": apiRes.headers.get("Content-Type") || "application/octet-stream",
"Content-Length": apiRes.headers.get("Content-Length") || "",
"Accept-Ranges": apiRes.headers.get("Accept-Ranges") || "",
"Content-Range": apiRes.headers.get("Content-Range") || "",
},
});

View File

@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest, { params }: { params: Promise<{ fileId: string }> }) {
const { fileId } = await params;
const cookieHeader = req.headers.get("cookie");
const apiRes = await fetch(`${process.env.API_BASE_URL}/reverse-shares/files/${fileId}/copy`, {
method: "POST",
headers: {
cookie: cookieHeader || "",
},
redirect: "manual",
});
const resBody = await apiRes.text();
const res = new NextResponse(resBody, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
const setCookie = apiRes.headers.getSetCookie?.() || [];
if (setCookie.length > 0) {
res.headers.set("Set-Cookie", setCookie.join(","));
}
return res;
}

View File

@@ -7,18 +7,28 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ file
const apiRes = await fetch(`${process.env.API_BASE_URL}/reverse-shares/files/${fileId}/download`, {
method: "GET",
headers: {
"Content-Type": "application/json",
cookie: cookieHeader || "",
},
redirect: "manual",
});
const resBody = await apiRes.text();
if (!apiRes.ok) {
const resBody = await apiRes.text();
return new NextResponse(resBody, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
}
const res = new NextResponse(resBody, {
const res = new NextResponse(apiRes.body, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
"Content-Type": apiRes.headers.get("Content-Type") || "application/octet-stream",
"Content-Length": apiRes.headers.get("Content-Length") || "",
"Accept-Ranges": apiRes.headers.get("Accept-Ranges") || "",
"Content-Range": apiRes.headers.get("Content-Range") || "",
},
});

View File

@@ -3,12 +3,15 @@ import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest, { params }: { params: Promise<{ shareId: string }> }) {
const cookieHeader = req.headers.get("cookie");
const { shareId } = await params;
const body = await req.text();
const apiRes = await fetch(`${process.env.API_BASE_URL}/shares/${shareId}/recipients/notify`, {
const apiRes = await fetch(`${process.env.API_BASE_URL}/shares/${shareId}/notify`, {
method: "POST",
headers: {
"Content-Type": "application/json",
cookie: cookieHeader || "",
},
body: body,
redirect: "manual",
});

View File

@@ -51,6 +51,8 @@ export function RecentShares({ shares, shareManager, onOpenCreateModal, onCopyLi
onCopyLink={onCopyLink}
onDelete={shareManager.setShareToDelete}
onBulkDelete={shareManager.handleBulkDelete}
onBulkDownload={shareManager.handleBulkDownload}
onDownloadShareFiles={shareManager.handleDownloadShareFiles}
onEdit={shareManager.setShareToEdit}
onUpdateName={shareManager.handleUpdateName}
onUpdateDescription={shareManager.handleUpdateDescription}

View File

@@ -1,14 +1,76 @@
import { IconDatabaseCog } from "@tabler/icons-react";
import { IconAlertCircle, IconDatabaseCog, IconRefresh } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import type { StorageUsageProps } from "../types";
import { formatStorageSize } from "../utils/format-storage-size";
export function StorageUsage({ diskSpace }: StorageUsageProps) {
export function StorageUsage({ diskSpace, diskSpaceError, onRetry }: StorageUsageProps) {
const t = useTranslations();
const getErrorMessage = (error: string) => {
switch (error) {
case "disk_detection_failed":
return t("storageUsage.errors.detectionFailed");
case "server_error":
return t("storageUsage.errors.serverError");
default:
return t("storageUsage.errors.unknown");
}
};
if (diskSpaceError) {
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-3 py-4">
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
<IconAlertCircle size={20} />
<span className="text-sm font-medium">{t("storageUsage.errors.title")}</span>
</div>
<p className="text-sm text-muted-foreground">{getErrorMessage(diskSpaceError)}</p>
{onRetry && (
<Button variant="outline" size="sm" onClick={onRetry} className="w-fit">
<IconRefresh size={16} className="mr-2" />
{t("storageUsage.retry")}
</Button>
)}
</div>
</div>
</CardContent>
</Card>
);
}
if (!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">
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
<div className="flex justify-between text-sm text-muted-foreground">
<span>{t("storageUsage.loading")}</span>
<span>{t("storageUsage.loading")}</span>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
return (
<Card className="w-full">
<CardContent className="">

View File

@@ -18,6 +18,7 @@ export function useDashboard() {
diskAvailableGB: number;
uploadAllowed: boolean;
} | null>(null);
const [diskSpaceError, setDiskSpaceError] = useState<string | null>(null);
const [recentFiles, setRecentFiles] = useState<any[]>([]);
const [recentShares, setRecentShares] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
@@ -34,24 +35,44 @@ export function useDashboard() {
const loadDashboardData = async () => {
try {
const [diskSpaceRes, filesRes, sharesRes] = await Promise.all([getDiskSpace(), listFiles(), listUserShares()]);
const loadDiskSpace = async () => {
try {
const diskSpaceRes = await getDiskSpace();
setDiskSpace(diskSpaceRes.data);
setDiskSpaceError(null);
} catch (error: any) {
console.warn("Failed to load disk space:", error);
setDiskSpace(null);
setDiskSpace(diskSpaceRes.data);
if (error.response?.status === 503 && error.response?.data?.code === "DISK_SPACE_DETECTION_FAILED") {
setDiskSpaceError("disk_detection_failed");
} else if (error.response?.status >= 500) {
setDiskSpaceError("server_error");
} else {
setDiskSpaceError("unknown_error");
}
}
};
const allFiles = filesRes.data.files || [];
const sortedFiles = [...allFiles].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
const loadFilesAndShares = async () => {
const [filesRes, sharesRes] = await Promise.all([listFiles(), listUserShares()]);
setRecentFiles(sortedFiles.slice(0, 5));
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()
);
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));
};
setRecentShares(sortedShares.slice(0, 5));
await Promise.allSettled([loadDiskSpace(), loadFilesAndShares()]);
} catch (error) {
console.error("Critical dashboard error:", error);
toast.error(t("dashboard.loadError"));
} finally {
setIsLoading(false);
@@ -76,6 +97,7 @@ export function useDashboard() {
return {
isLoading,
diskSpace,
diskSpaceError,
recentFiles,
recentShares,
modals: {

View File

@@ -19,6 +19,7 @@ export default function DashboardPage() {
const {
isLoading,
diskSpace,
diskSpaceError,
recentFiles,
recentShares,
modals,
@@ -32,6 +33,10 @@ export default function DashboardPage() {
return <LoadingScreen />;
}
const handleRetryDiskSpace = async () => {
await loadDashboardData();
};
return (
<ProtectedRoute>
<FileManagerLayout
@@ -40,7 +45,7 @@ export default function DashboardPage() {
showBreadcrumb={false}
title={t("dashboard.pageTitle")}
>
<StorageUsage diskSpace={diskSpace} />
<StorageUsage diskSpace={diskSpace} diskSpaceError={diskSpaceError} onRetry={handleRetryDiskSpace} />
<QuickAccessCards />
<div className="flex flex-col gap-6">

View File

@@ -24,6 +24,8 @@ export interface StorageUsageProps {
diskAvailableGB: number;
uploadAllowed: boolean;
} | null;
diskSpaceError?: string | null;
onRetry?: () => void;
}
export interface DashboardModalsProps {

View File

@@ -23,7 +23,7 @@ export function LoginForm({ error, isVisible, onToggleVisibility, onSubmit }: Lo
const form = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: "",
emailOrUsername: "",
password: "",
},
});
@@ -37,18 +37,18 @@ export function LoginForm({ error, isVisible, onToggleVisibility, onSubmit }: Lo
</p>
);
const renderEmailField = () => (
const renderEmailOrUsernameField = () => (
<FormField
control={form.control}
name="email"
name="emailOrUsername"
render={({ field }) => (
<FormItem>
<FormLabel>{t("login.emailLabel")}</FormLabel>
<FormLabel>{t("login.emailOrUsernameLabel")}</FormLabel>
<FormControl className="-mb-1">
<Input
{...field}
type="email"
placeholder={t("login.emailPlaceholder")}
type="text"
placeholder={t("login.emailOrUsernamePlaceholder")}
disabled={isSubmitting}
className="bg-transparent backdrop-blur-md"
/>
@@ -89,7 +89,7 @@ export function LoginForm({ error, isVisible, onToggleVisibility, onSubmit }: Lo
{renderErrorMessage()}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
{renderEmailField()}
{renderEmailOrUsernameField()}
{renderPasswordField()}
<Button className="w-full mt-4 cursor-pointer" variant="default" size="lg" type="submit">
{isSubmitting ? t("login.signingIn") : t("login.signIn")}

View File

@@ -12,7 +12,7 @@ import { getCurrentUser, login } from "@/http/endpoints";
import { LoginFormValues } from "../schemas/schema";
export const loginSchema = z.object({
email: z.string(),
emailOrUsername: z.string(),
password: z.string(),
});

View File

@@ -5,7 +5,7 @@ type TFunction = ReturnType<typeof useTranslations>;
export const createLoginSchema = (t: TFunction) =>
z.object({
email: z.string().min(1, t("validation.emailRequired")).email(t("validation.invalidEmail")),
emailOrUsername: z.string().min(1, t("validation.emailOrUsernameRequired")),
password: z.string().min(1, t("validation.passwordRequired")),
});

View File

@@ -8,6 +8,7 @@ import { Separator } from "@/components/ui/separator";
import { createFieldDescriptions, createGroupMetadata } from "../constants";
import { SettingsGroupProps } from "../types";
import { isFieldHidden, SettingsInput } from "./settings-input";
import { SmtpTestButton } from "./smtp-test-button";
export function SettingsGroup({ group, configs, form, isCollapsed, onToggleCollapse, onSubmit }: SettingsGroupProps) {
const t = useTranslations();
@@ -19,6 +20,8 @@ export function SettingsGroup({ group, configs, form, isCollapsed, onToggleColla
description: t("settings.groups.defaultDescription"),
};
const isEmailGroup = group === "email";
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<Card className="p-6 gap-0">
@@ -48,40 +51,83 @@ export function SettingsGroup({ group, configs, form, isCollapsed, onToggleColla
<div className="flex flex-col gap-4">
{configs
.filter((config) => !isFieldHidden(config.key))
.map((config) => (
<div key={config.key} className="space-y-2 mb-3">
<SettingsInput
config={config}
error={form.formState.errors.configs?.[config.key]}
register={form.register}
setValue={form.setValue}
smtpEnabled={form.watch("configs.smtpEnabled")}
oidcEnabled={form.watch("configs.oidcEnabled")}
watch={form.watch}
/>
<p className="text-xs text-muted-foreground ml-1">
{t(`settings.fields.${config.key}.description`, {
defaultValue:
FIELD_DESCRIPTIONS[config.key as keyof typeof FIELD_DESCRIPTIONS] ||
config.description ||
t("settings.fields.noDescription"),
})}
</p>
</div>
))}
</div>
<div className="flex justify-end mt-4">
<Button
variant="default"
disabled={form.formState.isSubmitting}
className="flex items-center gap-2"
type="submit"
>
{!form.formState.isSubmitting && <IconDeviceFloppy className="h-4 w-4" />}
{t("settings.buttons.save", {
group: t(`settings.groups.${group}.title`, { defaultValue: metadata.title }),
.map((config) => {
const smtpEnabled = form.watch("configs.smtpEnabled");
const smtpNoAuth = form.watch("configs.smtpNoAuth");
const isSmtpAuthField = config.key === "smtpUser" || config.key === "smtpPass";
const smtpFields = [
"smtpHost",
"smtpPort",
"smtpUser",
"smtpPass",
"smtpSecure",
"smtpNoAuth",
"smtpFromName",
"smtpFromEmail",
];
if (smtpEnabled !== "true" && smtpFields.includes(config.key)) {
return null;
}
if (isSmtpAuthField && smtpNoAuth === "true") {
return null;
}
return (
<div key={config.key} className="space-y-2 mb-3">
<SettingsInput
config={config}
error={form.formState.errors.configs?.[config.key]}
register={form.register}
setValue={form.setValue}
smtpEnabled={form.watch("configs.smtpEnabled")}
oidcEnabled={form.watch("configs.oidcEnabled")}
watch={form.watch}
/>
<p className="text-xs text-muted-foreground ml-1">
{t(`settings.fields.${config.key}.description`, {
defaultValue:
FIELD_DESCRIPTIONS[config.key as keyof typeof FIELD_DESCRIPTIONS] ||
config.description ||
t("settings.fields.noDescription"),
})}
</p>
</div>
);
})}
</Button>
</div>
<div className="flex justify-between items-center mt-4">
<div className="flex">
{isEmailGroup && form.watch("configs.smtpEnabled") === "true" && (
<SmtpTestButton
smtpEnabled={form.watch("configs.smtpEnabled") || "false"}
getFormValues={() => ({
smtpEnabled: form.getValues("configs.smtpEnabled") || "false",
smtpHost: form.getValues("configs.smtpHost") || "",
smtpPort: form.getValues("configs.smtpPort") || "",
smtpUser: form.getValues("configs.smtpUser") || "",
smtpPass: form.getValues("configs.smtpPass") || "",
smtpSecure: form.getValues("configs.smtpSecure") || "auto",
smtpNoAuth: form.getValues("configs.smtpNoAuth") || "false",
})}
/>
)}
</div>
<div className="flex">
<Button
variant="default"
disabled={form.formState.isSubmitting}
className="flex items-center gap-2"
type="submit"
>
{!form.formState.isSubmitting && <IconDeviceFloppy className="h-4 w-4" />}
{t("settings.buttons.save", {
group: t(`settings.groups.${group}.title`, { defaultValue: metadata.title }),
})}
</Button>
</div>
</div>
</CardContent>
</Card>

View File

@@ -2,8 +2,10 @@ import { IconInfoCircle } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { UseFormRegister, UseFormSetValue, UseFormWatch } from "react-hook-form";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { PasswordInput } from "@/components/ui/password-input";
import { Switch } from "@/components/ui/switch";
import { TagsInput } from "@/components/ui/tags-input";
import { createFieldTitles } from "../constants";
import { Config } from "../types";
@@ -129,7 +131,7 @@ export function SettingsInput({
? "openid profile email"
: config.key === "oidcAdminEmailDomains"
? "admin.com company.org"
: "Digite e pressione Enter"
: t("settings.tooltips.defaultPlaceholder", { defaultValue: "Enter and press Enter" })
}
/>
{error && <p className="text-danger text-xs mt-1">{error.message}</p>}
@@ -137,6 +139,73 @@ export function SettingsInput({
);
}
if (config.key === "smtpEnabled") {
const currentValue = watch(`configs.${config.key}`) === "true";
return (
<div className="space-y-2">
<div className="flex items-center space-x-3">
<Switch
id={`configs.${config.key}`}
checked={currentValue}
onCheckedChange={(checked) => {
setValue(`configs.${config.key}`, checked ? "true" : "false", { shouldDirty: true });
}}
disabled={isDisabled}
/>
<label htmlFor={`configs.${config.key}`} className="text-sm font-semibold cursor-pointer">
{friendlyLabel}
</label>
</div>
{config.description && <p className="text-xs text-muted-foreground ml-11">{config.description}</p>}
{error && <p className="text-danger text-xs mt-1 ml-11">{error.message}</p>}
</div>
);
}
if (config.key === "smtpSecure") {
return (
<div className="space-y-2">
<label className="block text-sm font-semibold">{friendlyLabel}</label>
<select
{...register(`configs.${config.key}`)}
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm"
disabled={isDisabled}
>
<option value="auto">{t("settings.fields.smtpSecure.options.auto")}</option>
<option value="ssl">{t("settings.fields.smtpSecure.options.ssl")}</option>
<option value="tls">{t("settings.fields.smtpSecure.options.tls")}</option>
<option value="none">{t("settings.fields.smtpSecure.options.none")}</option>
</select>
{error && <p className="text-danger text-xs mt-1">{error.message}</p>}
</div>
);
}
if (config.key === "smtpNoAuth") {
const currentValue = watch(`configs.${config.key}`) === "true";
return (
<div className="space-y-2">
<div className="flex items-center space-x-3">
<Checkbox
id={`configs.${config.key}`}
checked={currentValue}
onCheckedChange={(checked) => {
setValue(`configs.${config.key}`, checked ? "true" : "false", { shouldDirty: true });
}}
disabled={isDisabled}
/>
<label htmlFor={`configs.${config.key}`} className="text-sm font-semibold cursor-pointer">
{friendlyLabel}
</label>
</div>
{config.description && <p className="text-xs text-muted-foreground ml-7">{config.description}</p>}
{error && <p className="text-danger text-xs mt-1 ml-7">{error.message}</p>}
</div>
);
}
switch (config.type) {
case "boolean":
return (

View File

@@ -0,0 +1,94 @@
"use client";
import { useState } from "react";
import { IconFlask, IconInfoCircle, IconLoader } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { testSmtpConnection } from "@/http/endpoints/app";
interface SmtpTestButtonProps {
smtpEnabled: string;
getFormValues: () => {
smtpEnabled: string;
smtpHost: string;
smtpPort: string;
smtpUser: string;
smtpPass: string;
smtpSecure: string;
smtpNoAuth: string;
};
}
export function SmtpTestButton({ smtpEnabled, getFormValues }: SmtpTestButtonProps) {
const [isLoading, setIsLoading] = useState(false);
const t = useTranslations();
const handleTestConnection = async () => {
const formValues = getFormValues();
if (formValues.smtpEnabled !== "true") {
toast.error(t("settings.messages.smtpNotEnabled"));
return;
}
if (!formValues.smtpHost || !formValues.smtpPort) {
toast.error(t("settings.messages.smtpMissingHostPort"));
return;
}
if (formValues.smtpNoAuth !== "true" && (!formValues.smtpUser || !formValues.smtpPass)) {
toast.error(t("settings.messages.smtpMissingAuth"));
return;
}
setIsLoading(true);
try {
const response = await testSmtpConnection({
smtpConfig: {
smtpEnabled: formValues.smtpEnabled,
smtpHost: formValues.smtpHost,
smtpPort: formValues.smtpPort,
smtpUser: formValues.smtpUser,
smtpPass: formValues.smtpPass,
smtpSecure: formValues.smtpSecure,
smtpNoAuth: formValues.smtpNoAuth,
},
});
if (response.data.success) {
toast.success(t("settings.messages.smtpTestSuccess"));
} else {
toast.error(t("settings.messages.smtpTestGenericError"));
}
} catch (error: any) {
const errorMessage = error?.response?.data?.error || error?.message || t("common.unexpectedError");
toast.error(t("settings.messages.smtpTestFailed", { error: errorMessage }));
} finally {
setIsLoading(false);
}
};
return (
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={handleTestConnection}
disabled={isLoading || smtpEnabled !== "true"}
className="flex items-center gap-2"
>
{isLoading ? <IconLoader className="h-4 w-4 animate-spin" /> : <IconFlask className="h-4 w-4" />}
{isLoading ? t("settings.buttons.testing") : t("settings.buttons.testSmtp")}
</Button>
<div className="relative group">
<IconInfoCircle className="h-4 w-4 text-muted-foreground hover:text-foreground cursor-help transition-colors" />
<div className="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 px-3 py-2 bg-popover text-popover-foreground text-xs rounded-md border shadow-md opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-50 w-72 text-wrap">
{t("settings.tooltips.testSmtp")}
<div className="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-popover"></div>
</div>
</div>
</div>
);
}

View File

@@ -46,6 +46,8 @@ export const createFieldDescriptions = (t: ReturnType<typeof createTranslator>)
smtpPass: t("settings.fields.smtpPass.description"),
smtpFromName: t("settings.fields.smtpFromName.description"),
smtpFromEmail: t("settings.fields.smtpFromEmail.description"),
smtpSecure: t("settings.fields.smtpSecure.description"),
smtpNoAuth: t("settings.fields.smtpNoAuth.description"),
// OIDC settings (nomes corretos do seed)
oidcEnabled: t("settings.fields.oidcEnabled.description"),
@@ -85,6 +87,8 @@ export const createFieldTitles = (t: ReturnType<typeof createTranslator>) => ({
smtpPass: t("settings.fields.smtpPass.title"),
smtpFromName: t("settings.fields.smtpFromName.title"),
smtpFromEmail: t("settings.fields.smtpFromEmail.title"),
smtpSecure: t("settings.fields.smtpSecure.title"),
smtpNoAuth: t("settings.fields.smtpNoAuth.title"),
// OIDC settings
oidcEnabled: t("settings.fields.oidcEnabled.title"),

View File

@@ -77,8 +77,26 @@ export function useSettings() {
}
if (group === "email") {
if (a.key === "smtpEnabled") return -1;
if (b.key === "smtpEnabled") return 1;
const smtpOrder = [
"smtpEnabled",
"smtpHost",
"smtpPort",
"smtpSecure",
"smtpNoAuth",
"smtpUser",
"smtpPass",
"smtpFromName",
"smtpFromEmail",
];
const aIndex = smtpOrder.indexOf(a.key);
const bIndex = smtpOrder.indexOf(b.key);
if (aIndex !== -1 && bIndex !== -1) {
return aIndex - bIndex;
}
if (aIndex !== -1) return -1;
if (bIndex !== -1) return 1;
}
if (group === "oidc") {

View File

@@ -42,7 +42,7 @@ export function FileSelector({ shareId, selectedFiles, onSave, onEditFile }: Fil
setShareFiles(allFiles.filter((file) => selectedFiles.includes(file.id)));
setAvailableFiles(allFiles.filter((file) => !selectedFiles.includes(file.id)));
} catch (error) {
toast.error("Failed to load files");
toast.error(t("files.loadError"));
}
};
@@ -79,7 +79,7 @@ export function FileSelector({ shareId, selectedFiles, onSave, onEditFile }: Fil
await onSave(shareFiles.map((f) => f.id));
} catch (error) {
toast.error("Failed to update files");
toast.error(t("shareManager.filesUpdateError"));
} finally {
setIsLoading(false);
}

View File

@@ -60,11 +60,11 @@ export function BulkDownloadModal({ isOpen, onClose, onDownload, fileCount }: Bu
<DialogFooter className="flex gap-2">
<Button variant="outline" onClick={handleClose}>
<IconX className="h-4 w-4 mr-2" />
<IconX className="h-4 w-4" />
{t("common.cancel")}
</Button>
<Button onClick={handleSubmit} disabled={!zipName.trim()}>
<IconDownload className="h-4 w-4 mr-2" />
<IconDownload className="h-4 w-4" />
{t("bulkDownload.download")}
</Button>
</DialogFooter>

View File

@@ -6,6 +6,8 @@ import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { getFileIcon } from "@/utils/file-icons";
import { truncateFileName } from "@/utils/file-utils";
interface DeleteConfirmationModalProps {
isOpen: boolean;
@@ -54,11 +56,18 @@ export function DeleteConfirmationModal({
</p>
<ScrollArea className="h-32 w-full rounded-md border p-2">
<div className="space-y-1">
{files.map((fileName, index) => (
<div key={index} className="text-sm text-muted-foreground truncate">
{fileName}
</div>
))}
{files.map((fileName, index) => {
const { icon: FileIcon, color } = getFileIcon(fileName);
const displayName = truncateFileName(fileName);
return (
<div key={index} className="flex items-center gap-2 p-2 bg-muted/20 rounded text-sm min-w-0">
<FileIcon className={`h-4 w-4 ${color} flex-shrink-0`} />
<span className="flex-1 break-all" title={fileName}>
{displayName}
</span>
</div>
);
})}
</div>
</ScrollArea>
</div>

View File

@@ -1,17 +1,13 @@
"use client";
import { useEffect, useState } from "react";
import { IconDownload } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { CustomAudioPlayer } from "@/components/audio/custom-audio-player";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { getDownloadUrl } from "@/http/endpoints";
import { useFilePreview } from "@/hooks/use-file-preview";
import { getFileIcon } from "@/utils/file-icons";
import { FilePreviewRenderer } from "./previews";
interface FilePreviewModalProps {
isOpen: boolean;
@@ -20,304 +16,14 @@ interface FilePreviewModalProps {
name: string;
objectName: string;
type?: string;
id?: string;
};
isReverseShare?: boolean;
}
export function FilePreviewModal({ isOpen, onClose, file }: FilePreviewModalProps) {
export function FilePreviewModal({ isOpen, onClose, file, isReverseShare = false }: FilePreviewModalProps) {
const t = useTranslations();
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [videoBlob, setVideoBlob] = useState<string | null>(null);
const [pdfAsBlob, setPdfAsBlob] = useState(false);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [pdfLoadFailed, setPdfLoadFailed] = useState(false);
const [isLoadingPreview, setIsLoadingPreview] = useState(false);
useEffect(() => {
if (isOpen && file.objectName && !isLoadingPreview) {
setIsLoading(true);
setPreviewUrl(null);
setVideoBlob(null);
setPdfAsBlob(false);
setDownloadUrl(null);
setPdfLoadFailed(false);
loadPreview();
}
}, [file.objectName, isOpen]);
useEffect(() => {
return () => {
if (previewUrl && previewUrl.startsWith("blob:")) {
URL.revokeObjectURL(previewUrl);
}
if (videoBlob && videoBlob.startsWith("blob:")) {
URL.revokeObjectURL(videoBlob);
}
};
}, [previewUrl, videoBlob]);
useEffect(() => {
if (!isOpen) {
if (previewUrl && previewUrl.startsWith("blob:")) {
URL.revokeObjectURL(previewUrl);
setPreviewUrl(null);
}
if (videoBlob && videoBlob.startsWith("blob:")) {
URL.revokeObjectURL(videoBlob);
setVideoBlob(null);
}
}
}, [isOpen]);
const loadPreview = async () => {
if (!file.objectName || isLoadingPreview) return;
setIsLoadingPreview(true);
try {
const encodedObjectName = encodeURIComponent(file.objectName);
const response = await getDownloadUrl(encodedObjectName);
const url = response.data.url;
setDownloadUrl(url);
const fileType = getFileType();
if (fileType === "video") {
await loadVideoPreview(url);
} else if (fileType === "audio") {
await loadAudioPreview(url);
} else if (fileType === "pdf") {
await loadPdfPreview(url);
} else {
setPreviewUrl(url);
}
} catch (error) {
console.error("Failed to load preview:", error);
toast.error(t("filePreview.loadError"));
} finally {
setIsLoading(false);
setIsLoadingPreview(false);
}
};
const loadVideoPreview = async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
setVideoBlob(blobUrl);
} catch (error) {
console.error("Failed to load video as blob:", error);
setPreviewUrl(url);
}
};
const loadAudioPreview = async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
setPreviewUrl(blobUrl);
} catch (error) {
console.error("Failed to load audio as blob:", error);
setPreviewUrl(url);
}
};
const loadPdfPreview = async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
const finalBlob = new Blob([blob], { type: "application/pdf" });
const blobUrl = URL.createObjectURL(finalBlob);
setPreviewUrl(blobUrl);
setPdfAsBlob(true);
} catch (error) {
console.error("Failed to load PDF as blob:", error);
setPreviewUrl(url);
setTimeout(() => {
if (!pdfLoadFailed && !pdfAsBlob) {
handlePdfLoadError();
}
}, 4000);
}
};
const handlePdfLoadError = async () => {
if (pdfLoadFailed || pdfAsBlob) return;
setPdfLoadFailed(true);
if (downloadUrl) {
setTimeout(() => {
loadPdfPreview(downloadUrl);
}, 500);
}
};
const handleDownload = async () => {
try {
let downloadUrlToUse = downloadUrl;
if (!downloadUrlToUse) {
const encodedObjectName = encodeURIComponent(file.objectName);
const response = await getDownloadUrl(encodedObjectName);
downloadUrlToUse = response.data.url;
}
const fileResponse = await fetch(downloadUrlToUse);
if (!fileResponse.ok) {
throw new Error(`Download failed: ${fileResponse.status} - ${fileResponse.statusText}`);
}
const blob = await fileResponse.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error) {
toast.error(t("filePreview.downloadError"));
console.error("Download error:", error);
}
};
const getFileType = () => {
const extension = file.name.split(".").pop()?.toLowerCase();
if (extension === "pdf") return "pdf";
if (["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "tiff"].includes(extension || "")) return "image";
if (["mp3", "wav", "ogg", "m4a", "aac", "flac"].includes(extension || "")) return "audio";
if (["mp4", "webm", "ogg", "mov", "avi", "mkv", "wmv", "flv", "m4v"].includes(extension || "")) return "video";
return "other";
};
const renderPreview = () => {
const fileType = getFileType();
const { icon: FileIcon, color } = getFileIcon(file.name);
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
<p className="text-muted-foreground">{t("filePreview.loading")}</p>
</div>
);
}
const mediaUrl = fileType === "video" ? videoBlob : previewUrl;
if (!mediaUrl && (fileType === "video" || fileType === "audio")) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<FileIcon className={`h-12 w-12 ${color}`} />
<p className="text-muted-foreground">{t("filePreview.notAvailable")}</p>
<p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
</div>
);
}
if (!previewUrl && fileType !== "video") {
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<FileIcon className={`h-12 w-12 ${color}`} />
<p className="text-muted-foreground">{t("filePreview.notAvailable")}</p>
<p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
</div>
);
}
switch (fileType) {
case "pdf":
return (
<ScrollArea className="w-full">
<div className="w-full min-h-[600px] border rounded-lg overflow-hidden bg-card">
{pdfAsBlob ? (
<iframe
src={`${previewUrl!}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
className="w-full h-full min-h-[600px]"
title={file.name}
style={{ border: "none" }}
/>
) : pdfLoadFailed ? (
<div className="flex items-center justify-center h-full min-h-[600px]">
<div className="flex flex-col items-center gap-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
<p className="text-muted-foreground">{t("filePreview.loadingAlternative")}</p>
</div>
</div>
) : (
<div className="w-full h-full min-h-[600px] relative">
<object
data={`${previewUrl!}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
type="application/pdf"
className="w-full h-full min-h-[600px]"
onError={handlePdfLoadError}
>
<iframe
src={`${previewUrl!}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
className="w-full h-full min-h-[600px]"
title={file.name}
style={{ border: "none" }}
onError={handlePdfLoadError}
/>
</object>
</div>
)}
</div>
</ScrollArea>
);
case "image":
return (
<AspectRatio ratio={16 / 9} className="bg-muted">
<img src={previewUrl!} alt={file.name} className="object-contain w-full h-full rounded-md" />
</AspectRatio>
);
case "audio":
return (
<div className="flex flex-col items-center justify-center gap-6 py-4">
<CustomAudioPlayer src={mediaUrl!} />
</div>
);
case "video":
return (
<div className="flex flex-col items-center justify-center gap-4 py-6">
<div className="w-full max-w-4xl">
<video controls className="w-full rounded-lg" preload="metadata" style={{ maxHeight: "70vh" }}>
<source src={mediaUrl!} />
{t("filePreview.videoNotSupported")}
</video>
</div>
</div>
);
default:
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<FileIcon className={`text-6xl ${color}`} />
<p className="text-muted-foreground">{t("filePreview.notAvailable")}</p>
<p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
</div>
);
}
};
const previewState = useFilePreview({ file, isOpen, isReverseShare });
return (
<Dialog open={isOpen} onOpenChange={onClose}>
@@ -331,12 +37,24 @@ export function FilePreviewModal({ isOpen, onClose, file }: FilePreviewModalProp
<span className="truncate">{file.name}</span>
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto">{renderPreview()}</div>
<div className="flex-1 overflow-auto">
<FilePreviewRenderer
fileType={previewState.fileType}
fileName={file.name}
previewUrl={previewState.previewUrl}
videoBlob={previewState.videoBlob}
textContent={previewState.textContent}
isLoading={previewState.isLoading}
pdfAsBlob={previewState.pdfAsBlob}
pdfLoadFailed={previewState.pdfLoadFailed}
onPdfLoadError={previewState.handlePdfLoadError}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{t("common.close")}
</Button>
<Button onClick={handleDownload}>
<Button onClick={previewState.handleDownload}>
<IconDownload className="h-4 w-4" />
{t("common.download")}
</Button>

View File

@@ -0,0 +1,13 @@
import { CustomAudioPlayer } from "@/components/audio/custom-audio-player";
interface AudioPreviewProps {
src: string;
}
export function AudioPreview({ src }: AudioPreviewProps) {
return (
<div className="flex flex-col items-center justify-center gap-6 py-4">
<CustomAudioPlayer src={src} />
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { useTranslations } from "next-intl";
import { getFileIcon } from "@/utils/file-icons";
interface DefaultPreviewProps {
fileName: string;
isLoading?: boolean;
message?: string;
}
export function DefaultPreview({ fileName, isLoading, message }: DefaultPreviewProps) {
const t = useTranslations();
const { icon: FileIcon, color } = getFileIcon(fileName);
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
<p className="text-muted-foreground">{t("filePreview.loading")}</p>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<FileIcon className={`h-12 w-12 ${color}`} />
<p className="text-muted-foreground">{message || t("filePreview.notAvailable")}</p>
<p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { type FileType } from "@/utils/file-types";
import { AudioPreview } from "./audio-preview";
import { DefaultPreview } from "./default-preview";
import { ImagePreview } from "./image-preview";
import { PdfPreview } from "./pdf-preview";
import { TextPreview } from "./text-preview";
import { VideoPreview } from "./video-preview";
interface FilePreviewRendererProps {
fileType: FileType;
fileName: string;
previewUrl: string | null;
videoBlob: string | null;
textContent: string | null;
isLoading: boolean;
pdfAsBlob: boolean;
pdfLoadFailed: boolean;
onPdfLoadError: () => void;
}
export function FilePreviewRenderer({
fileType,
fileName,
previewUrl,
videoBlob,
textContent,
isLoading,
pdfAsBlob,
pdfLoadFailed,
onPdfLoadError,
}: FilePreviewRendererProps) {
if (isLoading) {
return <DefaultPreview fileName={fileName} isLoading />;
}
const mediaUrl = fileType === "video" ? videoBlob : previewUrl;
if (!mediaUrl && (fileType === "video" || fileType === "audio")) {
return <DefaultPreview fileName={fileName} />;
}
if (fileType === "text" && !textContent) {
return <DefaultPreview fileName={fileName} />;
}
if (!previewUrl && fileType !== "video" && fileType !== "text") {
return <DefaultPreview fileName={fileName} />;
}
switch (fileType) {
case "pdf":
return (
<PdfPreview
src={previewUrl!}
fileName={fileName}
pdfAsBlob={pdfAsBlob}
pdfLoadFailed={pdfLoadFailed}
onLoadError={onPdfLoadError}
/>
);
case "text":
return <TextPreview content={textContent} fileName={fileName} />;
case "image":
return <ImagePreview src={previewUrl!} alt={fileName} />;
case "audio":
return <AudioPreview src={mediaUrl!} />;
case "video":
return <VideoPreview src={mediaUrl!} />;
default:
return <DefaultPreview fileName={fileName} />;
}
}

Some files were not shown because too many files have changed in this diff Show More