mirror of
https://github.com/kyantech/Palmr.git
synced 2025-10-23 06:11:58 +00:00
Compare commits
68 Commits
v3.0-beta
...
v3.0.0-bet
Author | SHA1 | Date | |
---|---|---|---|
|
719b7f0036 | ||
|
68c565f265 | ||
|
22c5a44af8 | ||
|
4e841b272c | ||
|
6af10c6f33 | ||
|
978a1e5755 | ||
|
c265b8e08d | ||
|
d0173a0bf9 | ||
|
0d346b75cc | ||
|
0a65917cbf | ||
|
f651f50180 | ||
|
1125665bb1 | ||
|
b65aac3044 | ||
|
a865aabed0 | ||
|
561e8faf33 | ||
|
6445b0ce3e | ||
|
90cd3333cb | ||
|
2ca0db70c3 | ||
|
28697fa270 | ||
|
d739c1b213 | ||
|
25a0c39135 | ||
|
185fa4c191 | ||
|
9dfb034c2e | ||
|
936a2b71c7 | ||
|
cd14c28be1 | ||
|
3c084a6686 | ||
|
6a1381684b | ||
|
dc20770fe6 | ||
|
6e526f7f88 | ||
|
858852c8cd | ||
|
363dedbb2c | ||
|
cd215c79b8 | ||
|
98586efbcd | ||
|
c724e644c7 | ||
|
555ff18a87 | ||
|
5100e1591b | ||
|
6de29bbf07 | ||
|
39c47be940 | ||
|
76d96816bc | ||
|
b3e7658a76 | ||
|
61a579aeb3 | ||
|
cc9c375774 | ||
|
016006ba3d | ||
|
cbc567c6a8 | ||
|
25b4d886f7 | ||
|
98953e042b | ||
|
9e06a67593 | ||
|
9682f96905 | ||
|
d2c69c3b36 | ||
|
9afe8292fa | ||
|
e15f50a8a8 | ||
|
8affdc8f95 | ||
|
281eff0f14 | ||
|
b28f1f97c4 | ||
|
c5660b3c6b | ||
|
e64f718998 | ||
|
f00a9dadd0 | ||
|
c262c164d2 | ||
|
1d882252e3 | ||
|
2ea7343e0c | ||
|
54bd987b9a | ||
|
b900953674 | ||
|
d07ebfd01f | ||
|
5b0b01eecd | ||
|
cb87505afd | ||
|
b447204908 | ||
|
4049878cfe | ||
|
13ae0d3b8c |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -29,4 +29,5 @@ apps/server/.env
|
||||
apps/server/dist/*
|
||||
|
||||
#DEFAULT
|
||||
.env
|
||||
.env
|
||||
data/
|
85
Dockerfile
85
Dockerfile
@@ -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"]
|
@@ -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
|
||||
|
||||
|
@@ -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---",
|
||||
|
@@ -137,6 +137,22 @@ The setup process varies depending on your chosen identity provider. Here are ex
|
||||
|
||||

|
||||
|
||||
### 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`
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Testing OIDC configuration
|
||||
|
@@ -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.
|
||||
|
199
apps/docs/content/docs/3.0-beta/reverse-proxy-configuration.mdx
Normal file
199
apps/docs/content/docs/3.0-beta/reverse-proxy-configuration.mdx
Normal 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.
|
525
apps/docs/content/docs/3.0-beta/translation-management.mdx
Normal file
525
apps/docs/content/docs/3.0-beta/translation-management.mdx
Normal 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.
|
222
apps/docs/content/docs/3.0-beta/uid-gid-configuration.mdx
Normal file
222
apps/docs/content/docs/3.0-beta/uid-gid-configuration.mdx
Normal 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.
|
BIN
apps/docs/public/assets/v3/oidc/zitadel-provider-setup.png
Normal file
BIN
apps/docs/public/assets/v3/oidc/zitadel-provider-setup.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 263 KiB |
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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,
|
||||
|
@@ -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);
|
||||
|
@@ -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 });
|
||||
}
|
||||
|
@@ -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 });
|
||||
|
@@ -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>;
|
||||
|
@@ -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,
|
||||
});
|
||||
|
||||
|
@@ -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");
|
||||
}
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
@@ -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",
|
||||
{
|
||||
|
@@ -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",
|
||||
{
|
||||
|
@@ -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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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({
|
||||
|
@@ -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)
|
||||
);
|
||||
}
|
||||
|
@@ -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;
|
||||
|
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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();
|
||||
}
|
||||
|
@@ -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");
|
||||
}
|
||||
};
|
||||
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
@@ -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);
|
||||
}
|
||||
|
@@ -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,
|
||||
});
|
||||
|
45
apps/server/src/utils/container-detection.ts
Normal file
45
apps/server/src/utils/container-detection.ts
Normal 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();
|
@@ -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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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",
|
||||
|
@@ -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",
|
||||
|
@@ -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",
|
||||
|
@@ -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",
|
||||
|
@@ -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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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",
|
||||
|
@@ -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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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
1400
apps/web/messages/pl-PL.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
@@ -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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
342
apps/web/messages/translate_missing.py
Normal file
342
apps/web/messages/translate_missing.py
Normal 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())
|
@@ -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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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",
|
||||
|
231
apps/web/scripts/check_translations.py
Executable file
231
apps/web/scripts/check_translations.py
Executable 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())
|
187
apps/web/scripts/clean_translations.py
Executable file
187
apps/web/scripts/clean_translations.py
Executable 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())
|
144
apps/web/scripts/run_translations.py
Executable file
144
apps/web/scripts/run_translations.py
Executable 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())
|
273
apps/web/scripts/sync_translations.py
Executable file
273
apps/web/scripts/sync_translations.py
Executable 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())
|
342
apps/web/scripts/translate_missing.py
Executable file
342
apps/web/scripts/translate_missing.py
Executable 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())
|
@@ -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>
|
||||
|
@@ -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:
|
||||
|
@@ -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>
|
||||
|
@@ -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 = {
|
||||
|
@@ -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,
|
||||
|
@@ -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
|
||||
|
@@ -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")}
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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);
|
||||
};
|
||||
|
@@ -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">
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
||||
|
@@ -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} />;
|
||||
}
|
||||
|
@@ -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);
|
||||
|
@@ -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"));
|
||||
}
|
||||
|
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@@ -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}
|
||||
|
@@ -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;
|
||||
|
@@ -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") || "",
|
||||
},
|
||||
});
|
||||
|
||||
|
@@ -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;
|
||||
}
|
@@ -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") || "",
|
||||
},
|
||||
});
|
||||
|
||||
|
@@ -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",
|
||||
});
|
||||
|
||||
|
@@ -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,
|
||||
});
|
||||
|
||||
|
@@ -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}
|
||||
|
@@ -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="">
|
||||
|
@@ -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: {
|
||||
|
@@ -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">
|
||||
|
@@ -24,6 +24,8 @@ export interface StorageUsageProps {
|
||||
diskAvailableGB: number;
|
||||
uploadAllowed: boolean;
|
||||
} | null;
|
||||
diskSpaceError?: string | null;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export interface DashboardModalsProps {
|
||||
|
@@ -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")}
|
||||
|
@@ -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");
|
||||
|
@@ -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")),
|
||||
});
|
||||
|
||||
|
@@ -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();
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
13
apps/web/src/components/modals/previews/audio-preview.tsx
Normal file
13
apps/web/src/components/modals/previews/audio-preview.tsx
Normal 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>
|
||||
);
|
||||
}
|
31
apps/web/src/components/modals/previews/default-preview.tsx
Normal file
31
apps/web/src/components/modals/previews/default-preview.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -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} />;
|
||||
}
|
||||
}
|
14
apps/web/src/components/modals/previews/image-preview.tsx
Normal file
14
apps/web/src/components/modals/previews/image-preview.tsx
Normal 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>
|
||||
);
|
||||
}
|
7
apps/web/src/components/modals/previews/index.ts
Normal file
7
apps/web/src/components/modals/previews/index.ts
Normal 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";
|
54
apps/web/src/components/modals/previews/pdf-preview.tsx
Normal file
54
apps/web/src/components/modals/previews/pdf-preview.tsx
Normal 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>
|
||||
);
|
||||
}
|
40
apps/web/src/components/modals/previews/text-preview.tsx
Normal file
40
apps/web/src/components/modals/previews/text-preview.tsx
Normal 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>
|
||||
);
|
||||
}
|
20
apps/web/src/components/modals/previews/video-preview.tsx
Normal file
20
apps/web/src/components/modals/previews/video-preview.tsx
Normal 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
Reference in New Issue
Block a user