Compare commits

...

68 Commits

Author SHA1 Message Date
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
Daniel Luiz Alves
6de29bbf07 fix: standardize environment variable imports and enhance user auth (#69) 2025-06-18 17:08:59 -03:00
Daniel Luiz Alves
39c47be940 fix: standardize environment variable imports and enhance user authentication error handling
- Updated imports for environment variables in auth and email services to ensure consistency.
- Improved error handling in user routes to provide more specific responses for unauthorized access and internal server errors.
2025-06-18 16:57:11 -03:00
Daniel Luiz Alves
76d96816bc v3.0.0-beta.3 (#68) 2025-06-18 16:19:24 -03:00
Daniel Luiz Alves
b3e7658a76 feat: enhance authentication flow and improve database setup script (#67) 2025-06-18 15:32:41 -03:00
Daniel Luiz Alves
61a579aeb3 feat: enhance authentication flow and improve database setup script
- Added a check for first user access in the authentication context to handle initial user setup.
- Updated the server start script to ensure proper ownership and permissions for database operations, enhancing compatibility with Docker environments.
- Refactored database seeding and configuration checks to run as the target user, preventing permission issues during setup.
2025-06-18 15:32:12 -03:00
Daniel Luiz Alves
cc9c375774 feat: add reverse proxy support (#66) 2025-06-18 12:44:39 -03:00
Daniel Luiz Alves
016006ba3d fix: storage calculation when running within docker (#65) 2025-06-18 12:44:20 -03:00
ruohki
cbc567c6a8 fixed logic error 2025-06-18 17:26:23 +02:00
Daniel Luiz Alves
25b4d886f7 docs: Update reverse proxy configuration to address SQLite "readonly database" error
- Added guidance for configuring proper UID/GID permissions to resolve SQLite issues with bind mounts.
- Included a note on checking host UID/GID and linked to detailed setup documentation for clarity.
2025-06-18 12:23:00 -03:00
ruohki
98953e042b check if runs within docker to pick storage loc 2025-06-18 17:16:11 +02:00
Daniel Luiz Alves
9e06a67593 docs: remove outdated Nginx configuration from reverse proxy documentation
- Eliminated the Nginx HTTP configuration section for reverse proxies without HTTPS/SSL to streamline the documentation.
- Maintained focus on the SECURE_SITE variable and Docker Compose setup for clarity in reverse proxy configurations.
2025-06-18 12:14:56 -03:00
Daniel Luiz Alves
9682f96905 docs: reverse proxy documentation to streamline Docker Compose example
- Removed outdated Docker Compose configuration for the Palmr service.
- Retained the SECURE_SITE environment variable setting for clarity.
- Updated documentation to emphasize HTTP security considerations.
2025-06-18 12:14:05 -03:00
Daniel Luiz Alves
d2c69c3b36 feat: Add SECURE_SITE configuration and reverse proxy documentation
- Introduced the SECURE_SITE environment variable to control cookie security settings based on deployment context.
- Updated Dockerfile to log SECURE_SITE status during application startup.
- Enhanced documentation with a new guide on reverse proxy configuration, detailing the use of SECURE_SITE for secure cookie handling.
- Adjusted authentication and email services to utilize SECURE_SITE for secure connections.
- Updated frontend components to set cookie security based on the current protocol.
2025-06-18 12:10:54 -03:00
Daniel Luiz Alves
9afe8292fa v3.0.0-beta.2 (#62) 2025-06-17 23:43:12 -03:00
Daniel Luiz Alves
e15f50a8a8 fix(docker): Implement bind mount compose (#61) 2025-06-17 23:31:06 -03:00
Daniel Luiz Alves
8affdc8f95 (fix) Share download (#58) 2025-06-17 23:27:38 -03:00
Daniel Luiz Alves
281eff0f14 chore: update Dockerfile and supervisord configuration for improved logging
- Modified the Dockerfile to streamline the creation of the supervisor configuration directory.
- Updated `infra/supervisord.conf` to redirect logs to stdout and stderr, enhancing log management and visibility.
- Removed specific log file paths and sizes to simplify logging setup.
2025-06-17 23:23:36 -03:00
Daniel Luiz Alves
b28f1f97c4 Refactor Dockerfile to use external supervisord configuration file
- Replaced inline supervisor configuration in Dockerfile with a separate `infra/supervisord.conf` file for better organization and maintainability.
- Ensured the new configuration retains all previous settings for the server and web programs.
2025-06-17 23:14:40 -03:00
Daniel Luiz Alves
c5660b3c6b feat: Enhance Docker setup and documentation for Palmr.
- Added a new `docker-compose-bind-mount-example.yaml` for easier bind mount configuration.
- Updated `.gitignore` to include the `data/` directory for persistent storage.
- Modified `docker-compose.yaml` to clarify volume paths and improve comments.
- Enhanced `Dockerfile` to support flexible UID/GID configuration and ensure proper directory permissions.
- Updated environment variable handling in `server-start.sh` and Prisma configuration for better database management.
- Revised documentation in `quick-start.mdx` and `uid-gid-configuration.mdx` to reflect new features and best practices for deployment.
2025-06-17 22:46:28 -03:00
Charly Gley
e64f718998 fix: change error and success messages 2025-06-17 19:59:27 +02:00
Charly Gley
f00a9dadd0 fix: make download on shares work 2025-06-17 19:55:23 +02:00
Daniel Luiz Alves
c262c164d2 v3.0.0-beta.1 (#57) 2025-06-17 11:07:39 -03:00
Daniel Luiz Alves
1d882252e3 feat: UID GID environment support (#56) 2025-06-17 10:49:57 -03:00
Daniel Luiz Alves
2ea7343e0c feat: Add flexible UID/GID configuration support in Dockerfile and documentation
- Updated Dockerfile to allow configurable user and group IDs via environment variables `PALMR_UID` and `PALMR_GID`, enhancing compatibility with host systems.
- Introduced a new documentation file `uid-gid-configuration.mdx` detailing the configuration process and troubleshooting for permission issues, particularly for NAS systems.
- Updated `meta.json` to include a reference to the new UID/GID configuration guide.
2025-06-17 10:46:36 -03:00
Daniel Luiz Alves
54bd987b9a docs: Update supported languages to include Polish
- Increased the total number of supported languages from 14 to 15, adding Polish (pl-PL) with complete translations across all application features and interfaces.
2025-06-17 10:43:02 -03:00
Daniel Luiz Alves
b900953674 fix: Updare Polish language translations for the application
- Created a new `pl-PL.json` file containing comprehensive translations for various application components, including common messages, file management, user authentication, and sharing features. This addition enhances accessibility for Polish-speaking users.
2025-06-17 10:39:37 -03:00
Daniel Luiz Alves
d07ebfd01f Create pl_PL.json (#43) 2025-06-17 10:34:14 -03:00
Daniel Luiz Alves
5b0b01eecd Update language-switcher.tsx (#44) 2025-06-17 10:25:40 -03:00
Daniel Luiz Alves
cb87505afd Remove hardcoded environment variables from supervisord.conf (#54) 2025-06-17 10:23:46 -03:00
Clay Buxton
b447204908 Remove hardcoded environment variables from supervisord.conf 2025-06-15 22:35:37 -04:00
Kamil
4049878cfe Update language-switcher.tsx
Add Polish language selector
2025-06-13 20:40:39 +02:00
Kamil
13ae0d3b8c Create pl_PL.json
Add Polish language translation
2025-06-13 20:38:58 +02:00
114 changed files with 8347 additions and 1427 deletions

3
.gitignore vendored
View File

@@ -29,4 +29,5 @@ apps/server/.env
apps/server/dist/*
#DEFAULT
.env
.env
data/

View File

@@ -1,10 +1,11 @@
FROM node:20-alpine AS base
# Install system dependencies (removed netcat-openbsd since we no longer need to wait for PostgreSQL)
# Install system dependencies
RUN apk add --no-cache \
gcompat \
supervisor \
curl
curl \
su-exec
# Enable pnpm
RUN corepack enable pnpm
@@ -72,19 +73,20 @@ ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV API_BASE_URL=http://127.0.0.1:3333
# Create application user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 palmr
# Define build arguments for user/group configuration (defaults to current values)
ARG PALMR_UID=1001
ARG PALMR_GID=1001
# Create application directories and set permissions
# Include storage directories for filesystem mode and SQLite database directory
RUN mkdir -p /app/server /app/web /home/palmr/.npm /home/palmr/.cache \
/app/server/uploads /app/server/temp-chunks /app/server/uploads/logo \
/app/server/prisma
# Create application user with configurable UID/GID
RUN addgroup --system --gid ${PALMR_GID} nodejs
RUN adduser --system --uid ${PALMR_UID} --ingroup nodejs palmr
# Create application directories
RUN mkdir -p /app/palmr-app /app/web /home/palmr/.npm /home/palmr/.cache
RUN chown -R palmr:nodejs /app /home/palmr
# === Copy Server Files ===
WORKDIR /app/server
# === Copy Server Files to /app/palmr-app (separate from /app/server for bind mounts) ===
WORKDIR /app/palmr-app
# Copy server production files
COPY --from=server-builder --chown=palmr:nodejs /app/server/dist ./dist
@@ -95,11 +97,11 @@ COPY --from=server-builder --chown=palmr:nodejs /app/server/package.json ./
# Copy password reset script and make it executable
COPY --from=server-builder --chown=palmr:nodejs /app/server/reset-password.sh ./
COPY --from=server-builder --chown=palmr:nodejs /app/server/src/scripts/ ./src/scripts/
COPY --from=server-builder --chown=palmr:nodejs /app/server/PASSWORD_RESET_GUIDE.md ./
RUN chmod +x ./reset-password.sh
# Ensure storage directories have correct permissions
RUN chown -R palmr:nodejs /app/server/uploads /app/server/temp-chunks /app/server/prisma
# Copy seed file to the shared location for bind mounts
RUN mkdir -p /app/server/prisma
COPY --from=server-builder --chown=palmr:nodejs /app/server/prisma/seed.js /app/server/prisma/seed.js
# === Copy Web Files ===
WORKDIR /app/web
@@ -113,56 +115,35 @@ COPY --from=web-builder --chown=palmr:nodejs /app/web/.next/static ./.next/stati
WORKDIR /app
# Create supervisor configuration
RUN mkdir -p /etc/supervisor/conf.d /var/log/supervisor
RUN mkdir -p /etc/supervisor/conf.d
# Copy server start script
COPY infra/server-start.sh /app/server-start.sh
RUN chmod +x /app/server-start.sh
RUN chown palmr:nodejs /app/server-start.sh
# Copy supervisor configuration (simplified without PostgreSQL dependency)
COPY <<EOF /etc/supervisor/conf.d/supervisord.conf
[supervisord]
nodaemon=true
user=root
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
[program:server]
command=/app/server-start.sh
directory=/app/server
user=palmr
autostart=true
autorestart=true
stderr_logfile=/var/log/supervisor/server.err.log
stdout_logfile=/var/log/supervisor/server.out.log
environment=PORT=3333,HOME="/home/palmr",ENABLE_S3="false",ENCRYPTION_KEY="default-key-change-in-production"
priority=100
[program:web]
command=/bin/sh -c 'echo "Waiting for API to be ready..."; while ! curl -f http://127.0.0.1:3333/health >/dev/null 2>&1; do echo "API not ready, waiting..."; sleep 2; done; echo "API is ready! Starting frontend..."; exec node server.js'
directory=/app/web
user=palmr
autostart=true
autorestart=true
stderr_logfile=/var/log/supervisor/web.err.log
stdout_logfile=/var/log/supervisor/web.out.log
environment=PORT=5487,HOSTNAME="0.0.0.0",HOME="/home/palmr",API_BASE_URL="http://127.0.0.1:3333"
priority=200
startsecs=10
EOF
# Copy supervisor configuration
COPY infra/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
# Create main startup script
COPY <<EOF /app/start.sh
#!/bin/sh
set -e
echo "Starting Palmr Application..."
echo "Storage Mode: \${ENABLE_S3:-false}"
echo "Secure Site: \${SECURE_SITE:-false}"
echo "Database: SQLite"
# Ensure storage directories exist with correct permissions
# Set global environment variables
export DATABASE_URL="file:/app/server/prisma/palmr.db"
export UPLOAD_PATH="/app/server/uploads"
export TEMP_CHUNKS_PATH="/app/server/temp-chunks"
# Ensure /app/server directory exists for bind mounts
mkdir -p /app/server/uploads /app/server/temp-chunks /app/server/uploads/logo /app/server/prisma
chown -R palmr:nodejs /app/server/uploads /app/server/temp-chunks /app/server/prisma
echo "Data directories ready for first run..."
# Start supervisor
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
@@ -170,8 +151,8 @@ EOF
RUN chmod +x /app/start.sh
# Create volume mount points for persistent storage (filesystem mode and SQLite database)
VOLUME ["/app/server/uploads", "/app/server/temp-chunks", "/app/server/prisma"]
# Create volume mount points for bind mounts
VOLUME ["/app/server"]
# Expose ports
EXPOSE 3333 5487
@@ -181,4 +162,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -f http://localhost:5487 || exit 1
# Start application
CMD ["/app/start.sh"]
CMD ["/app/start.sh"]

View File

@@ -9,7 +9,7 @@ The integration of next-intl ensures consistent internationalization throughout
## Supported languages
Palmr currently supports 14 languages with complete translations across all application features and interfaces.
Palmr currently supports 15 languages with complete translations across all application features and interfaces.
---
@@ -29,6 +29,7 @@ Palmr currently supports 14 languages with complete translations across all appl
| Chinese (Simplified) | zh-CN | Standard Simplified Chinese support | 100% |
| Italian | it-IT | Standard Italian language support | 100% |
| Dutch | nl-NL | Standard Dutch language support | 100% |
| Polish | pl-PL | Standard Polish language support | 100% |
## Language selection

View File

@@ -14,12 +14,15 @@
"---Configuration---",
"configuring-smtp",
"available-languages",
"uid-gid-configuration",
"reverse-proxy-configuration",
"password-reset-without-smtp",
"oidc-authentication",
"---Developers---",
"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

@@ -3,32 +3,50 @@ title: Quick Start (Docker)
icon: "Rocket"
---
Hey there! Welcome to the fastest way to launch <span className="font-bold">Palmr.</span>, your very own secure <span className="font-bold italic">file sharing solution</span>. Whether you're a first-timer to <span className="font-bold italic">self-hosting</span> or a tech wizard, we've made this process incredibly straightforward. In just a few minutes, you'll have a sleek, user-friendly <span className="font-bold italic">file sharing platform</span> running on your <span className="font-bold italic">server</span> or <span className="font-bold italic">VPS</span>.
Welcome to the fastest way to deploy <span className="font-bold">Palmr.</span> - your secure, self-hosted file sharing solution. This guide will have you up and running in minutes, whether you're new to self-hosting or an experienced developer.
This guide is all about speed and simplicity, using our built-in <span className="font-bold italic">file storage system</span> ideal for most users. While Palmr. supports advanced setups like <span className="font-bold italic">manual installation</span> or <span className="font-bold italic">Amazon S3-compatible external storage</span>, we're focusing on the easiest path with <span className="font-bold italic">Docker Compose</span>. Curious about other options? Check out the dedicated sections in our docs for those advanced configurations.
Palmr. offers flexible deployment options to match your infrastructure needs. This guide focuses on Docker deployment with our recommended filesystem storage, perfect for most use cases.
Let's dive in and get Palmr. up and running!
## Prerequisites
## What you'll need
Ensure you have the following installed on your system:
To get started, you only need two tools installed on your system. Don't worry, they're easy to set up:
- **Docker** - Container runtime ([installation guide](https://docs.docker.com/get-docker/))
- **Docker Compose** - Multi-container orchestration ([installation guide](https://docs.docker.com/compose/install/))
- **Docker** ([https://docs.docker.com](https://docs.docker.com/)) - This will run Palmr. in a container.
- **Docker Compose** ([https://docs.docker.com/compose](https://docs.docker.com/compose/)) - This helps manage the setup with a simple configuration file.
> **Platform Support**: Palmr. is developed on macOS and extensively tested on Linux servers. While we haven't formally tested other platforms, Docker's cross-platform nature should ensure compatibility. Report any issues on our [GitHub repository](https://github.com/kyantech/Palmr/issues).
> **Note**: Palmr. was developed on **MacOS** and thoroughly tested on **Linux servers**, ensuring top-notch performance on these platforms. We haven't tested on **Windows** or other environments yet, so there might be some hiccups. Since we're still in **beta**, bugs can pop up anywhere. If you spot an issue, we'd love your help please report it on our GitHub [issues page](https://github.com/kyantech/Palmr/issues).
## Storage Options
## Setting up with Docker Compose
Palmr. supports two storage approaches for persistent data:
Docker Compose is the simplest way to deploy Palmr. across different environments. Once you've got Docker and Docker Compose installed, you're ready to roll with our streamlined setup.
### Named Volumes (Recommended)
In the root folder of the Palmr. project, you'll find a few compose files. For this guide, we're using `docker-compose.yaml` the only file you need to run Palmr. with file system storage. No need to build anything yourself; our pre-built images are hosted on [DockerHub](https://hub.docker.com/repositories/kyantech) and referenced in this file.
**Best for**: Production environments, automated deployments
You can tweak settings directly in `docker-compose.yaml` or use environment variables (more on that later). Let's take a closer look at what's inside this file.
- ✅ **Managed by Docker**: No permission issues or manual path management
- ✅ **Optimized Performance**: Docker-native storage optimization
- ✅ **Cross-platform**: Consistent behavior across operating systems
- ✅ **Simplified Backups**: Docker volume commands for backup/restore
## Exploring the docker-compose.yaml file
### Bind Mounts
Here's the full content of our `docker-compose.yaml`. Feel free to copy it from here or grab it from our official repository ([Docker Compose](https://github.com/kyantech/Palmr/blob/main/docker-compose.yaml)).
**Best for**: Development, direct file access requirements
- ✅ **Direct Access**: Files stored in local directory you specify
- ✅ **Transparent Storage**: Direct filesystem access from host
- ✅ **Custom Backup**: Use existing file system backup solutions
- ⚠️ **Permission Considerations**: May require user/group configuration
---
## Option 1: Named Volumes (Recommended)
Named volumes provide the best performance and are managed entirely by Docker.
### Configuration
Use the provided `docker-compose.yaml` for named volumes:
```yaml
services:
@@ -38,104 +56,118 @@ services:
environment:
- ENABLE_S3=false
- ENCRYPTION_KEY=change-this-key-in-production-min-32-chars # CHANGE THIS KEY FOR SECURITY
# - SECURE_SITE=false # Set to true if you are using a reverse proxy
ports:
- "5487:5487" # Web port
- "3333:3333" # API port (OPTIONAL EXPOSED - ONLY IF YOU WANT TO ACCESS THE API DIRECTLY, IF YOU DONT WANT TO EXPOSE THE API, JUST REMOVE THIS LINE )
- "5487:5487" # Web interface
- "3333:3333" # API port (OPTIONAL EXPOSED - ONLY IF YOU WANT TO ACCESS THE API DIRECTLY)
volumes:
- palmr_data:/app/server # Volume for the application data
- palmr_data:/app/server # Named volume for the application data
restart: unless-stopped # Restart the container unless it is stopped
volumes:
palmr_data:
```
We've added helpful comments in the file to guide you through customization. Let's break down what you can adjust to fit your setup.
---
### Understanding the services
Palmr. runs as a single service in this filesystem storage setup. Here's a quick overview:
| **Service** | **Image** | **Exposed Ports** | **Main Features** |
| ----------- | ---------------------------------------------------------------------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| palmr | [kyantech/palmr:latest](https://hub.docker.com/repository/docker/kyantech/palmr/general) | **3333** (API)<br/>**5487** (Web) | • Combined backend API and frontend service<br/>• Uses local filesystem storage<br/>• Has healthcheck to ensure availability |
---
### Customizing with environment variables
You can fine-tune Palmr. using environment variables. Here's what's available for the filesystem storage setup:
| **Variable** | **Default Value** | **Description** |
| ---------------- | ------------------------------------------ | ------------------------------------------------------------ |
| `ENABLE_S3` | false | Set to 'false' for filesystem storage or 'true' for S3/MinIO |
| `ENCRYPTION_KEY` | change-this-key-in-production-min-32-chars | Required for filesystem encryption (minimum 32 characters) |
> **Important**: These variables can be set in a `.env` file at the project root or directly in your environment when running Docker Compose. The `ENCRYPTION_KEY` is crucial for securing your filesystem storage **always change it** to a unique, secure value in production. you can generate a secure key using the [KeyGenerator](/docs/3.0-beta/quick-start#generating-a-secure-encryption-key) tool.
---
### Managing persistent data
To ensure your data sticks around even if the container restarts, we use a persistent volume:
| **Volume** | **Description** |
| ------------ | ------------------------------------- |
| `palmr_data` | Stores all the data of Palmr. service |
---
## Launching Palmr.
With your `docker-compose.yaml` ready, it's time to start Palmr.! Run this command to launch everything in the background:
### Deployment
```bash
docker-compose up -d
```
This runs Palmr. in **detached mode**, meaning it operates silently in the background without flooding your terminal with logs.
Now, open your browser and visit:
```bash
http://localhost:5487
```
If you're on a server, replace `localhost` with your server's IP:
```bash
http://[YOUR_SERVER_IP]:5487
```
For example, if your server IP is `192.168.1.10`, the URL would be `http://192.168.1.10:5487`. Remember, this is just an example use your actual server IP.
> **Pro Tip**: For full functionality and security, configure your server with **HTTPS** by setting up a valid SSL certificate.
---
## Keeping Palmr. up to date
## Option 2: Bind Mounts
Want the latest features and fixes? Updating Palmr. is a breeze. Run these commands to pull the newest version from DockerHub and restart the service:
Bind mounts store data in a local directory, providing direct file system access.
### Configuration
To use bind mounts, **replace the content** of your `docker-compose.yaml` with the following configuration (you can also reference `docker-compose-bind-mount-example.yaml` as a template):
```yaml
services:
palmr:
image: kyantech/palmr:latest
container_name: palmr
environment:
- ENABLE_S3=false
- ENCRYPTION_KEY=change-this-key-in-production-min-32-chars # CHANGE THIS KEY FOR SECURITY
# - SECURE_SITE=false # Set to true if you are using a reverse proxy
# Optional: Set custom UID/GID for file permissions
# - PALMR_UID=1000
# - PALMR_GID=1000
ports:
- "5487:5487" # Web port
- "3333:3333" # API port (OPTIONAL EXPOSED - ONLY IF YOU WANT TO ACCESS THE API DIRECTLY)
volumes:
# Bind mount for persistent data (uploads, database, temp files)
- ./data:/app/server # Local directory for the application data
restart: unless-stopped # Restart the container unless it is stopped
```
### Deployment
```bash
docker-compose pull
docker-compose up -d
```
That's it! You're now running the latest version of Palmr.
> **Permission Configuration**: If you encounter permission issues with bind mounts (common on NAS systems), see our [UID/GID Configuration](/docs/3.0-beta/uid-gid-configuration) guide for automatic permission handling.
---
## Running with Docker (without Compose)
## Environment Variables
Prefer to skip Docker Compose and use plain Docker? No problem! Use this command to start Palmr. directly:
Configure Palmr. behavior through environment variables:
| Variable | Default | Description |
| ---------------- | ------- | ------------------------------------------------------- |
| `ENABLE_S3` | `false` | Enable S3-compatible storage |
| `ENCRYPTION_KEY` | - | **Required**: Minimum 32 characters for file encryption |
| `SECURE_SITE` | `false` | Enable secure cookies for HTTPS/reverse proxy setups |
> **⚠️ Security Warning**: Always change the `ENCRYPTION_KEY` in production. This key encrypts your files - losing it makes files permanently inaccessible.
> **🔗 Reverse Proxy**: If deploying behind a reverse proxy (Traefik, Nginx, etc.), set `SECURE_SITE=true` and review our [Reverse Proxy Configuration](/docs/3.0-beta/reverse-proxy-configuration) guide for proper setup.
### Generate Secure Encryption Keys
Need a strong key for `ENCRYPTION_KEY`? Use our built-in generator to create cryptographically secure keys:
<KeyGenerator />
---
## Accessing Palmr.
Once deployed, access Palmr. through your web browser:
- **Local**: `http://localhost:5487`
- **Server**: `http://YOUR_SERVER_IP:5487`
### API Access (Optional)
If you exposed port 3333 in your configuration, you can also access:
- **API Documentation**: `http://localhost:3333/docs` (local) or `http://YOUR_SERVER_IP:3333/docs` (server)
- **API Endpoints**: Available at `http://localhost:3333` (local) or `http://YOUR_SERVER_IP:3333` (server)
> **📚 Learn More**: For complete API documentation, authentication, and integration examples, see our [API Reference](/docs/3.0-beta/api) guide.
> **💡 Production Tip**: For production deployments, configure HTTPS with a valid SSL certificate for enhanced security.
---
## Docker CLI Alternative
Prefer using Docker directly? Both storage options are supported:
**Named Volume:**
```bash
docker run -d \
--name palmr \
-e ENABLE_S3=false \
-e ENCRYPTION_KEY=change-this-key-in-production-min-32-chars \
-e ENCRYPTION_KEY=your-secure-key-min-32-chars \
-p 5487:5487 \
-p 3333:3333 \
-v palmr_data:/app/server \
@@ -143,22 +175,84 @@ docker run -d \
kyantech/palmr:latest
```
This also runs in detached mode, so it's hands-off. Access Palmr. at the same URLs mentioned earlier.
**Bind Mount:**
> **Critical Reminder**: Whichever method you choose, **change the `ENCRYPTION_KEY`** to a secure, unique value. This key encrypts your files on the filesystem if you lose it, your files become inaccessible.
```bash
docker run -d \
--name palmr \
-e ENABLE_S3=false \
-e ENCRYPTION_KEY=your-secure-key-min-32-chars \
-p 5487:5487 \
-p 3333:3333 \
-v $(pwd)/data:/app/server \
--restart unless-stopped \
kyantech/palmr:latest
```
---
## Generating a Secure Encryption Key
## Maintenance
Need a strong key for `ENCRYPTION_KEY`? Use our handy Password Generator Tool below to create one:
### Updates
<KeyGenerator />
Keep Palmr. current with the latest features and security fixes:
```bash
docker-compose pull
docker-compose up -d
```
### Backup & Restore
The backup method depends on which storage option you're using:
**Named Volume Backup:**
```bash
docker run --rm \
-v palmr_data:/data \
-v $(pwd):/backup \
alpine tar czf /backup/palmr-backup.tar.gz -C /data .
```
**Named Volume Restore:**
```bash
docker run --rm \
-v palmr_data:/data \
-v $(pwd):/backup \
alpine tar xzf /backup/palmr-backup.tar.gz -C /data
```
**Bind Mount Backup:**
```bash
tar czf palmr-backup.tar.gz ./data
```
**Bind Mount Restore:**
```bash
tar xzf palmr-backup.tar.gz
```
---
## You're all set!
## Next Steps
Congratulations! You've just deployed your own secure file sharing solution with Palmr. in record time. We're thrilled to have you on board and hope you love using this powerful tool as much as we loved building it.
Your Palmr. instance is now ready! Explore additional configuration options:
Got questions or ideas? Dive into the rest of our documentation or reach out via our GitHub [issues page](https://github.com/kyantech/Palmr/issues). Happy sharing!
### Advanced Configuration
- **[UID/GID Configuration](/docs/3.0-beta/uid-gid-configuration)** - Configure user permissions for NAS systems and custom environments
- **[S3 Storage](/docs/3.0-beta/s3-configuration)** - Scale with Amazon S3 or compatible storage providers
- **[Manual Installation](/docs/3.0-beta/manual-installation)** - Manual installation and custom configurations
### Integration & Development
- **[API Reference](/docs/3.0-beta/api)** - Integrate Palmr. with your applications
- **[Architecture Guide](/docs/3.0-beta/architecture)** - Understanding Palmr. components and design
---
Need help? Visit our [GitHub Issues](https://github.com/kyantech/Palmr/issues) or community discussions.

View File

@@ -0,0 +1,199 @@
---
title: Reverse Proxy Configuration
icon: "Shield"
---
When deploying **Palmr.** behind a reverse proxy (like Traefik, Nginx, or Cloudflare), you need to configure secure cookie settings to ensure proper authentication. This guide covers the `SECURE_SITE` environment variable and related proxy configurations.
## Overview
Reverse proxies terminate SSL/TLS connections and forward requests to Palmr., which can cause authentication issues if cookies aren't configured properly for HTTPS environments. The `SECURE_SITE` environment variable controls cookie security settings to handle these scenarios.
## The SECURE_SITE Environment Variable
The `SECURE_SITE` variable configures how Palmr. handles authentication cookies based on your deployment environment:
### Configuration Options
| Value | Cookie Settings | Use Case |
| ------- | ------------------------------------- | ----------------------------------- |
| `true` | `secure: true`, `sameSite: "lax"` | HTTPS/Production with reverse proxy |
| `false` | `secure: false`, `sameSite: "strict"` | HTTP/Development (default) |
### When to Use SECURE_SITE=true
Set `SECURE_SITE=true` in the following scenarios:
- ✅ **Reverse Proxy with HTTPS**: Traefik, Nginx, HAProxy with SSL termination
- ✅ **Cloud Providers**: Cloudflare, AWS ALB, Azure Application Gateway
- ✅ **CDN with HTTPS**: Any CDN that terminates SSL
- ✅ **Production Deployments**: When users access via HTTPS
### When to Use SECURE_SITE=false
Keep `SECURE_SITE=false` (default) for:
- ✅ **Local Development**: Running on `http://localhost`
- ✅ **Direct HTTP Access**: No reverse proxy involved
- ✅ **Testing Environments**: When using HTTP
- ✅ **HTTP Reverse Proxy**: Nginx, Apache, etc. without SSL termination
---
## HTTP Reverse Proxy Setup
**Docker Compose for HTTP Nginx:**
```yaml
environment:
- SECURE_SITE=false # HTTP = false
```
> **⚠️ HTTP Security**: Remember that HTTP transmits data in plain text. Consider using HTTPS in production environments.
---
## Troubleshooting Authentication Issues
### Common Symptoms
If you experience authentication issues behind a reverse proxy:
- ❌ Login appears successful but redirects to login page
- ❌ "No Authorization was found in request.cookies" errors
- ❌ API requests return 401 Unauthorized
- ❌ User registration fails silently
### Diagnostic Steps
1. **Check Browser Developer Tools**:
- Look for cookies in Application/Storage tab
- Verify cookie has `Secure` flag when using HTTPS
- Check if `SameSite` attribute is appropriate
2. **Verify Environment Variables**:
```bash
docker exec -it palmr env | grep SECURE_SITE
```
3. **Test Cookie Settings**:
- With `SECURE_SITE=false`: Should work on HTTP
- With `SECURE_SITE=true`: Should work on HTTPS
### Common Fixes
**Problem**: Authentication fails with reverse proxy
**Solution**: Set `SECURE_SITE=true` and ensure proper headers:
```yaml
environment:
- SECURE_SITE=true
```
**Problem**: Mixed content errors
**Solution**: Ensure proxy passes correct headers:
```yaml
# Traefik
- "traefik.http.middlewares.palmr-headers.headers.customrequestheaders.X-Forwarded-Proto=https"
# Nginx
proxy_set_header X-Forwarded-Proto $scheme;
```
**Problem**: Authentication fails with HTTP reverse proxy
**Solution**: Use `SECURE_SITE=false` and ensure proper cookie headers:
```yaml
environment:
- SECURE_SITE=false # For HTTP proxy
```
```nginx
# Nginx - Add these headers for cookie handling
proxy_set_header Cookie $http_cookie;
proxy_pass_header Set-Cookie;
```
**Problem**: SQLite "readonly database" error with bind mounts
**Solution**: Configure proper UID/GID permissions:
```yaml
environment:
- PALMR_UID=1000 # Your host UID (check with: id)
- PALMR_GID=1000 # Your host GID
- ENCRYPTION_KEY=your-key-here
```
> **💡 Note**: Check your host UID/GID with `id` command and use those values. See [UID/GID Configuration](/docs/3.0-beta/uid-gid-configuration) for detailed setup.
---
## Security Considerations
> **⚠️ Important**: Always use HTTPS in production environments. The `SECURE_SITE=true` setting ensures cookies are only sent over encrypted connections.
---
## Advanced Configuration
### Multiple Domains
If serving Palmr. on multiple domains, ensure consistent cookie settings:
```yaml
environment:
- SECURE_SITE=true # Use for all HTTPS domains
```
### Development vs Production
Use environment-specific configurations:
**Development (HTTP):**
```yaml
environment:
- SECURE_SITE=false
```
**Production (HTTPS):**
```yaml
environment:
- SECURE_SITE=true
```
### Health Checks
Add health checks to ensure proper proxy configuration:
```yaml
services:
palmr:
# ... other config
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5487/api/health"]
interval: 30s
timeout: 10s
retries: 3
```
---
## Need Help?
If you're still experiencing issues after following this guide:
1. **Check the Logs**: `docker logs palmr`
2. **Verify Headers**: Use browser dev tools or `curl -I`
3. **Test Direct Access**: Try accessing Palmr. directly (bypassing proxy)
4. **Open an Issue**: [Report bugs on GitHub](https://github.com/kyantech/Palmr/issues)
> **💡 Pro Tip**: When reporting issues, include your reverse proxy configuration and any relevant error messages from both Palmr. and your proxy logs.

View File

@@ -0,0 +1,525 @@
---
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 management system that automates synchronization, validation, and translation 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
- **Auto-translation**: Use Google Translate API for initial translations
- **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
# Auto-translate [TO_TRANSLATE] strings
pnpm run translations:translate
# Dry run mode (test without changes)
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 translate
python3 run_translations.py help
```
</Tab>
</Tabs>
## Available Scripts
### Main Commands (npm/pnpm)
| Command | Description |
| --------------------------------- | ------------------------------------------- |
| `pnpm run translations` | Complete workflow: sync + translate + check |
| `pnpm run translations:check` | Check translation status and completeness |
| `pnpm run translations:sync` | Synchronize missing keys from en-US.json |
| `pnpm run translations:translate` | Auto-translate [TO_TRANSLATE] strings |
| `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. **Auto-translate**: Run `pnpm run translations:translate` for automatic translations
4. **Review translations**: **Mandatory step** - Check all auto-generated translations for accuracy
5. **Test in UI**: Verify translations work correctly in the application interface
<Callout type="important">
**Never skip step 4**: Auto-generated translations must be reviewed before
committing to production. They are a starting point, not a final solution.
</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 critical strings or when automatic translation isn't sufficient:
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
└── translate_missing.py # Auto-translation
```
## Prerequisites
- **Python 3.6 or higher** - Required for running the translation scripts
- **No external dependencies** - Scripts use only Python standard libraries (googletrans auto-installs when needed)
- **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
- `translate` - Automatically translate marked strings
- `all` - Run complete workflow (sync + translate + 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
### Auto-Translation Script (`translate_missing.py`)
Automated translation using Google Translate API:
#### Process
1. **Dependency check**: Auto-installs `googletrans` if needed
2. **Scan for work**: Finds all `[TO_TRANSLATE]` prefixed strings
3. **Language mapping**: Maps file names to Google Translate language codes
4. **Batch translation**: Processes strings with rate limiting
5. **Update files**: Replaces marked strings with translations
6. **Error handling**: Retries failed translations, reports results
#### Safety Features
- **Rate limiting**: Configurable delay between requests
- **Retry logic**: Multiple attempts for failed translations
- **Dry run mode**: Preview changes without modifications
- **Language skipping**: Exclude specific languages from processing
- **Progress tracking**: Real-time status of translation progress
## 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
```
#### Translation Parameters (`translate`)
```bash
# Custom delay between translation requests (avoid rate limiting)
python3 scripts/run_translations.py translate --delay 2.0
# Skip specific languages from translation
python3 scripts/run_translations.py translate --skip-languages pt-BR.json fr-FR.json
# Dry run - see what would be translated
python3 scripts/run_translations.py translate --dry-run
# Specify custom messages directory
python3 scripts/run_translations.py translate --messages-dir /path/to/messages
```
#### 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`, `translate`, `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 |
| `--delay` | `translate` | Delay in seconds between translation requests |
| `--skip-languages` | `translate` | List of language files to skip |
### Dry Run Mode
Always test changes first:
```bash
# Test complete workflow
pnpm run translations:dry-run
# Test individual commands
python3 scripts/run_translations.py sync --dry-run
python3 scripts/run_translations.py translate --dry-run
```
### Common Use Cases
#### Scenario 1: Careful Translation with Review
```bash
# 1. Sync new keys without auto-marking for translation
python3 scripts/run_translations.py sync --no-mark-untranslated
# 2. Manually mark specific keys that need translation
# Edit files to add [TO_TRANSLATE] prefix where needed
# 3. Translate only marked strings with slower rate
python3 scripts/run_translations.py translate --delay 2.0
```
#### Scenario 2: Skip Already Reviewed Languages
```bash
# Skip languages that were already manually reviewed
python3 scripts/run_translations.py translate --skip-languages pt-BR.json es-ES.json
```
#### Scenario 3: Custom Project Structure
```bash
# Work with translations in different directory
python3 scripts/run_translations.py all --messages-dir ../custom-translations
```
#### Scenario 4: 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"
}
}
```
## Automatic Translation
<Callout type="warning">
**Review Required**: Automatic translations are provided for convenience but
**must be reviewed** before production use. They serve as a starting point,
not a final solution.
</Callout>
The system uses Google Translate (free API) to automatically translate strings marked with `[TO_TRANSLATE]`:
```json
{
"key": "[TO_TRANSLATE] Original English text"
}
```
After auto-translation:
```json
{
"key": "Texto original em inglês"
}
```
### Translation Review Process
1. **Generate**: Use `pnpm run translations:translate` to auto-translate
2. **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?
3. **Test**: Verify translations in the actual application interface
4. **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. **Auto-generate**: Use scripts to generate translations for other languages
3. **Review Required**: All auto-generated translations must be reviewed before production use
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
- **Auto-translation**: Scripts use English as source for automatic translation
- **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 translations**: Run `pnpm run translations:dry-run` before committing
4. **Review auto-translations**: All generated translations must be reviewed before production
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
## Troubleshooting
### Common Issues
**Python not found**: Ensure Python 3.6+ is installed and in PATH
**googletrans errors**: The system auto-installs dependencies, but you can manually install:
```bash
pip install googletrans==4.0.0rc1
```
**Rate limiting**: Increase delay between requests:
```bash
python3 scripts/run_translations.py translate --delay 2.0
```
### 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
================================================================================
```
### Auto-Translation Progress
```
🌍 Translating 3 languages...
⏱️ Delay between requests: 1.0s
[1/3] 🌐 Language: PT
🔍 Processing: pt-BR.json
📝 Found 12 strings to translate
📍 (1/12) Translating: dashboard.welcome
✅ "Welcome to Palmr" → "Bem-vindo ao Palmr"
💾 File saved with 12 translations
📊 FINAL SUMMARY
================================================================================
✅ Translations performed:
• 34 successes
• 2 failures
• 36 total processed
• Success rate: 94.4%
```
## 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**: Review auto-translations for accuracy
The translation management system ensures consistency and makes it easy to maintain high-quality localization across all supported languages.

View File

@@ -0,0 +1,222 @@
---
title: UID/GID Configuration
icon: "Users"
---
Configure user and group permissions for seamless bind mount compatibility across different host systems, particularly NAS environments.
## Overview
Palmr. supports runtime UID/GID configuration to resolve permission conflicts when using bind mounts. This eliminates the need for manual permission management on your host system.
**Default Configuration**: UID 1001, GID 1001
## When to Configure
UID/GID configuration is recommended when:
- Using bind mounts with different host user permissions
- Deploying on NAS systems (Synology, QNAP, etc.)
- Encountering "permission denied" errors
- Host system uses different default UID/GID values
## Environment Variables
Configure permissions using these optional environment variables:
| Variable | Description | Default | Example |
| ----------- | -------------------------------- | ------- | ------- |
| `PALMR_UID` | User ID for container processes | `1001` | `1000` |
| `PALMR_GID` | Group ID for container processes | `1001` | `1000` |
---
## Finding Host UID/GID
Determine your host system's user and group IDs:
```bash
# Check current user
id
# Output example
uid=1000(user) gid=1000(group) groups=1000(group),27(sudo)
```
Use the `uid` and `gid` values for your configuration.
---
## Configuration Examples
### Standard Linux System
```yaml
services:
palmr:
image: kyantech/palmr:latest
container_name: palmr
environment:
- ENABLE_S3=false
- ENCRYPTION_KEY=your-secure-key-min-32-chars
- PALMR_UID=1000
- PALMR_GID=1000
ports:
- "5487:5487"
volumes:
- ./data:/app/server
restart: unless-stopped
```
### Synology NAS
```yaml
services:
palmr:
image: kyantech/palmr:latest
container_name: palmr
environment:
- ENABLE_S3=false
- ENCRYPTION_KEY=your-secure-key-min-32-chars
- PALMR_UID=1026
- PALMR_GID=100
ports:
- "5487:5487"
volumes:
- /volume1/docker/palmr:/app/server
restart: unless-stopped
```
### QNAP NAS
```yaml
services:
palmr:
image: kyantech/palmr:latest
container_name: palmr
environment:
- ENABLE_S3=false
- ENCRYPTION_KEY=your-secure-key-min-32-chars
- PALMR_UID=1000
- PALMR_GID=100
ports:
- "5487:5487"
volumes:
- /share/Container/palmr:/app/server
restart: unless-stopped
```
---
## Migration Guide
### Existing Installations
To add UID/GID configuration to running installations:
1. **Stop the container**
```bash
docker-compose down
```
2. **Backup your data**
```bash
cp -r ./data ./data-backup
```
3. **Update configuration**
Add UID/GID variables to your `docker-compose.yaml`
4. **Restart with new configuration**
```bash
docker-compose up -d
```
---
## Verification
### Check Configuration
Verify UID/GID settings are applied correctly:
```bash
# View startup logs
docker-compose logs palmr | head -20
# Check file ownership
docker exec palmr ls -la /app/server/
# Verify process UID/GID
docker exec palmr ps aux | grep node
```
### Troubleshooting
**Permission issues persist:**
```bash
# Check environment variables
docker exec palmr env | grep PALMR
# Verify file ownership
docker exec palmr stat /app/server/prisma/palmr.db
# Review configuration logs
docker-compose logs palmr | grep -E "🔧|🔐|🔽"
```
**NAS-specific debugging:**
```bash
# Synology - Check mount point ownership
ls -la /volume1/docker/palmr/
# QNAP - Check mount point ownership
ls -la /share/Container/palmr/
# Check NAS user configuration
cat /etc/passwd | grep -v nobody
```
---
## Implementation Details
The UID/GID configuration process:
1. **Detection** - Environment variables are read during container startup
2. **Ownership Update** - File permissions are adjusted to match target UID/GID
3. **Privilege Drop** - Application runs with specified user permissions via `su-exec`
4. **Logging** - Configuration changes are logged for verification
This approach provides automatic permission management without user creation or system modification.
---
## Build-Time Configuration
For custom base images with different default values:
```bash
docker build \
--build-arg PALMR_UID=2000 \
--build-arg PALMR_GID=2000 \
-t palmr:custom .
```
Runtime environment variables override build-time defaults.
---
## Benefits
- **Zero Configuration** - Works automatically when environment variables are set
- **Universal Compatibility** - Supports any valid UID/GID combination
- **NAS Optimized** - Tested with major NAS platforms
- **Backward Compatible** - Existing deployments continue without modification
- **Performance Optimized** - Lightweight implementation using `su-exec`
For permission issues with bind mounts, add the appropriate `PALMR_UID` and `PALMR_GID` environment variables to resolve conflicts automatically.

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

View File

@@ -1,6 +1,7 @@
# FOR FILESYSTEM STORAGE ENV VARS
ENABLE_S3=false
ENCRYPTION_KEY=change-this-key-in-production-min-32-chars
DATABASE_URL="file:./palmr.db"
# FOR USE WITH S3 COMPATIBLE STORAGE
# ENABLE_S3=true

View File

@@ -4,7 +4,7 @@ generator client {
datasource db {
provider = "sqlite"
url = "file:./palmr.db"
url = env("DATABASE_URL")
}
model User {
@@ -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

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

@@ -11,6 +11,8 @@ const envSchema = z.object({
S3_REGION: z.string().optional(),
S3_BUCKET_NAME: z.string().optional(),
S3_FORCE_PATH_STYLE: z.union([z.literal("true"), z.literal("false")]).default("false"),
SECURE_SITE: z.union([z.literal("true"), z.literal("false")]).default("false"),
DATABASE_URL: z.string().optional().default("file:/app/server/prisma/palmr.db"),
});
export const env = envSchema.parse(process.env);

View File

@@ -4,7 +4,21 @@ 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 });
}

View File

@@ -1,3 +1,4 @@
import { env } from "../../env";
import { LoginSchema, RequestPasswordResetSchema, createResetPasswordSchema } from "./dto";
import { AuthService } from "./service";
import { FastifyReply, FastifyRequest } from "fastify";
@@ -17,8 +18,8 @@ export class AuthController {
reply.setCookie("token", token, {
httpOnly: true,
path: "/",
secure: false,
sameSite: "strict",
secure: env.SECURE_SITE === "true" ? true : false,
sameSite: env.SECURE_SITE === "true" ? "lax" : "strict",
});
return reply.send({ user });

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

@@ -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,6 +1,8 @@
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";
const execAsync = promisify(exec);
@@ -9,6 +11,126 @@ const prisma = new PrismaClient();
export class StorageService {
private configService = new ConfigService();
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 {
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 async _getDiskSpaceMultiplePaths(): Promise<{ total: number; available: number } | null> {
const pathsToTry = IS_RUNNING_IN_CONTAINER
? ["/app/server/uploads", "/app/server", "/app", "/"]
: [".", "./uploads", process.cwd()];
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(
userId?: string,
isAdmin?: boolean
@@ -20,46 +142,40 @@ export class StorageService {
}> {
try {
if (isAdmin) {
const command = process.platform === "win32"
? "wmic logicaldisk get size,freespace,caption"
: process.platform === "darwin"
? "df -k ."
: "df -B1 .";
console.log(`Running in container: ${IS_RUNNING_IN_CONTAINER}`);
const { stdout } = await execAsync(command);
let total = 0;
let available = 0;
const diskInfo = await this._getDiskSpaceMultiplePaths();
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);
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");
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 },
@@ -68,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

@@ -14,20 +14,25 @@ export async function userRoutes(app: FastifyInstance) {
const usersCount = await prisma.user.count();
if (usersCount > 0) {
await request.jwtVerify();
if (!request.user.isAdmin) {
try {
await request.jwtVerify();
if (!request.user.isAdmin) {
return reply
.status(403)
.send({ error: "Access restricted to administrators" })
.description("Access restricted to administrators");
}
} catch (authErr) {
console.error(authErr);
return reply
.status(403)
.send({ error: "Access restricted to administrators" })
.description("Access restricted to administrators");
.status(401)
.send({ error: "Unauthorized: a valid token is required to access this resource." })
.description("Unauthorized: a valid token is required to access this resource.");
}
}
} catch (err) {
console.error(err);
return reply
.status(401)
.send({ error: "Unauthorized: a valid token is required to access this resource." })
.description("Unauthorized: a valid token is required to access this resource.");
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": "كلمة المرور",
@@ -654,7 +656,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 +728,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 +744,15 @@
"title": "استخدام التخزين",
"ariaLabel": "شريط تقدم استخدام التخزين",
"used": "المستخدمة",
"available": "المتاحة"
"available": "متاح",
"loading": "جارٍ التحميل...",
"retry": "إعادة المحاولة",
"errors": {
"title": "معلومات التخزين غير متوفرة",
"detectionFailed": "تعذر اكتشاف مساحة القرص. قد يكون هذا بسبب مشاكل في إعدادات النظام أو صلاحيات غير كافية.",
"serverError": "حدث خطأ في الخادم أثناء استرجاع معلومات التخزين. يرجى المحاولة مرة أخرى لاحقاً.",
"unknown": "حدث خطأ غير متوقع أثناء تحميل معلومات التخزين."
}
},
"theme": {
"toggle": "تبديل السمة",
@@ -748,6 +770,7 @@
"uploadProgress": "تقدم الرفع",
"upload": "رفع",
"startUploads": "بدء الرفع",
"retry": "إعادة المحاولة",
"finish": "إنهاء",
"success": "تم رفع الملف بنجاح",
"allSuccess": "{count, plural, =1 {تم رفع الملف بنجاح} other {تم رفع # ملف بنجاح}}",
@@ -844,6 +867,7 @@
"passwordLength": "يجب أن تحتوي كلمة المرور على 8 أحرف على الأقل",
"passwordsMatch": "كلمتا المرور غير متطابقتين",
"emailRequired": "البريد الإلكتروني مطلوب",
"emailOrUsernameRequired": "البريد الإلكتروني أو اسم المستخدم مطلوب",
"passwordRequired": "كلمة المرور مطلوبة",
"nameRequired": "الاسم مطلوب",
"required": "هذا الحقل مطلوب"
@@ -1000,7 +1024,15 @@
"noFilesLimit": "بدون حد للملفات",
"noSizeLimit": "بدون حد للحجم",
"allFileTypes": "جميع أنواع الملفات",
"fileTypesHelp": "أدخل الامتدادات بدون نقطة، مفصولة بمسافة أو فاصلة أو شرطة أو خط عمودي"
"fileTypesHelp": "أدخل الامتدادات بدون نقطة، مفصولة بمسافة أو فاصلة أو شرطة أو خط عمودي",
"emailFieldRequired": "حقل البريد الإلكتروني",
"fieldOptions": {
"hidden": "مختفي",
"optional": "خياري",
"required": "مطلوب"
},
"fieldRequirements": "المتطلبات الميدانية",
"nameFieldRequired": "حقل الاسم"
},
"card": {
"untitled": "رابط بدون عنوان",
@@ -1145,7 +1177,9 @@
},
"actions": {
"preview": "معاينة",
"download": "تحميل"
"download": "تحميل",
"copyToMyFiles": "انسخ إلى ملفاتي",
"copying": "نسخ ..."
},
"uploadedBy": "تم الرفع بواسطة {name}",
"anonymous": "مجهول",
@@ -1153,7 +1187,9 @@
"downloadError": "خطأ في تحميل الملف",
"editSuccess": "تم تحديث الملف بنجاح",
"editError": "خطأ في تحديث الملف",
"previewNotAvailable": "المعاينة غير متوفرة"
"previewNotAvailable": "المعاينة غير متوفرة",
"copyError": "خطأ نسخ الملف إلى ملفاتك",
"copySuccess": "تم نسخ الملف إلى ملفاتك بنجاح"
}
},
"form": {
@@ -1214,7 +1250,19 @@
"passwordHelp": "يجب أن تكون كلمة المرور 4 أحرف على الأقل",
"passwordPlaceholder": "أدخل كلمة مرور لحماية الرابط"
},
"submit": "إنشاء رابط استلام"
"submit": "إنشاء رابط استلام",
"emailFieldRequired": {
"label": "متطلبات حقل البريد الإلكتروني",
"description": "تكوين ما إذا كان يجب عرض حقل البريد الإلكتروني للتحميل وإذا كان مطلوبًا"
},
"fieldRequirements": {
"title": "المتطلبات الميدانية",
"description": "تكوين الحقول المعروضة في نموذج التحميل"
},
"nameFieldRequired": {
"label": "اسم حقل الاسم",
"description": "تكوين إذا كان يجب عرض حقل اسم التحميل وإذا كان مطلوبًا"
}
},
"messages": {
"created": "تم إنشاء رابط الاستلام بنجاح!",
@@ -1269,11 +1317,14 @@
"linkInactive": "هذا الرابط غير نشط.",
"linkExpired": "هذا الرابط منتهي الصلاحية.",
"uploadFailed": "خطأ في رفع الملف",
"retry": "إعادة المحاولة",
"fileTooLarge": "الملف كبير جداً. الحجم الأقصى: {maxSize}",
"fileTypeNotAllowed": "نوع الملف غير مسموح به. الأنواع المقبولة: {allowedTypes}",
"maxFilesExceeded": "الحد الأقصى المسموح به هو {maxFiles} ملف/ملفات",
"selectAtLeastOneFile": "اختر ملفاً واحداً على الأقل",
"provideNameOrEmail": "قم بتوفير اسمك أو بريدك الإلكتروني"
"provideNameOrEmail": "قم بتوفير اسمك أو بريدك الإلكتروني",
"provideEmailRequired": "البريد الإلكتروني مطلوب",
"provideNameRequired": "الاسم مطلوب"
},
"fileDropzone": {
"dragActive": "أفلت الملفات هنا",
@@ -1296,7 +1347,9 @@
"descriptionLabel": "الوصف (اختياري)",
"descriptionPlaceholder": "أضف وصفاً للملفات...",
"uploadButton": "رفع {count} ملف/ملفات",
"uploading": "جارٍ الرفع..."
"uploading": "جارٍ الرفع...",
"emailLabelOptional": "البريد الإلكتروني (اختياري)",
"nameLabelOptional": "الاسم (اختياري)"
},
"success": {
"title": "تم رفع الملفات بنجاح! 🎉",
@@ -1334,7 +1387,9 @@
"cancel": "إلغاء",
"preview": "معاينة",
"download": "تحميل",
"delete": "حذف"
"delete": "حذف",
"copyToMyFiles": "انسخ إلى ملفاتي",
"copying": "نسخ ..."
},
"editField": {
"saveChanges": "حفظ التغييرات",
@@ -1342,4 +1397,4 @@
}
}
}
}
}

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",
@@ -654,7 +656,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 +728,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 +744,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 +770,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 +867,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 +1024,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 +1177,9 @@
},
"actions": {
"preview": "Vorschau",
"download": "Herunterladen"
"download": "Herunterladen",
"copyToMyFiles": "Kopieren Sie in meine Dateien",
"copying": "Kopieren..."
},
"uploadedBy": "Hochgeladen von {name}",
"anonymous": "Anonym",
@@ -1153,7 +1187,9 @@
"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"
}
},
"form": {
@@ -1214,7 +1250,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!",
@@ -1269,11 +1317,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 +1347,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,7 +1387,9 @@
"cancel": "Abbrechen",
"preview": "Vorschau",
"download": "Herunterladen",
"delete": "Löschen"
"delete": "Löschen",
"copyToMyFiles": "Kopieren Sie in meine Dateien",
"copying": "Kopieren..."
},
"editField": {
"saveChanges": "Änderungen speichern",

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",
@@ -712,7 +714,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 +786,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 +802,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 +828,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 +924,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 +1024,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 +1177,9 @@
},
"actions": {
"preview": "Preview",
"download": "Download"
"download": "Download",
"copyToMyFiles": "Copy to my files",
"copying": "Copying..."
},
"uploadedBy": "Uploaded by {name}",
"anonymous": "Anonymous",
@@ -1153,7 +1187,9 @@
"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"
}
},
"form": {
@@ -1214,6 +1250,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": {
@@ -1269,11 +1317,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 +1341,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 +1387,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",
@@ -654,7 +656,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 +728,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 +744,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 +770,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 +867,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 +1024,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 +1177,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 +1187,9 @@
"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"
}
},
"form": {
@@ -1214,7 +1250,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!",
@@ -1269,11 +1317,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 +1347,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,7 +1387,9 @@
"cancel": "Cancelar",
"preview": "Vista previa",
"download": "Descargar",
"delete": "Eliminar"
"delete": "Eliminar",
"copyToMyFiles": "Copiar a mis archivos",
"copying": "Proceso de copiar..."
},
"editField": {
"saveChanges": "Guardar cambios",

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",
@@ -654,7 +656,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 +728,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 +744,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 +770,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 +867,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 +1024,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 +1177,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 +1187,9 @@
"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"
}
},
"form": {
@@ -1214,7 +1250,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 !",
@@ -1269,11 +1317,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 +1347,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,7 +1387,9 @@
"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",

View File

@@ -202,6 +202,8 @@
"login": {
"welcome": "स्वागत है में",
"signInToContinue": "जारी रखने के लिए साइन इन करें",
"emailOrUsernameLabel": "ईमेल या उपयोगकर्ता नाम",
"emailOrUsernamePlaceholder": "अपना ईमेल या उपयोगकर्ता नाम दर्ज करें",
"emailLabel": "ईमेल पता",
"emailPlaceholder": "अपना ईमेल दर्ज करें",
"passwordLabel": "पासवर्ड",
@@ -654,7 +656,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 +728,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 +744,15 @@
"title": "स्टोरेज उपयोग",
"ariaLabel": "स्टोरेज उपयोग प्रगति पट्टी",
"used": "उपयोग किया गया",
"available": "उपलब्ध"
"available": "उपलब्ध",
"loading": "लोड हो रहा है...",
"retry": "पुनः प्रयास करें",
"errors": {
"title": "स्टोरेज जानकारी अनुपलब्ध",
"detectionFailed": "डिस्क स्पेस का पता लगाने में असमर्थ। यह सिस्टम कॉन्फ़िगरेशन समस्याओं या अपर्याप्त अनुमतियों के कारण हो सकता है।",
"serverError": "स्टोरेज जानकारी प्राप्त करते समय सर्वर त्रुटि हुई। कृपया बाद में पुनः प्रयास करें।",
"unknown": "स्टोरेज जानकारी लोड करते समय एक अनपेक्षित त्रुटि हुई।"
}
},
"theme": {
"toggle": "थीम टॉगल करें",
@@ -748,6 +770,7 @@
"uploadProgress": "अपलोड प्रगति",
"upload": "अपलोड",
"startUploads": "अपलोड शुरू करें",
"retry": "पुनः प्रयास करें",
"finish": "समाप्त",
"success": "फ़ाइल सफलतापूर्वक अपलोड की गई",
"allSuccess": "{count, plural, =1 {फ़ाइल सफलतापूर्वक अपलोड की गई} other {# फ़ाइलें सफलतापूर्वक अपलोड की गईं}}",
@@ -844,6 +867,7 @@
"passwordLength": "पासवर्ड कम से कम 8 अक्षर का होना चाहिए",
"passwordsMatch": "पासवर्ड मेल नहीं खाते",
"emailRequired": "ईमेल आवश्यक है",
"emailOrUsernameRequired": "ईमेल या उपयोगकर्ता नाम आवश्यक है",
"passwordRequired": "पासवर्ड आवश्यक है",
"nameRequired": "नाम आवश्यक है",
"required": "यह फ़ील्ड आवश्यक है"
@@ -1000,7 +1024,15 @@
"noFilesLimit": "फ़ाइलों की कोई सीमा नहीं",
"noSizeLimit": "आकार की कोई सीमा नहीं",
"allFileTypes": "सभी फ़ाइल प्रकार",
"fileTypesHelp": "एक्सटेंशन बिना बिंदु के, स्पेस, कॉमा, हाइफन या पाइप से अलग करके टाइप करें"
"fileTypesHelp": "एक्सटेंशन बिना बिंदु के, स्पेस, कॉमा, हाइफन या पाइप से अलग करके टाइप करें",
"emailFieldRequired": "ईमेल क्षेत्र",
"fieldOptions": {
"hidden": "छिपा हुआ",
"optional": "वैकल्पिक",
"required": "आवश्यक"
},
"fieldRequirements": "क्षेत्र आवश्यकताएँ",
"nameFieldRequired": "नाम क्षेत्र"
},
"card": {
"untitled": "शीर्षकहीन लिंक",
@@ -1145,7 +1177,9 @@
},
"actions": {
"preview": "पूर्वावलोकन",
"download": "डाउनलोड"
"download": "डाउनलोड",
"copyToMyFiles": "मेरी फ़ाइलों में कॉपी करें",
"copying": "नकल ..."
},
"uploadedBy": "{name} द्वारा भेजा गया",
"anonymous": "अज्ञात",
@@ -1153,7 +1187,9 @@
"downloadError": "फ़ाइल डाउनलोड करने में त्रुटि",
"editSuccess": "फ़ाइल सफलतापूर्वक अपडेट की गई",
"editError": "फ़ाइल अपडेट करने में त्रुटि",
"previewNotAvailable": "पूर्वावलोकन उपलब्ध नहीं है"
"previewNotAvailable": "पूर्वावलोकन उपलब्ध नहीं है",
"copyError": "अपनी फ़ाइलों में फ़ाइल की नकल करने में त्रुटि",
"copySuccess": "आपकी फ़ाइलों को सफलतापूर्वक कॉपी की गई फ़ाइल"
}
},
"form": {
@@ -1214,7 +1250,19 @@
"passwordHelp": "पासवर्ड कम से कम 4 अक्षर का होना चाहिए",
"passwordPlaceholder": "लिंक की सुरक्षा के लिए पासवर्ड दर्ज करें"
},
"submit": "प्राप्ति लिंक बनाएं"
"submit": "प्राप्ति लिंक बनाएं",
"emailFieldRequired": {
"label": "ईमेल क्षेत्र की आवश्यकता",
"description": "कॉन्फ़िगर करें कि क्या अपलोडर ईमेल फ़ील्ड दिखाया जाना चाहिए और यदि आवश्यक है"
},
"fieldRequirements": {
"title": "क्षेत्र आवश्यकताएँ",
"description": "कॉन्फ़िगर करें कि कौन से फ़ील्ड अपलोड फॉर्म में दिखाए गए हैं"
},
"nameFieldRequired": {
"label": "नाम क्षेत्र की आवश्यकता",
"description": "कॉन्फ़िगर करें कि क्या अपलोडर नाम फ़ील्ड दिखाया जाना चाहिए और यदि आवश्यक है"
}
},
"messages": {
"created": "प्राप्ति लिंक सफलतापूर्वक बनाया गया!",
@@ -1269,11 +1317,14 @@
"linkInactive": "यह लिंक निष्क्रिय है।",
"linkExpired": "यह लिंक समाप्त हो गया है।",
"uploadFailed": "फ़ाइल अपलोड करने में त्रुटि",
"retry": "पुनः प्रयास करें",
"fileTooLarge": "फ़ाइल बहुत बड़ी है। अधिकतम आकार: {maxSize}",
"fileTypeNotAllowed": "फ़ाइल प्रकार अनुमत नहीं है। स्वीकृत प्रकार: {allowedTypes}",
"maxFilesExceeded": "अधिकतम {maxFiles} फ़ाइलें अनुमत हैं",
"selectAtLeastOneFile": "कम से कम एक फ़ाइल चुनें",
"provideNameOrEmail": "अपना नाम या ईमेल प्रदान करें"
"provideNameOrEmail": "अपना नाम या ईमेल प्रदान करें",
"provideEmailRequired": "ईमेल की जरूरत है",
"provideNameRequired": "नाम आवश्यक है"
},
"fileDropzone": {
"dragActive": "फ़ाइलें यहां छोड़ें",
@@ -1296,7 +1347,9 @@
"descriptionLabel": "विवरण (वैकल्पिक)",
"descriptionPlaceholder": "फ़ाइलों का विवरण जोड़ें...",
"uploadButton": "{count} फ़ाइल(ें) भेजें",
"uploading": "भेजा जा रहा है..."
"uploading": "भेजा जा रहा है...",
"emailLabelOptional": "ईमेल वैकल्पिक)",
"nameLabelOptional": "नाम: (वैकल्पिक)"
},
"success": {
"title": "फ़ाइलें सफलतापूर्वक भेजी गईं! 🎉",
@@ -1334,7 +1387,9 @@
"cancel": "रद्द करें",
"preview": "पूर्वावलोकन",
"download": "डाउनलोड",
"delete": "हटाएं"
"delete": "हटाएं",
"copyToMyFiles": "मेरी फ़ाइलों में कॉपी करें",
"copying": "नकल ..."
},
"editField": {
"saveChanges": "परिवर्तन सहेजें",
@@ -1342,4 +1397,4 @@
}
}
}
}
}

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",
@@ -654,7 +656,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 +728,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 +744,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 +770,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 +866,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 +1024,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 +1177,9 @@
},
"actions": {
"preview": "Anteprima",
"download": "Scarica"
"download": "Scarica",
"copyToMyFiles": "Copia sui miei file",
"copying": "Copia ..."
},
"uploadedBy": "Inviato da {name}",
"anonymous": "Anonimo",
@@ -1153,7 +1187,9 @@
"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"
}
},
"form": {
@@ -1214,7 +1250,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!",
@@ -1269,11 +1317,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 +1347,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,7 +1387,9 @@
"cancel": "Annulla",
"preview": "Anteprima",
"download": "Scarica",
"delete": "Elimina"
"delete": "Elimina",
"copyToMyFiles": "Copia sui miei file",
"copying": "Copia ..."
},
"editField": {
"saveChanges": "Salva modifiche",

View File

@@ -202,6 +202,8 @@
"login": {
"welcome": "ようこそへ",
"signInToContinue": "続行するにはサインインしてください",
"emailOrUsernameLabel": "メールアドレスまたはユーザー名",
"emailOrUsernamePlaceholder": "メールアドレスまたはユーザー名を入力してください",
"emailLabel": "メールアドレス",
"emailPlaceholder": "メールアドレスを入力してください",
"passwordLabel": "パスワード",
@@ -654,7 +656,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 +728,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 +744,15 @@
"title": "ストレージ使用量",
"ariaLabel": "ストレージ使用状況のプログレスバー",
"used": "使用済み",
"available": "利用可能"
"available": "利用可能",
"loading": "読み込み中...",
"retry": "再試行",
"errors": {
"title": "ストレージ情報が利用できません",
"detectionFailed": "ディスク容量を検出できません。システム設定の問題または権限が不足している可能性があります。",
"serverError": "ストレージ情報の取得中にサーバーエラーが発生しました。後でもう一度お試しください。",
"unknown": "ストレージ情報の読み込み中に予期せぬエラーが発生しました。"
}
},
"theme": {
"toggle": "テーマを切り替える",
@@ -761,6 +783,7 @@
},
"multipleTitle": "複数ファイルをアップロード",
"startUploads": "アップロードを開始",
"retry": "再試行",
"allSuccess": "{count, plural, =1 {ファイルがアップロードされました} other {#個のファイルがアップロードされました}}",
"partialSuccess": "{success}個のファイルがアップロードされ、{error}個が失敗しました",
"dragAndDrop": "またはここにファイルをドラッグ&ドロップ"
@@ -844,6 +867,7 @@
"passwordLength": "パスワードは最低8文字必要です",
"passwordsMatch": "パスワードが一致しません",
"emailRequired": "メールアドレスは必須です",
"emailOrUsernameRequired": "メールアドレスまたはユーザー名は必須です",
"passwordRequired": "パスワードは必須です",
"nameRequired": "名前は必須です",
"required": "このフィールドは必須です"
@@ -1000,7 +1024,15 @@
"noFilesLimit": "ファイル数制限なし",
"noSizeLimit": "サイズ制限なし",
"allFileTypes": "すべてのファイル形式",
"fileTypesHelp": "拡張子をドット無しで入力し、スペース、カンマ、ハイフン、パイプで区切ってください"
"fileTypesHelp": "拡張子をドット無しで入力し、スペース、カンマ、ハイフン、パイプで区切ってください",
"emailFieldRequired": "電子メールフィールド",
"fieldOptions": {
"hidden": "隠れた",
"optional": "オプション",
"required": "必須"
},
"fieldRequirements": "フィールド要件",
"nameFieldRequired": "名前フィールド"
},
"card": {
"untitled": "無題のリンク",
@@ -1145,7 +1177,9 @@
},
"actions": {
"preview": "プレビュー",
"download": "ダウンロード"
"download": "ダウンロード",
"copyToMyFiles": "私のファイルにコピーします",
"copying": "コピー..."
},
"uploadedBy": "{name}が送信",
"anonymous": "匿名",
@@ -1153,7 +1187,9 @@
"downloadError": "ファイルのダウンロードに失敗しました",
"editSuccess": "ファイルを更新しました",
"editError": "ファイルの更新に失敗しました",
"previewNotAvailable": "プレビューは利用できません"
"previewNotAvailable": "プレビューは利用できません",
"copyError": "ファイルにファイルをコピーするエラー",
"copySuccess": "ファイルに正常にコピーされたファイル"
}
},
"form": {
@@ -1214,7 +1250,19 @@
"passwordHelp": "パスワードは4文字以上必要です",
"passwordPlaceholder": "リンクを保護するパスワードを入力"
},
"submit": "受信リンクを作成"
"submit": "受信リンクを作成",
"emailFieldRequired": {
"label": "電子メールフィールドの要件",
"description": "アップローダーの電子メールフィールドが表示されるかどうか、そしてそれが必要かどうかを構成します"
},
"fieldRequirements": {
"title": "フィールド要件",
"description": "アップロードフォームに表示されるフィールドを構成します"
},
"nameFieldRequired": {
"label": "名前フィールドの要件",
"description": "アップローダー名フィールドが表示されるかどうか、そしてそれが必要かどうかを構成します"
}
},
"messages": {
"created": "受信リンクを作成しました!",
@@ -1269,11 +1317,14 @@
"linkInactive": "このリンクは無効です。",
"linkExpired": "このリンクは期限切れです。",
"uploadFailed": "ファイルのアップロードに失敗しました",
"retry": "再試行",
"fileTooLarge": "ファイルが大きすぎます。最大サイズ: {maxSize}",
"fileTypeNotAllowed": "このファイル形式は許可されていません。許可される形式: {allowedTypes}",
"maxFilesExceeded": "最大 {maxFiles} ファイルまで許可されています",
"selectAtLeastOneFile": "少なくとも1つのファイルを選択してください",
"provideNameOrEmail": "名前またはメールアドレスを入力してください"
"provideNameOrEmail": "名前またはメールアドレスを入力してください",
"provideEmailRequired": "メールが必要です",
"provideNameRequired": "名前が必要です"
},
"fileDropzone": {
"dragActive": "ここにファイルをドロップ",
@@ -1296,7 +1347,9 @@
"descriptionLabel": "説明(オプション)",
"descriptionPlaceholder": "ファイルの説明を追加...",
"uploadButton": "{count} ファイルを送信",
"uploading": "送信中..."
"uploading": "送信中...",
"emailLabelOptional": "メール(オプション)",
"nameLabelOptional": "名前(オプション)"
},
"success": {
"title": "ファイルを送信しました! 🎉",
@@ -1334,7 +1387,9 @@
"cancel": "キャンセル",
"preview": "プレビュー",
"download": "ダウンロード",
"delete": "削除"
"delete": "削除",
"copyToMyFiles": "私のファイルにコピーします",
"copying": "コピー..."
},
"editField": {
"saveChanges": "変更を保存",
@@ -1342,4 +1397,4 @@
}
}
}
}
}

View File

@@ -202,6 +202,8 @@
"login": {
"welcome": "에 오신 것을 환영합니다",
"signInToContinue": "계속하려면 로그인하세요",
"emailOrUsernameLabel": "이메일 또는 사용자 이름",
"emailOrUsernamePlaceholder": "이메일 또는 사용자 이름을 입력하세요",
"emailLabel": "이메일 주소",
"emailPlaceholder": "이메일을 입력하세요",
"passwordLabel": "비밀번호",
@@ -654,7 +656,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 +728,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 +744,15 @@
"title": "스토리지 사용량",
"ariaLabel": "스토리지 사용량 진행 바",
"used": "사용됨",
"available": "사용 가능"
"available": "사용 가능",
"loading": "로딩 중...",
"retry": "다시 시도",
"errors": {
"title": "스토리지 정보를 사용할 수 없음",
"detectionFailed": "디스크 공간을 감지할 수 없습니다. 시스템 구성 문제 또는 권한이 부족한 것이 원인일 수 있습니다.",
"serverError": "스토리지 정보를 검색하는 중에 서버 오류가 발생했습니다. 나중에 다시 시도해 주세요.",
"unknown": "스토리지 정보를 로드하는 중에 예기치 않은 오류가 발생했습니다."
}
},
"theme": {
"toggle": "테마 전환",
@@ -748,6 +770,7 @@
"uploadProgress": "업로드 진행률",
"upload": "업로드",
"startUploads": "업로드 시작",
"retry": "다시 시도",
"finish": "완료",
"success": "파일이 성공적으로 업로드되었습니다",
"allSuccess": "{count, plural, =1 {파일이 성공적으로 업로드되었습니다} other {# 개 파일이 성공적으로 업로드되었습니다}}",
@@ -844,6 +867,7 @@
"passwordLength": "비밀번호는 최소 8자 이상이어야 합니다",
"passwordsMatch": "비밀번호가 일치하지 않습니다",
"emailRequired": "이메일은 필수입니다",
"emailOrUsernameRequired": "이메일 또는 사용자 이름은 필수입니다",
"passwordRequired": "비밀번호는 필수입니다",
"nameRequired": "이름은 필수입니다",
"required": "이 필드는 필수입니다"
@@ -1000,7 +1024,15 @@
"noFilesLimit": "파일 수 제한 없음",
"noSizeLimit": "크기 제한 없음",
"allFileTypes": "모든 파일 유형",
"fileTypesHelp": "확장자를 점 없이 입력하고 공백, 쉼표, 대시 또는 파이프로 구분"
"fileTypesHelp": "확장자를 점 없이 입력하고 공백, 쉼표, 대시 또는 파이프로 구분",
"emailFieldRequired": "이메일 필드",
"fieldOptions": {
"hidden": "숨겨진",
"optional": "선택 과목",
"required": "필수의"
},
"fieldRequirements": "필드 요구 사항",
"nameFieldRequired": "이름 필드"
},
"card": {
"untitled": "제목 없는 링크",
@@ -1145,7 +1177,9 @@
},
"actions": {
"preview": "미리보기",
"download": "다운로드"
"download": "다운로드",
"copyToMyFiles": "내 파일에 복사하십시오",
"copying": "사자..."
},
"uploadedBy": "{name}님이 업로드함",
"anonymous": "익명",
@@ -1153,7 +1187,9 @@
"downloadError": "파일 다운로드 오류",
"editSuccess": "파일이 성공적으로 업데이트됨",
"editError": "파일 업데이트 오류",
"previewNotAvailable": "미리보기 불가"
"previewNotAvailable": "미리보기 불가",
"copyError": "파일에 파일을 복사합니다",
"copySuccess": "파일을 파일에 성공적으로 복사했습니다"
}
},
"form": {
@@ -1214,7 +1250,19 @@
"passwordHelp": "비밀번호는 최소 4자 이상이어야 합니다",
"passwordPlaceholder": "링크를 보호할 비밀번호 입력"
},
"submit": "수신 링크 생성"
"submit": "수신 링크 생성",
"emailFieldRequired": {
"label": "이메일 필드 요구 사항",
"description": "업 로더 이메일 필드를 표시 해야하는지 확인하고 필요한 경우 구성하십시오."
},
"fieldRequirements": {
"title": "필드 요구 사항",
"description": "업로드 양식으로 표시되는 필드를 구성하십시오"
},
"nameFieldRequired": {
"label": "이름 필드 요구 사항",
"description": "업로더 이름 필드를 표시 해야하는지 확인하고 필요한 경우 구성하십시오."
}
},
"messages": {
"created": "수신 링크가 성공적으로 생성되었습니다!",
@@ -1269,11 +1317,14 @@
"linkInactive": "이 링크는 비활성 상태입니다.",
"linkExpired": "이 링크는 만료되었습니다.",
"uploadFailed": "파일 업로드 오류",
"retry": "다시 시도",
"fileTooLarge": "파일이 너무 큽니다. 최대 크기: {maxSize}",
"fileTypeNotAllowed": "허용되지 않는 파일 유형입니다. 허용된 유형: {allowedTypes}",
"maxFilesExceeded": "최대 {maxFiles}개의 파일만 허용됩니다",
"selectAtLeastOneFile": "최소 한 개의 파일을 선택하세요",
"provideNameOrEmail": "이름 또는 이메일을 입력하세요"
"provideNameOrEmail": "이름 또는 이메일을 입력하세요",
"provideEmailRequired": "이메일이 필요합니다",
"provideNameRequired": "이름이 필요합니다"
},
"fileDropzone": {
"dragActive": "여기에 파일을 놓으세요",
@@ -1296,7 +1347,9 @@
"descriptionLabel": "설명 (선택사항)",
"descriptionPlaceholder": "파일에 대한 설명 추가...",
"uploadButton": "{count}개 파일 보내기",
"uploading": "보내는 중..."
"uploading": "보내는 중...",
"emailLabelOptional": "이메일 (선택 사항)",
"nameLabelOptional": "이름 (선택 사항)"
},
"success": {
"title": "파일이 성공적으로 보내졌습니다! 🎉",
@@ -1334,7 +1387,9 @@
"cancel": "취소",
"preview": "미리보기",
"download": "다운로드",
"delete": "삭제"
"delete": "삭제",
"copyToMyFiles": "내 파일에 복사하십시오",
"copying": "사자..."
},
"editField": {
"saveChanges": "변경사항 저장",
@@ -1342,4 +1397,4 @@
}
}
}
}
}

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",
@@ -654,7 +656,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 +728,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 +744,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 +770,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 +866,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 +1024,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 +1177,9 @@
},
"actions": {
"preview": "Voorvertoning",
"download": "Downloaden"
"download": "Downloaden",
"copyToMyFiles": "Kopieer naar mijn bestanden",
"copying": "Kopiëren ..."
},
"uploadedBy": "Verzonden door {name}",
"anonymous": "Anoniem",
@@ -1153,7 +1187,9 @@
"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"
}
},
"form": {
@@ -1214,7 +1250,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!",
@@ -1269,11 +1317,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 +1347,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,7 +1387,9 @@
"cancel": "Annuleren",
"preview": "Voorvertoning",
"download": "Downloaden",
"delete": "Verwijderen"
"delete": "Verwijderen",
"copyToMyFiles": "Kopieer naar mijn bestanden",
"copying": "Kopiëren ..."
},
"editField": {
"saveChanges": "Wijzigingen opslaan",
@@ -1342,4 +1397,4 @@
}
}
}
}
}

1400
apps/web/messages/pl-PL.json Normal file

File diff suppressed because it is too large Load Diff

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": {
@@ -676,7 +678,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 +722,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 +758,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 +792,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 +880,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 +961,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 +973,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 +985,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 +998,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 +1024,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 +1177,9 @@
},
"actions": {
"preview": "Visualizar",
"download": "Baixar"
"download": "Baixar",
"copyToMyFiles": "Copiar para meus arquivos",
"copying": "Copiando..."
},
"uploadedBy": "Enviado por {name}",
"anonymous": "Anônimo",
@@ -1153,7 +1187,9 @@
"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"
}
},
"form": {
@@ -1214,7 +1250,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!",
@@ -1269,11 +1317,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 +1347,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,7 +1387,9 @@
"cancel": "Cancelar",
"preview": "Visualizar",
"download": "Baixar",
"delete": "Excluir"
"delete": "Excluir",
"copyToMyFiles": "Copiar para meus arquivos",
"copying": "Copiando..."
},
"editField": {
"saveChanges": "Salvar alterações",

View File

@@ -202,6 +202,8 @@
"login": {
"welcome": "Добро пожаловать в",
"signInToContinue": "Войдите, чтобы продолжить",
"emailOrUsernameLabel": "Электронная почта или имя пользователя",
"emailOrUsernamePlaceholder": "Введите электронную почту или имя пользователя",
"emailLabel": "Адрес электронной почты",
"emailPlaceholder": "Введите вашу электронную почту",
"passwordLabel": "Пароль",
@@ -654,7 +656,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 +728,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 +744,15 @@
"title": "Использование хранилища",
"ariaLabel": "Индикатор использования хранилища",
"used": "Использовано",
"available": "Доступно"
"available": "Доступно",
"loading": "Загрузка...",
"retry": "Повторить",
"errors": {
"title": "Информация о хранилище недоступна",
"detectionFailed": "Не удалось определить свободное место на диске. Это может быть связано с проблемами конфигурации системы или недостаточными правами доступа.",
"serverError": "Произошла ошибка сервера при получении информации о хранилище. Пожалуйста, повторите попытку позже.",
"unknown": "Произошла непредвиденная ошибка при загрузке информации о хранилище."
}
},
"theme": {
"toggle": "Переключить тему",
@@ -748,6 +770,7 @@
"uploadProgress": "Прогресс загрузки",
"upload": "Загрузить",
"startUploads": "Начать Загрузку",
"retry": "Повторить",
"finish": "Завершить",
"success": "Файл успешно загружен",
"allSuccess": "{count, plural, =1 {Файл успешно загружен} other {# файлов успешно загружено}}",
@@ -844,6 +867,7 @@
"passwordLength": "Пароль должен содержать не менее 8 символов",
"passwordsMatch": "Пароли не совпадают",
"emailRequired": "Требуется электронная почта",
"emailOrUsernameRequired": "Электронная почта или имя пользователя обязательно",
"passwordRequired": "Требуется пароль",
"nameRequired": "Требуется имя",
"required": "Это поле обязательно"
@@ -1000,7 +1024,15 @@
"noFilesLimit": "Без ограничения файлов",
"noSizeLimit": "Без ограничения размера",
"allFileTypes": "Все типы файлов",
"fileTypesHelp": "Введите расширения без точки, разделенные пробелом, запятой, дефисом или вертикальной чертой"
"fileTypesHelp": "Введите расширения без точки, разделенные пробелом, запятой, дефисом или вертикальной чертой",
"emailFieldRequired": "Поле электронной почты",
"fieldOptions": {
"hidden": "Скрытый",
"optional": "Необязательный",
"required": "Необходимый"
},
"fieldRequirements": "Полевые требования",
"nameFieldRequired": "Имя Поле"
},
"card": {
"untitled": "Ссылка без названия",
@@ -1145,7 +1177,9 @@
},
"actions": {
"preview": "Предпросмотр",
"download": "Скачать"
"download": "Скачать",
"copyToMyFiles": "Скопируйте в мои файлы",
"copying": "Копирование ..."
},
"uploadedBy": "Загружено {name}",
"anonymous": "Аноним",
@@ -1153,7 +1187,9 @@
"downloadError": "Ошибка при загрузке файла",
"editSuccess": "Файл успешно обновлен",
"editError": "Ошибка при обновлении файла",
"previewNotAvailable": "Предпросмотр недоступен"
"previewNotAvailable": "Предпросмотр недоступен",
"copyError": "Ошибка копирования файла в ваши файлы",
"copySuccess": "Файл успешно скопирован в ваши файлы"
}
},
"form": {
@@ -1214,7 +1250,19 @@
"passwordHelp": "Пароль должен содержать минимум 4 символа",
"passwordPlaceholder": "Введите пароль для защиты ссылки"
},
"submit": "Создать ссылку для получения"
"submit": "Создать ссылку для получения",
"emailFieldRequired": {
"label": "Требование поля электронной почты",
"description": "Настройка, если следует отобразить поле электронной почты загрузчика и если это требуется"
},
"fieldRequirements": {
"title": "Полевые требования",
"description": "Настройте, какие поля показаны в форме загрузки"
},
"nameFieldRequired": {
"label": "Требование поля имени",
"description": "Настройка, если должно быть показано поле имени загрузчика и если оно требуется"
}
},
"messages": {
"created": "Ссылка для получения успешно создана!",
@@ -1269,11 +1317,14 @@
"linkInactive": "Эта ссылка неактивна.",
"linkExpired": "Срок действия этой ссылки истек.",
"uploadFailed": "Ошибка при загрузке файла",
"retry": "Повторить",
"fileTooLarge": "Файл слишком большой. Максимальный размер: {maxSize}",
"fileTypeNotAllowed": "Тип файла не разрешен. Разрешенные типы: {allowedTypes}",
"maxFilesExceeded": "Максимально разрешено {maxFiles} файлов",
"selectAtLeastOneFile": "Выберите хотя бы один файл",
"provideNameOrEmail": "Укажите ваше имя или email"
"provideNameOrEmail": "Укажите ваше имя или email",
"provideEmailRequired": "Электронная почта требуется",
"provideNameRequired": "Имя требуется"
},
"fileDropzone": {
"dragActive": "Отпустите файлы здесь",
@@ -1296,7 +1347,9 @@
"descriptionLabel": "Описание (необязательно)",
"descriptionPlaceholder": "Добавьте описание к файлам...",
"uploadButton": "Отправить {count} файл(ов)",
"uploading": "Отправка..."
"uploading": "Отправка...",
"emailLabelOptional": "Электронная почта (необязательно)",
"nameLabelOptional": "Имя (необязательно)"
},
"success": {
"title": "Файлы успешно отправлены! 🎉",
@@ -1334,7 +1387,9 @@
"cancel": "Отмена",
"preview": "Предпросмотр",
"download": "Скачать",
"delete": "Удалить"
"delete": "Удалить",
"copyToMyFiles": "Скопируйте в мои файлы",
"copying": "Копирование ..."
},
"editField": {
"saveChanges": "Сохранить изменения",
@@ -1342,4 +1397,4 @@
}
}
}
}
}

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",
@@ -654,7 +656,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 +728,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 +744,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 +770,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 +867,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 +1024,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 +1177,9 @@
},
"actions": {
"preview": "Önizle",
"download": "İndir"
"download": "İndir",
"copyToMyFiles": "Dosyalarımı kopyala",
"copying": "Kopyalama ..."
},
"uploadedBy": "{name} tarafından gönderildi",
"anonymous": "Anonim",
@@ -1153,7 +1187,9 @@
"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"
}
},
"form": {
@@ -1214,7 +1250,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!",
@@ -1269,11 +1317,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 +1347,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,7 +1387,9 @@
"cancel": "İptal",
"preview": "Önizle",
"download": "İndir",
"delete": "Sil"
"delete": "Sil",
"copyToMyFiles": "Dosyalarımı kopyala",
"copying": "Kopyalama ..."
},
"editField": {
"saveChanges": "Değişiklikleri kaydet",
@@ -1342,4 +1397,4 @@
}
}
}
}
}

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": "密码",
@@ -654,7 +656,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 +728,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 +744,15 @@
"title": "存储使用情况",
"ariaLabel": "存储使用进度条",
"used": "已使用:",
"available": "可用"
"available": "可用",
"loading": "加载中...",
"retry": "重试",
"errors": {
"title": "存储信息不可用",
"detectionFailed": "无法检测磁盘空间。这可能是由于系统配置问题或权限不足。",
"serverError": "检索存储信息时发生服务器错误。请稍后重试。",
"unknown": "加载存储信息时发生意外错误。"
}
},
"theme": {
"toggle": "切换主题",
@@ -748,6 +770,7 @@
"uploadProgress": "上传进度",
"upload": "上传",
"startUploads": "开始上传",
"retry": "重试",
"finish": "完成",
"success": "文件上传成功",
"allSuccess": "{count, plural, =1 {文件上传成功} other {# 个文件上传成功}}",
@@ -844,6 +867,7 @@
"passwordLength": "密码至少需要8个字符",
"passwordsMatch": "密码不匹配",
"emailRequired": "电子邮件为必填项",
"emailOrUsernameRequired": "电子邮件或用户名是必填项",
"passwordRequired": "密码为必填项",
"nameRequired": "名称为必填项",
"required": "此字段为必填项"
@@ -1000,7 +1024,15 @@
"noFilesLimit": "无文件数限制",
"noSizeLimit": "无大小限制",
"allFileTypes": "所有文件类型",
"fileTypesHelp": "输入不带点的扩展名,用空格、逗号、横线或竖线分隔"
"fileTypesHelp": "输入不带点的扩展名,用空格、逗号、横线或竖线分隔",
"emailFieldRequired": "电子邮件字段",
"fieldOptions": {
"hidden": "隐",
"optional": "选修的",
"required": "必需的"
},
"fieldRequirements": "现场要求",
"nameFieldRequired": "名称字段"
},
"card": {
"untitled": "无标题链接",
@@ -1145,7 +1177,9 @@
},
"actions": {
"preview": "预览",
"download": "下载"
"download": "下载",
"copyToMyFiles": "复制到我的文件",
"copying": "复制..."
},
"uploadedBy": "由 {name} 上传",
"anonymous": "匿名",
@@ -1153,7 +1187,9 @@
"downloadError": "下载文件时出错",
"editSuccess": "文件更新成功",
"editError": "更新文件时出错",
"previewNotAvailable": "预览不可用"
"previewNotAvailable": "预览不可用",
"copyError": "错误将文件复制到您的文件",
"copySuccess": "文件已成功复制到您的文件"
}
},
"form": {
@@ -1214,7 +1250,19 @@
"passwordHelp": "密码至少需要4个字符",
"passwordPlaceholder": "输入密码以保护链接"
},
"submit": "创建接收链接"
"submit": "创建接收链接",
"emailFieldRequired": {
"label": "电子邮件字段要求",
"description": "配置是否应显示上传器电子邮件字段以及是否需要"
},
"fieldRequirements": {
"title": "现场要求",
"description": "配置在上传表单中显示哪些字段"
},
"nameFieldRequired": {
"label": "名称字段要求",
"description": "配置是否应显示上传器名称字段以及是否需要"
}
},
"messages": {
"created": "接收链接创建成功!",
@@ -1269,11 +1317,14 @@
"linkInactive": "此链接已停用。",
"linkExpired": "此链接已过期。",
"uploadFailed": "上传文件时出错",
"retry": "重试",
"fileTooLarge": "文件太大。最大大小:{maxSize}",
"fileTypeNotAllowed": "不允许的文件类型。允许的类型:{allowedTypes}",
"maxFilesExceeded": "最多允许 {maxFiles} 个文件",
"selectAtLeastOneFile": "请至少选择一个文件",
"provideNameOrEmail": "请提供您的姓名或电子邮件"
"provideNameOrEmail": "请提供您的姓名或电子邮件",
"provideEmailRequired": "需要电子邮件",
"provideNameRequired": "需要名称"
},
"fileDropzone": {
"dragActive": "将文件拖放到此处",
@@ -1296,7 +1347,9 @@
"descriptionLabel": "描述(可选)",
"descriptionPlaceholder": "为文件添加描述...",
"uploadButton": "上传 {count} 个文件",
"uploading": "上传中..."
"uploading": "上传中...",
"emailLabelOptional": "电子邮件(可选)",
"nameLabelOptional": "名称(可选)"
},
"success": {
"title": "文件上传成功!🎉",
@@ -1334,7 +1387,9 @@
"cancel": "取消",
"preview": "预览",
"download": "下载",
"delete": "删除"
"delete": "删除",
"copyToMyFiles": "复制到我的文件",
"copying": "复制..."
},
"editField": {
"saveChanges": "保存更改",
@@ -1342,4 +1397,4 @@
}
}
}
}
}

View File

@@ -20,7 +20,13 @@
"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:translate": "python3 scripts/run_translations.py translate",
"translations:help": "python3 scripts/run_translations.py help",
"translations:dry-run": "python3 scripts/run_translations.py all --dry-run"
},
"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,144 @@
#!/usr/bin/env python3
"""
Main script to run all Palmr translation 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 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 translate --delay 2.0\n'
' python3 run_translations.py all # Complete workflow\n',
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument(
'command',
choices=['check', 'sync', 'translate', 'all', 'help'],
help='Command to execute:\n'
'check - Check translation status\n'
'sync - Synchronize missing keys\n'
'translate - Automatically translate strings\n'
'all - Run complete workflow\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("🌐 translate - Automatically translate")
print(" python3 run_translations.py translate")
print(" python3 run_translations.py translate --dry-run")
print(" python3 run_translations.py translate --delay 2.0")
print(" python3 run_translations.py translate --skip-languages pt-BR.json")
print()
print("⚡ all - Complete workflow (sync + translate)")
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 any command to test")
print("• Use --help on any command for specific options")
print("• Read https://docs.palmr.dev/docs/3.0-beta/translation-management for complete documentation")
return 0
elif args.command == 'check':
print("🔍 Checking translation status...")
return run_command('check_translations.py', remaining_args)
elif args.command == 'sync':
print("🔄 Synchronizing translation keys...")
return run_command('sync_translations.py', remaining_args)
elif args.command == 'translate':
print("🌐 Automatically translating strings...")
return run_command('translate_missing.py', remaining_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...")
result = run_command('check_translations.py', remaining_args)
if result != 0:
print("❌ Error in initial check")
return result
print("\n" + "="*50)
# 2. Sync
print("2⃣ Synchronizing missing keys...")
result = run_command('sync_translations.py', remaining_args)
if result != 0:
print("❌ Error in synchronization")
return result
if not is_dry_run:
print("\n" + "="*50)
# 3. Translate
print("3⃣ Automatically translating strings...")
result = run_command('translate_missing.py', remaining_args)
if result != 0:
print("❌ Error in translation")
return result
print("\n" + "="*50)
# 4. Final check
print("4⃣ Final check...")
result = run_command('check_translations.py', remaining_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")
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

@@ -0,0 +1,342 @@
#!/usr/bin/env python3
"""
Script to automatically translate strings marked with [TO_TRANSLATE]
using free Google Translate.
"""
import json
import time
import re
from pathlib import Path
from typing import Dict, Any, List, Tuple, Optional
import argparse
import sys
# Language code mapping from file names to Google Translate codes
LANGUAGE_MAPPING = {
'pt-BR.json': 'pt', # Portuguese (Brazil) -> Portuguese
'es-ES.json': 'es', # Spanish (Spain) -> Spanish
'fr-FR.json': 'fr', # French (France) -> French
'de-DE.json': 'de', # German -> German
'it-IT.json': 'it', # Italian -> Italian
'ru-RU.json': 'ru', # Russian -> Russian
'ja-JP.json': 'ja', # Japanese -> Japanese
'ko-KR.json': 'ko', # Korean -> Korean
'zh-CN.json': 'zh-cn', # Chinese (Simplified) -> Simplified Chinese
'ar-SA.json': 'ar', # Arabic -> Arabic
'hi-IN.json': 'hi', # Hindi -> Hindi
'nl-NL.json': 'nl', # Dutch -> Dutch
'tr-TR.json': 'tr', # Turkish -> Turkish
'pl-PL.json': 'pl', # Polish -> Polish
}
# Prefix to identify untranslated strings
TO_TRANSLATE_PREFIX = '[TO_TRANSLATE] '
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_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_untranslated_strings(data: Dict[str, Any], prefix: str = '') -> List[Tuple[str, str]]:
"""Find all strings marked with [TO_TRANSLATE] recursively."""
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 prefix to get original text
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():
"""Install the googletrans library if not available."""
try:
import googletrans
return True
except ImportError:
print("📦 'googletrans' library not found. Attempting to install...")
import subprocess
try:
subprocess.check_call([sys.executable, "-m", "pip", "install", "googletrans==4.0.0rc1"])
print("✅ googletrans installed successfully!")
return True
except subprocess.CalledProcessError:
print("❌ Failed to install googletrans. Install manually with:")
print("pip install googletrans==4.0.0rc1")
return False
def translate_text(text: str, target_language: str, max_retries: int = 3) -> Optional[str]:
"""Translate text using free Google Translate."""
try:
from googletrans import Translator
translator = Translator()
for attempt in range(max_retries):
try:
# Translate from English to target language
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" ⚠️ Attempt {attempt + 1} failed: {str(e)[:50]}... Retrying in 2s...")
time.sleep(2)
else:
print(f" ❌ Failed after {max_retries} attempts: {str(e)[:50]}...")
return None
except ImportError:
print("❌ googletrans library not available")
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]:
"""
Translate all [TO_TRANSLATE] strings in a file.
Returns: (total_found, successful_translations, failed_translations)
"""
print(f"🔍 Processing: {file_path.name}")
# Load file
data = load_json_file(file_path)
if not data:
return 0, 0, 0
# Find untranslated strings
untranslated_strings = find_untranslated_strings(data)
if not untranslated_strings:
print(f" ✅ No strings to translate")
return 0, 0, 0
print(f" 📝 Found {len(untranslated_strings)} strings to translate")
if dry_run:
print(f" 🔍 [DRY RUN] Strings that would be translated:")
for key, text in untranslated_strings[:3]:
print(f" - {key}: \"{text[:50]}{'...' if len(text) > 50 else ''}\"")
if len(untranslated_strings) > 3:
print(f" ... and {len(untranslated_strings) - 3} more")
return len(untranslated_strings), 0, 0
# Translate each 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)}) Translating: {key_path}")
# Translate text
translated_text = translate_text(original_text, target_language)
if translated_text and translated_text != original_text:
# Update in dictionary
set_nested_value(updated_data, key_path, translated_text)
successful += 1
print(f"\"{original_text[:30]}...\"\"{translated_text[:30]}...\"")
else:
failed += 1
print(f" ❌ Translation failed")
# Delay between requests to avoid rate limiting
if i < len(untranslated_strings): # Don't wait after the last one
time.sleep(delay_between_requests)
# Save updated file
if successful > 0:
if save_json_file(file_path, updated_data):
print(f" 💾 File saved with {successful} translations")
else:
print(f" ❌ Error saving file")
failed += successful # Count as failure if couldn't save
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:
"""Translate all language files that have [TO_TRANSLATE] strings."""
if not install_googletrans():
return
skip_languages = skip_languages or []
# Find language JSON files
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("❌ No language files found")
return
print(f"🌍 Translating {len(language_files)} languages...")
print(f"⏱️ Delay between requests: {delay_between_requests}s")
if dry_run:
print("🔍 DRY RUN MODE - No changes will be made")
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)}] 🌐 Language: {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
# Pause between files (except the last one)
if i < len(language_files) and not dry_run:
print(f" ⏸️ Pausing {delay_between_requests * 2}s before next language...")
time.sleep(delay_between_requests * 2)
# Final summary
print("\n" + "=" * 60)
print("📊 FINAL SUMMARY")
print("=" * 60)
if dry_run:
print(f"🔍 DRY RUN MODE:")
print(f"{total_found} strings would be translated")
else:
print(f"✅ Translations performed:")
print(f"{total_successful} successes")
print(f"{total_failed} failures")
print(f"{total_found} total processed")
if total_successful > 0:
success_rate = (total_successful / total_found) * 100
print(f" • Success rate: {success_rate:.1f}%")
print("\n💡 TIPS:")
print("• Run 'python3 check_translations.py' to verify results")
print("• Strings that failed translation keep the [TO_TRANSLATE] prefix")
print("• Consider reviewing automatic translations to ensure quality")
def main():
parser = argparse.ArgumentParser(
description='Automatically translate strings marked with [TO_TRANSLATE]'
)
parser.add_argument(
'--messages-dir',
type=Path,
default=Path(__file__).parent.parent / 'messages',
help='Directory containing message files (default: ../messages)'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Only show what would be translated without making changes'
)
parser.add_argument(
'--delay',
type=float,
default=1.0,
help='Delay in seconds between translation requests (default: 1.0)'
)
parser.add_argument(
'--skip-languages',
nargs='*',
default=[],
help='List of languages to skip (ex: pt-BR.json fr-FR.json)'
)
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"🔍 Dry run: {args.dry_run}")
print(f"⏱️ Delay: {args.delay}s")
if args.skip_languages:
print(f"⏭️ Skipping: {', '.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

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

@@ -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,16 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { IconCheck, IconDownload, IconEdit, IconEye, IconFile, IconTrash, IconX } from "@tabler/icons-react";
import {
IconCheck,
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";
@@ -16,6 +25,7 @@ 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,
@@ -25,7 +35,6 @@ import { getFileIcon } from "@/utils/file-icons";
import { ReverseShare } from "../hooks/use-reverse-shares";
import { ReverseShareFilePreviewModal } from "./reverse-share-file-preview-modal";
// Types
interface EditingState {
fileId: string;
field: string;
@@ -36,7 +45,6 @@ interface HoverState {
field: string;
}
// Custom Hooks
function useFileEdit() {
const [editingFile, setEditingFile] = useState<EditingState | null>(null);
const [editValue, setEditValue] = useState("");
@@ -74,7 +82,6 @@ function useFileEdit() {
};
}
// Utility Functions
const formatFileSize = (sizeString: string) => {
const sizeInBytes = parseInt(sizeString);
if (sizeInBytes === 0) return "0 B";
@@ -102,6 +109,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 +132,6 @@ const getSenderInitials = (file: ReverseShareFile) => {
return "?";
};
// Components
interface EditableFieldProps {
file: ReverseShareFile;
field: "name" | "description";
@@ -252,6 +261,7 @@ interface FileRowProps {
editValue: string;
inputRef: React.RefObject<HTMLInputElement | null>;
hoveredFile: HoverState | null;
copyingFile: string | null;
onStartEdit: (fileId: string, field: string, currentValue: string) => void;
onSaveEdit: () => void;
onCancelEdit: () => void;
@@ -261,6 +271,7 @@ interface FileRowProps {
onPreview: (file: ReverseShareFile) => void;
onDownload: (file: ReverseShareFile) => void;
onDelete: (file: ReverseShareFile) => void;
onCopy: (file: ReverseShareFile) => void;
}
function FileRow({
@@ -269,6 +280,7 @@ function FileRow({
editValue,
inputRef,
hoveredFile,
copyingFile,
onStartEdit,
onSaveEdit,
onCancelEdit,
@@ -278,6 +290,7 @@ function FileRow({
onPreview,
onDownload,
onDelete,
onCopy,
}: FileRowProps) {
const t = useTranslations();
const { icon: FileIcon, color } = getFileIcon(file.name);
@@ -331,12 +344,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 +365,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,6 +424,7 @@ 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 { editingFile, editValue, setEditValue, inputRef, startEdit, cancelEdit } = useFileEdit();
@@ -481,6 +513,29 @@ export function ReceivedFilesModal({
}
};
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);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
saveEdit();
@@ -554,6 +609,7 @@ export function ReceivedFilesModal({
editValue={editValue}
inputRef={inputRef}
hoveredFile={hoveredFile}
copyingFile={copyingFile}
onStartEdit={startEdit}
onSaveEdit={saveEdit}
onCancelEdit={cancelEdit}
@@ -563,6 +619,7 @@ export function ReceivedFilesModal({
onPreview={handlePreview}
onDownload={handleDownload}
onDelete={handleDeleteFile}
onCopy={handleCopyFile}
/>
))}
</TableBody>

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

@@ -51,11 +51,11 @@ export function usePublicShare() {
await loadShare(password);
};
const handleDownload = async (file: { id: string; name: string }) => {
const handleDownload = async (objectName: string, fileName: string) => {
try {
const response = await getDownloadUrl(file.id);
const encodedObjectName = encodeURIComponent(objectName);
const response = await getDownloadUrl(encodedObjectName);
const downloadUrl = response.data.url;
const fileName = downloadUrl.split("/").pop() || file.name;
const link = document.createElement("a");
link.href = downloadUrl;
@@ -63,6 +63,7 @@ export function usePublicShare() {
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.success(t("share.messages.downloadStarted"));
} catch (error) {
toast.error(t("share.errors.downloadFailed"));
}

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

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

@@ -14,8 +14,8 @@ export default function AuthCallbackPage() {
if (token) {
Cookies.set("token", token, {
path: "/",
secure: false,
sameSite: "strict",
secure: window.location.protocol === "https:",
sameSite: window.location.protocol === "https:" ? "lax" : "strict",
httpOnly: false,
});

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(),
});
@@ -49,6 +49,17 @@ export function useLogin() {
useEffect(() => {
const checkAuth = async () => {
try {
const appInfoResponse = await fetch("/api/app/info");
const appInfo = await appInfoResponse.json();
if (appInfo.firstUserAccess) {
setUser(null);
setIsAdmin(false);
setIsAuthenticated(false);
setIsInitialized(true);
return;
}
const userResponse = await getCurrentUser();
if (!userResponse?.data?.user) {
throw new Error("No user data");

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

@@ -22,6 +22,7 @@ const languages = {
"de-DE": "Deutsch",
"it-IT": "Italiano",
"nl-NL": "Nederlands",
"pl-PL": "Polski",
"tr-TR": "Türkçe (Turkish)",
"ru-RU": "Русский (Russian)",
"hi-IN": "हिन्दी (Hindi)",
@@ -48,7 +49,7 @@ export function LanguageSwitcher() {
maxAge: COOKIE_MAX_AGE,
path: "/",
sameSite: "lax",
secure: false,
secure: window.location.protocol === "https:",
});
router.refresh();

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

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

View File

@@ -0,0 +1,14 @@
import { AspectRatio } from "@/components/ui/aspect-ratio";
interface ImagePreviewProps {
src: string;
alt: string;
}
export function ImagePreview({ src, alt }: ImagePreviewProps) {
return (
<AspectRatio ratio={16 / 9} className="bg-muted">
<img src={src} alt={alt} className="object-contain w-full h-full rounded-md" />
</AspectRatio>
);
}

View File

@@ -0,0 +1,7 @@
export { ImagePreview } from "./image-preview";
export { VideoPreview } from "./video-preview";
export { AudioPreview } from "./audio-preview";
export { PdfPreview } from "./pdf-preview";
export { TextPreview } from "./text-preview";
export { DefaultPreview } from "./default-preview";
export { FilePreviewRenderer } from "./file-preview-render";

View File

@@ -0,0 +1,54 @@
import { useTranslations } from "next-intl";
import { ScrollArea } from "@/components/ui/scroll-area";
interface PdfPreviewProps {
src: string;
fileName: string;
pdfAsBlob: boolean;
pdfLoadFailed: boolean;
onLoadError: () => void;
}
export function PdfPreview({ src, fileName, pdfAsBlob, pdfLoadFailed, onLoadError }: PdfPreviewProps) {
const t = useTranslations();
return (
<ScrollArea className="w-full">
<div className="w-full min-h-[600px] border rounded-lg overflow-hidden bg-card">
{pdfAsBlob ? (
<iframe
src={`${src}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
className="w-full h-full min-h-[600px]"
title={fileName}
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={`${src}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
type="application/pdf"
className="w-full h-full min-h-[600px]"
onError={onLoadError}
>
<iframe
src={`${src}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
className="w-full h-full min-h-[600px]"
title={fileName}
style={{ border: "none" }}
onError={onLoadError}
/>
</object>
</div>
)}
</div>
</ScrollArea>
);
}

View File

@@ -0,0 +1,40 @@
import { useTranslations } from "next-intl";
import { ScrollArea } from "@/components/ui/scroll-area";
import { getFileExtension } from "@/utils/file-types";
interface TextPreviewProps {
content: string | null;
fileName: string;
isLoading?: boolean;
}
export function TextPreview({ content, fileName, isLoading }: TextPreviewProps) {
const t = useTranslations();
const extension = getFileExtension(fileName);
if (isLoading || !content) {
return (
<ScrollArea className="w-full max-h-[600px]">
<div className="w-full border rounded-lg overflow-hidden bg-card">
<div className="flex items-center justify-center h-32">
<div className="flex flex-col items-center gap-2">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
<p className="text-sm text-muted-foreground">{t("filePreview.loading")}</p>
</div>
</div>
</div>
</ScrollArea>
);
}
return (
<ScrollArea className="w-full max-h-[600px]">
<div className="w-full border rounded-lg overflow-hidden bg-card">
<pre className="p-4 text-sm font-mono whitespace-pre-wrap break-words overflow-x-auto">
<code className={`language-${extension || "text"}`}>{content}</code>
</pre>
</div>
</ScrollArea>
);
}

View File

@@ -0,0 +1,20 @@
import { useTranslations } from "next-intl";
interface VideoPreviewProps {
src: string;
}
export function VideoPreview({ src }: VideoPreviewProps) {
const t = useTranslations();
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={src} />
{t("filePreview.videoNotSupported")}
</video>
</div>
</div>
);
}

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