mirror of
https://github.com/kyantech/Palmr.git
synced 2025-11-03 13:33:20 +00:00
Compare commits
46 Commits
v3.1.2-bet
...
v3.1.8-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7ae7833ad | ||
|
|
22f34f6f81 | ||
|
|
29efe0a10e | ||
|
|
965c64b468 | ||
|
|
ce57cda672 | ||
|
|
a59857079e | ||
|
|
9ae2a0c628 | ||
|
|
f2c514cd82 | ||
|
|
6755230c53 | ||
|
|
f2a0e60f20 | ||
|
|
6cb21e95c4 | ||
|
|
868add68a5 | ||
|
|
307148d951 | ||
|
|
9cb4235550 | ||
|
|
6014b3e961 | ||
|
|
32f0a891ba | ||
|
|
124ac46eeb | ||
|
|
d3e76c19bf | ||
|
|
dd1ce189ae | ||
|
|
82e43b06c6 | ||
|
|
aab4e6d9df | ||
|
|
1f097678ce | ||
|
|
96cb4a04ec | ||
|
|
b7c4b37e89 | ||
|
|
952cf27ecb | ||
|
|
765810e4e5 | ||
|
|
36d09a7679 | ||
|
|
c6d6648942 | ||
|
|
54ca7580b0 | ||
|
|
4e53d239bb | ||
|
|
6491894f0e | ||
|
|
93e05dd913 | ||
|
|
2efe69e50b | ||
|
|
761865a6a3 | ||
|
|
25fed8db61 | ||
|
|
de42e1ca47 | ||
|
|
138e20d36d | ||
|
|
433610286c | ||
|
|
236f94247a | ||
|
|
1a5c1de510 | ||
|
|
6fb55005d4 | ||
|
|
4779671323 | ||
|
|
e7876739e7 | ||
|
|
e699e30af3 | ||
|
|
7541a2b085 | ||
|
|
24aa605973 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -30,6 +30,7 @@ apps/server/dist/*
|
|||||||
|
|
||||||
#DEFAULT
|
#DEFAULT
|
||||||
.env
|
.env
|
||||||
|
.steering
|
||||||
data/
|
data/
|
||||||
|
|
||||||
node_modules/
|
node_modules/
|
||||||
12
Dockerfile
12
Dockerfile
@@ -82,7 +82,7 @@ RUN addgroup --system --gid ${PALMR_GID} nodejs
|
|||||||
RUN adduser --system --uid ${PALMR_UID} --ingroup nodejs palmr
|
RUN adduser --system --uid ${PALMR_UID} --ingroup nodejs palmr
|
||||||
|
|
||||||
# Create application directories
|
# Create application directories
|
||||||
RUN mkdir -p /app/palmr-app /app/web /home/palmr/.npm /home/palmr/.cache
|
RUN mkdir -p /app/palmr-app /app/web /app/infra /home/palmr/.npm /home/palmr/.cache
|
||||||
RUN chown -R palmr:nodejs /app /home/palmr
|
RUN chown -R palmr:nodejs /app /home/palmr
|
||||||
|
|
||||||
# === Copy Server Files to /app/palmr-app (separate from /app/server for bind mounts) ===
|
# === Copy Server Files to /app/palmr-app (separate from /app/server for bind mounts) ===
|
||||||
@@ -117,10 +117,13 @@ WORKDIR /app
|
|||||||
# Create supervisor configuration
|
# Create supervisor configuration
|
||||||
RUN mkdir -p /etc/supervisor/conf.d
|
RUN mkdir -p /etc/supervisor/conf.d
|
||||||
|
|
||||||
# Copy server start script
|
# Copy server start script and configuration files
|
||||||
COPY infra/server-start.sh /app/server-start.sh
|
COPY infra/server-start.sh /app/server-start.sh
|
||||||
|
COPY infra/configs.json /app/infra/configs.json
|
||||||
|
COPY infra/providers.json /app/infra/providers.json
|
||||||
|
COPY infra/check-missing.js /app/infra/check-missing.js
|
||||||
RUN chmod +x /app/server-start.sh
|
RUN chmod +x /app/server-start.sh
|
||||||
RUN chown palmr:nodejs /app/server-start.sh
|
RUN chown -R palmr:nodejs /app/server-start.sh /app/infra
|
||||||
|
|
||||||
# Copy supervisor configuration
|
# Copy supervisor configuration
|
||||||
COPY infra/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
COPY infra/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||||
@@ -133,11 +136,12 @@ set -e
|
|||||||
echo "Starting Palmr Application..."
|
echo "Starting Palmr Application..."
|
||||||
echo "Storage Mode: \${ENABLE_S3:-false}"
|
echo "Storage Mode: \${ENABLE_S3:-false}"
|
||||||
echo "Secure Site: \${SECURE_SITE:-false}"
|
echo "Secure Site: \${SECURE_SITE:-false}"
|
||||||
echo "Encryption: \${DISABLE_FILESYSTEM_ENCRYPTION:-false}"
|
echo "Encryption: \${DISABLE_FILESYSTEM_ENCRYPTION:-true}"
|
||||||
echo "Database: SQLite"
|
echo "Database: SQLite"
|
||||||
|
|
||||||
# Set global environment variables
|
# Set global environment variables
|
||||||
export DATABASE_URL="file:/app/server/prisma/palmr.db"
|
export DATABASE_URL="file:/app/server/prisma/palmr.db"
|
||||||
|
export NEXT_PUBLIC_DEFAULT_LANGUAGE=\${DEFAULT_LANGUAGE:-en-US}
|
||||||
|
|
||||||
# Ensure /app/server directory exists for bind mounts
|
# Ensure /app/server directory exists for bind mounts
|
||||||
mkdir -p /app/server/uploads /app/server/temp-uploads /app/server/prisma
|
mkdir -p /app/server/uploads /app/server/temp-uploads /app/server/prisma
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ services:
|
|||||||
```bash
|
```bash
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name palmr \
|
--name palmr \
|
||||||
-e ENABLE_S3=false \
|
|
||||||
-e ENCRYPTION_KEY=change-this-key-in-production-min-32-chars \
|
|
||||||
-p 5487:5487 \
|
-p 5487:5487 \
|
||||||
-p 3333:3333 \
|
-p 3333:3333 \
|
||||||
-v palmr_data:/app/server \
|
-v palmr_data:/app/server \
|
||||||
|
|||||||
@@ -43,6 +43,16 @@ Palmr. uses **filesystem storage** as the default storage solution, keeping thin
|
|||||||
- Excellent performance for local file operations
|
- Excellent performance for local file operations
|
||||||
- Optional S3-compatible storage support for cloud deployments and scalability
|
- Optional S3-compatible storage support for cloud deployments and scalability
|
||||||
|
|
||||||
|
#### Performance Considerations with Encryption
|
||||||
|
|
||||||
|
By default, filesystem storage operates without encryption for optimal performance, providing fast uploads and downloads with minimal CPU overhead. This approach is ideal for most use cases where performance is prioritized.
|
||||||
|
|
||||||
|
If you need to protect sensitive files at rest, you can enable encryption by setting `DISABLE_FILESYSTEM_ENCRYPTION=false` and providing an `ENCRYPTION_KEY` in your configuration. When enabled, Palmr uses AES-256-CBC encryption, which adds CPU overhead during uploads (encryption) and downloads (decryption), particularly for large files or in resource-constrained environments like containers or low-end VMs.
|
||||||
|
|
||||||
|
For optimal performance with encryption enabled, ensure your hardware supports AES-NI acceleration (check with `cat /proc/cpuinfo | grep aes` on Linux).
|
||||||
|
|
||||||
|
As an alternative, consider using S3-compatible object storage (e.g., AWS S3 or MinIO), which can offload file storage from the local filesystem and potentially reduce local CPU overhead for encryption/decryption. See [S3 Providers](/docs/3.1-beta/s3-providers) for setup instructions.
|
||||||
|
|
||||||
### Fastify + Zod + TypeScript
|
### Fastify + Zod + TypeScript
|
||||||
|
|
||||||
The backend of Palmr. is powered by **Fastify**, **Zod**, and **TypeScript**, creating a robust and type-safe API layer. Fastify is a super-fast Node.js web framework optimized for performance and low overhead, designed to handle lots of concurrent requests with minimal resource usage. Zod provides runtime type validation and schema definition, ensuring all incoming data is properly validated before reaching business logic. TypeScript adds compile-time type safety throughout the entire backend codebase. This combination creates a highly reliable and maintainable backend that prevents bugs and security issues while maintaining excellent performance.
|
The backend of Palmr. is powered by **Fastify**, **Zod**, and **TypeScript**, creating a robust and type-safe API layer. Fastify is a super-fast Node.js web framework optimized for performance and low overhead, designed to handle lots of concurrent requests with minimal resource usage. Zod provides runtime type validation and schema definition, ensuring all incoming data is properly validated before reaching business logic. TypeScript adds compile-time type safety throughout the entire backend codebase. This combination creates a highly reliable and maintainable backend that prevents bugs and security issues while maintaining excellent performance.
|
||||||
|
|||||||
@@ -47,12 +47,12 @@ docker exec -it <container_name_or_id> /bin/sh
|
|||||||
|
|
||||||
Replace `<container_name_or_id>` with the name or ID of your Palmr container. This command opens an interactive shell session inside the container, allowing you to execute commands directly.
|
Replace `<container_name_or_id>` with the name or ID of your Palmr container. This command opens an interactive shell session inside the container, allowing you to execute commands directly.
|
||||||
|
|
||||||
### 3. Navigate to the server directory
|
### 3. Navigate to the application directory
|
||||||
|
|
||||||
Once inside the container, navigate to the server directory where the reset script is located:
|
Once inside the container, navigate to the application directory where the reset script is located:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /app/server
|
cd /app/palmr-app
|
||||||
```
|
```
|
||||||
|
|
||||||
This directory contains the necessary scripts and configurations for managing Palmr's backend operations.
|
This directory contains the necessary scripts and configurations for managing Palmr's backend operations.
|
||||||
@@ -135,11 +135,11 @@ If you encounter issues while running the script, refer to the following solutio
|
|||||||
- Confirm that the `prisma/palmr.db` file exists and has the correct permissions.
|
- Confirm that the `prisma/palmr.db` file exists and has the correct permissions.
|
||||||
- Verify that the container has access to the database volume.
|
- Verify that the container has access to the database volume.
|
||||||
|
|
||||||
- **Error: "Script must be run from server directory"**
|
- **Error: "Script must be run from application directory"**
|
||||||
This error appears if you are not in the correct directory. Navigate to the server directory with:
|
This error appears if you are not in the correct directory. Navigate to the application directory with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /app/server
|
cd /app/palmr-app
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Error: "User not found"**
|
- **Error: "User not found"**
|
||||||
|
|||||||
@@ -3,215 +3,250 @@ title: Quick Start (Docker)
|
|||||||
icon: "Rocket"
|
icon: "Rocket"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
import { Callout } from "fumadocs-ui/components/callout";
|
||||||
|
import { Tab, Tabs } from "fumadocs-ui/components/tabs";
|
||||||
|
|
||||||
|
import { Card, CardGrid } from "@/components/ui/card";
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
Ensure you have the following installed on your system:
|
Before you begin, make sure you have:
|
||||||
|
|
||||||
- **Docker** - Container runtime ([installation guide](https://docs.docker.com/get-docker/))
|
- **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 Compose** - Multi-container orchestration ([installation guide](https://docs.docker.com/compose/install/))
|
||||||
|
- **2GB+ available disk space** for the application and your files
|
||||||
|
- **Port 5487** available for the web interface
|
||||||
|
- **Port 3333** available for API access (optional)
|
||||||
|
|
||||||
> **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).
|
<Callout>
|
||||||
|
**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).
|
||||||
|
</Callout>
|
||||||
|
|
||||||
## Storage Options
|
## Storage Options
|
||||||
|
|
||||||
Palmr. supports two storage approaches for persistent data:
|
Palmr. supports two storage approaches for persistent data:
|
||||||
|
|
||||||
### Named Volumes (Recommended)
|
- **Named Volumes (Recommended)** - Docker-managed storage with optimal performance and no permission issues
|
||||||
|
- **Bind Mounts** - Direct host filesystem access, ideal for development and direct file management
|
||||||
|
|
||||||
**Best for**: Production environments, automated deployments
|
## Deployment Options
|
||||||
|
|
||||||
- ✅ **Managed by Docker**: No permission issues or manual path management
|
Choose your storage method based on your needs:
|
||||||
- ✅ **Optimized Performance**: Docker-native storage optimization
|
|
||||||
- ✅ **Cross-platform**: Consistent behavior across operating systems
|
|
||||||
- ✅ **Simplified Backups**: Docker volume commands for backup/restore
|
|
||||||
|
|
||||||
### Bind Mounts
|
<Tabs items={['Named Volumes (Recommended)', 'Bind Mounts']}>
|
||||||
|
<Tab value="Named Volumes (Recommended)">
|
||||||
|
Docker-managed storage that provides the best balance of performance, security, and ease of use:
|
||||||
|
|
||||||
**Best for**: Development, direct file access requirements
|
- **No Permission Issues**: Docker handles all permission management automatically
|
||||||
|
- **Performance**: Optimized for container workloads with better I/O performance
|
||||||
|
- **Production Ready**: Recommended for production deployments
|
||||||
|
|
||||||
- ✅ **Direct Access**: Files stored in local directory you specify
|
### Configuration
|
||||||
- ✅ **Transparent Storage**: Direct filesystem access from host
|
|
||||||
- ✅ **Custom Backup**: Use existing file system backup solutions
|
|
||||||
- ⚠️ **Permission Considerations**: **Common Issue** - Requires UID/GID configuration (see troubleshooting below)
|
|
||||||
|
|
||||||
---
|
Create a `docker-compose.yml` file:
|
||||||
|
|
||||||
## Option 1: Named Volumes (Recommended)
|
```yaml
|
||||||
|
services:
|
||||||
Named volumes provide the best performance and are managed entirely by Docker.
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
Use the provided `docker-compose.yaml` for named volumes:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
palmr:
|
palmr:
|
||||||
image: kyantech/palmr:latest
|
image: kyantech/palmr:latest
|
||||||
container_name: palmr
|
container_name: palmr
|
||||||
environment:
|
restart: unless-stopped
|
||||||
- ENABLE_S3=false
|
|
||||||
- ENCRYPTION_KEY=change-this-key-in-production-min-32-chars # CHANGE THIS KEY FOR SECURITY
|
|
||||||
# - DISABLE_FILESYSTEM_ENCRYPTION=false # Set to true to disable file encryption (ENCRYPTION_KEY becomes optional)
|
|
||||||
# - SECURE_SITE=false # Set to true if you are using a reverse proxy
|
|
||||||
ports:
|
ports:
|
||||||
- "5487:5487" # Web interface
|
- "5487:5487" # Web interface
|
||||||
- "3333:3333" # API port (OPTIONAL EXPOSED - ONLY IF YOU WANT TO ACCESS THE API DIRECTLY)
|
# - "3333:3333" # API (optional)
|
||||||
|
environment:
|
||||||
|
# Optional: Uncomment and configure as needed (if you don`t use, you can remove)
|
||||||
|
# - ENABLE_S3=true # Set to true to enable S3-compatible storage
|
||||||
|
# - DISABLE_FILESYSTEM_ENCRYPTION=true # Set to false to enable file encryption
|
||||||
|
# - ENCRYPTION_KEY=your-secure-key-min-32-chars # Required only if encryption is enabled
|
||||||
|
# - PALMR_UID=1000 # UID for the container processes (default is 1000)
|
||||||
|
# - PALMR_GID=1000 # GID for the container processes (default is 1000)
|
||||||
|
# - SECURE_SITE=false # Set to true if you are using a reverse proxy
|
||||||
|
# - DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US)
|
||||||
volumes:
|
volumes:
|
||||||
- palmr_data:/app/server # Named volume for the application data
|
- palmr_data:/app/server
|
||||||
restart: unless-stopped # Restart the container unless it is stopped
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
palmr_data:
|
palmr_data:
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** If you haveing problem with uploading files, try to change the `PALMR_UID` and `PALMR_GID` to the UID and GID of the user running the container. You can find the UID and GID of the user running the container with the command `id -u` and `id -g`. in Linux systems the default user is `1000` and the default group is `1000`. For test you can add the environment variables below to the `docker-compose.yaml` file and restart the container.
|
<Callout type="info">
|
||||||
|
**Having upload or permission issues?** Add `PALMR_UID=1000` and `PALMR_GID=1000` to your environment variables. Check our [UID/GID Configuration](/docs/3.1-beta/uid-gid-configuration) guide for more details.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
```yaml
|
### Deploy
|
||||||
environment:
|
|
||||||
- PALMR_UID=1000 # UID for the container processes (default is 1001)
|
|
||||||
- PALMR_GID=1000 # GID for the container processes (default is 1001)
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Note:** For more information about UID and GID, see our [UID/GID Configuration](/docs/3.1-beta/uid-gid-configuration) guide.
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
### Deployment
|
</Tab>
|
||||||
|
<Tab value="Bind Mounts">
|
||||||
|
Direct mapping to host filesystem directories, providing direct file access:
|
||||||
|
|
||||||
```bash
|
- **Direct Access**: Files are directly accessible from your host system
|
||||||
docker-compose up -d
|
- **Development Friendly**: Easy to inspect, modify, or backup files manually
|
||||||
```
|
- **Platform Dependent**: May require UID/GID configuration, especially on NAS systems
|
||||||
|
|
||||||
---
|
### Configuration
|
||||||
|
|
||||||
## Option 2: Bind Mounts
|
Create a `docker-compose.yml` file:
|
||||||
|
|
||||||
Bind mounts store data in a local directory, providing direct file system access.
|
```yaml
|
||||||
|
services:
|
||||||
### 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:
|
palmr:
|
||||||
image: kyantech/palmr:latest
|
image: kyantech/palmr:latest
|
||||||
container_name: palmr
|
container_name: palmr
|
||||||
environment:
|
restart: unless-stopped
|
||||||
- ENABLE_S3=false
|
|
||||||
- ENCRYPTION_KEY=change-this-key-in-production-min-32-chars # CHANGE THIS KEY FOR SECURITY
|
|
||||||
- PALMR_UID=1000 # UID for the container processes (default is 1001)
|
|
||||||
- PALMR_GID=1000 # GID for the container processes (default is 1001)
|
|
||||||
# - DISABLE_FILESYSTEM_ENCRYPTION=false # Set to true to disable file encryption (ENCRYPTION_KEY becomes optional)
|
|
||||||
# - SECURE_SITE=false # Set to true if you are using a reverse proxy
|
|
||||||
ports:
|
ports:
|
||||||
- "5487:5487" # Web port
|
- "5487:5487" # Web interface
|
||||||
- "3333:3333" # API port (OPTIONAL EXPOSED - ONLY IF YOU WANT TO ACCESS THE API DIRECTLY)
|
# - "3333:3333" # API (optional)
|
||||||
|
environment:
|
||||||
|
# Optional: Uncomment and configure as needed (if you don`t use, you can remove)
|
||||||
|
# - ENABLE_S3=true # Set to true to enable S3-compatible storage
|
||||||
|
# - DISABLE_FILESYSTEM_ENCRYPTION=false # Set to false to enable file encryption
|
||||||
|
# - ENCRYPTION_KEY=your-secure-key-min-32-chars # Required only if encryption is enabled
|
||||||
|
# - PALMR_UID=1000 # UID for the container processes (default is 1000)
|
||||||
|
# - PALMR_GID=1000 # GID for the container processes (default is 1000)
|
||||||
|
# - SECURE_SITE=false # Set to true if you are using a reverse proxy
|
||||||
|
# - DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US)
|
||||||
volumes:
|
volumes:
|
||||||
# Bind mount for persistent data (uploads, database, temp files)
|
- ./data:/app/server
|
||||||
- ./data:/app/server # Local directory for the application data
|
```
|
||||||
restart: unless-stopped # Restart the container unless it is stopped
|
|
||||||
```
|
|
||||||
|
|
||||||
### Deployment
|
<Callout type="info">
|
||||||
|
**Having upload or permission issues?** Add `PALMR_UID=1000` and `PALMR_GID=1000` to your environment variables. Check our [UID/GID Configuration](/docs/3.1-beta/uid-gid-configuration) guide for more details.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
```bash
|
### Deploy
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Permission Configuration**: If you encounter permission issues with bind mounts (common on NAS systems), see our [UID/GID Configuration](/docs/3.1-beta/uid-gid-configuration) guide for automatic permission handling.
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
---
|
</Tab>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
## Environment Variables
|
## Configuration
|
||||||
|
|
||||||
Configure Palmr. behavior through environment variables:
|
Customize Palmr's behavior with these environment variables:
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
| ------------------------------- | ------- | ------------------------------------------------------------------------------------ |
|
| ------------------------------- | ------- | -------------------------------------------------------------------------------------------- |
|
||||||
| `ENABLE_S3` | `false` | Enable S3-compatible storage |
|
| `ENABLE_S3` | `false` | Enable S3-compatible storage backends |
|
||||||
| `ENCRYPTION_KEY` | - | **Required** (unless encryption disabled): Minimum 32 characters for file encryption |
|
| `ENCRYPTION_KEY` | - | **Required when encryption is enabled**: 32+ character key for file encryption |
|
||||||
| `DISABLE_FILESYSTEM_ENCRYPTION` | `false` | Disable file encryption for direct filesystem access |
|
| `DISABLE_FILESYSTEM_ENCRYPTION` | `true` | Disable file encryption for better performance (set to `false` to enable encryption) |
|
||||||
| `SECURE_SITE` | `false` | Enable secure cookies for HTTPS/reverse proxy setups |
|
| `SECURE_SITE` | `false` | Enable secure cookies for HTTPS/reverse proxy deployments |
|
||||||
|
| `DEFAULT_LANGUAGE` | `en-US` | Default application language ([see available languages](/docs/3.1-beta/available-languages)) |
|
||||||
|
| `PALMR_UID` | `1000` | User ID for container processes (helps with file permissions) |
|
||||||
|
| `PALMR_GID` | `1000` | Group ID for container processes (helps with file permissions) |
|
||||||
|
|
||||||
> **⚠️ Security Warning**: Always change the `ENCRYPTION_KEY` in production when encryption is enabled. This key encrypts your files - losing it makes files permanently inaccessible.
|
<Callout type="info">
|
||||||
|
**Performance First**: Palmr runs without encryption by default for optimal speed and lower resource usage—perfect for
|
||||||
|
most use cases.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
> **🔓 File Encryption Control**: The `DISABLE_FILESYSTEM_ENCRYPTION` variable allows you to store files without encryption for direct filesystem access. When set to `true`, the `ENCRYPTION_KEY` becomes optional. **Important**: Once set, this configuration is permanent for your deployment. Switching between encrypted and unencrypted modes will break file access for existing uploads. Choose your strategy before uploading files.
|
<Callout type="warn">
|
||||||
|
**Encryption Notice**: To enable encryption, set `DISABLE_FILESYSTEM_ENCRYPTION=false` and provide a 32+ character
|
||||||
|
`ENCRYPTION_KEY`. **Important**: This choice is permanent—switching encryption modes after uploading files will break
|
||||||
|
access to existing uploads.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
> **🔗 Reverse Proxy**: If deploying behind a reverse proxy (Traefik, Nginx, etc.), set `SECURE_SITE=true` and review our [Reverse Proxy Configuration](/docs/3.1-beta/reverse-proxy-configuration) guide for proper setup.
|
<Callout>
|
||||||
|
**Using a Reverse Proxy?** Set `SECURE_SITE=true` and check our [Reverse Proxy
|
||||||
|
Configuration](/docs/3.1-beta/reverse-proxy-configuration) guide for proper HTTPS setup.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
### Generate Secure Encryption Keys
|
### Generate Encryption Keys (Optional)
|
||||||
|
|
||||||
Need a strong key for `ENCRYPTION_KEY`? Use our built-in generator to create cryptographically secure keys:
|
Need file encryption? Generate a secure key:
|
||||||
|
|
||||||
<KeyGenerator />
|
<KeyGenerator />
|
||||||
|
|
||||||
> **💡 Pro Tip**: If you're using `DISABLE_FILESYSTEM_ENCRYPTION=true`, you can skip the `ENCRYPTION_KEY` entirely for a simpler setup. However, remember that files will be stored unencrypted on your filesystem.
|
> **Pro Tip**: Only enable encryption if you're handling sensitive data. For most users, the default unencrypted mode provides better performance.
|
||||||
|
|
||||||
---
|
## Access Your Instance
|
||||||
|
|
||||||
## Accessing Palmr.
|
Once deployed, open Palmr in your browser:
|
||||||
|
|
||||||
Once deployed, access Palmr. through your web browser:
|
- **Web Interface**: `http://localhost:5487` (local) or `http://YOUR_SERVER_IP:5487` (remote)
|
||||||
|
- **API Documentation**: `http://localhost:3333/docs` (if port 3333 is exposed)
|
||||||
|
|
||||||
- **Local**: `http://localhost:5487`
|
<Callout type="info">
|
||||||
- **Server**: `http://YOUR_SERVER_IP:5487`
|
**Learn More**: For complete API documentation, authentication, and integration examples, see our [API
|
||||||
|
Reference](/docs/3.1-beta/api) guide
|
||||||
|
</Callout>
|
||||||
|
|
||||||
### API Access (Optional)
|
<Callout type="warn">
|
||||||
|
**Production Ready?** Configure HTTPS with a valid SSL certificate for secure production deployments.
|
||||||
If you exposed port 3333 in your configuration, you can also access:
|
</Callout>
|
||||||
|
|
||||||
- **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.1-beta/api) guide.
|
|
||||||
|
|
||||||
> **💡 Production Tip**: For production deployments, configure HTTPS with a valid SSL certificate for enhanced security.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Docker CLI Alternative
|
## Docker CLI Alternative
|
||||||
|
|
||||||
Prefer using Docker directly? Both storage options are supported:
|
Prefer Docker commands over Compose? Here are the equivalent commands:
|
||||||
|
|
||||||
**Named Volume:**
|
<Tabs items={["Named Volume", "Bind Mount"]}>
|
||||||
|
<Tab value="Named Volume">
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name palmr \
|
--name palmr \
|
||||||
-e ENABLE_S3=false \
|
# Optional: Uncomment and configure as needed (if you don`t use, you can remove)
|
||||||
-e ENCRYPTION_KEY=your-secure-key-min-32-chars \
|
# -e ENABLE_S3=true \ # Set to true to enable S3-compatible storage (OPTIONAL - default is false)
|
||||||
# -e DISABLE_FILESYSTEM_ENCRYPTION=true # Uncomment to disable file encryption (ENCRYPTION_KEY becomes optional)
|
# -e DISABLE_FILESYSTEM_ENCRYPTION=false \ # Set to false to enable file encryption (ENCRYPTION_KEY becomes required) | (OPTIONAL - default is true)
|
||||||
|
# -e ENCRYPTION_KEY=your-secure-key-min-32-chars # Required only if encryption is enabled
|
||||||
|
# -e PALMR_UID=1000 # UID for the container processes (default is 1000)
|
||||||
|
# -e PALMR_GID=1000 # GID for the container processes (default is 1000)
|
||||||
# -e SECURE_SITE=false # Set to true if you are using a reverse proxy
|
# -e SECURE_SITE=false # Set to true if you are using a reverse proxy
|
||||||
|
# -e DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US)
|
||||||
-p 5487:5487 \
|
-p 5487:5487 \
|
||||||
-p 3333:3333 \
|
-p 3333:3333 \
|
||||||
-v palmr_data:/app/server \
|
-v palmr_data:/app/server \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
kyantech/palmr:latest
|
kyantech/palmr:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Permission Configuration**: If you encounter permission issues with bind mounts (common on NAS systems), see our [UID/GID Configuration](/docs/3.1-beta/uid-gid-configuration) guide for automatic permission handling.
|
|
||||||
|
|
||||||
**Bind Mount:**
|
<Callout type="info">
|
||||||
|
**Permission Issues?** Add `-e PALMR_UID=1000 -e PALMR_GID=1000` to the command above. See our [UID/GID Configuration](/docs/3.1-beta/uid-gid-configuration) guide for details.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
```bash
|
</Tab>
|
||||||
docker run -d \
|
|
||||||
|
<Tab value="Bind Mount">
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
--name palmr \
|
--name palmr \
|
||||||
-e ENABLE_S3=false \
|
# Optional: Uncomment and configure as needed (if you don`t use, you can remove)
|
||||||
-e ENCRYPTION_KEY=your-secure-key-min-32-chars \
|
# -e ENABLE_S3=true \ # Set to true to enable S3-compatible storage (OPTIONAL - default is false)
|
||||||
-e PALMR_UID=1000 # UID for the container processes (default is 1001)
|
# -e DISABLE_FILESYSTEM_ENCRYPTION=true \ # Set to false to enable file encryption (ENCRYPTION_KEY becomes required) | (OPTIONAL - default is true)
|
||||||
-e PALMR_GID=1000 # GID for the container processes (default is 1001)
|
# -e ENCRYPTION_KEY=your-secure-key-min-32-chars # Required only if encryption is enabled
|
||||||
# -e DISABLE_FILESYSTEM_ENCRYPTION=true # Uncomment to disable file encryption (ENCRYPTION_KEY becomes optional)
|
# -e PALMR_UID=1000 # UID for the container processes (default is 1000)
|
||||||
|
# -e PALMR_GID=1000 # GID for the container processes (default is 1000)
|
||||||
# -e SECURE_SITE=false # Set to true if you are using a reverse proxy
|
# -e SECURE_SITE=false # Set to true if you are using a reverse proxy
|
||||||
|
# -e DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US)
|
||||||
-p 5487:5487 \
|
-p 5487:5487 \
|
||||||
-p 3333:3333 \
|
-p 3333:3333 \
|
||||||
-v $(pwd)/data:/app/server \
|
-v $(pwd)/data:/app/server \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
kyantech/palmr:latest
|
kyantech/palmr:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
<Callout type="info">
|
||||||
|
**Permission Issues?** Add `-e PALMR_UID=1000 -e PALMR_GID=1000` to the command above. See our [UID/GID Configuration](/docs/3.1-beta/uid-gid-configuration) guide for details.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
</Tab>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -219,52 +254,50 @@ docker run -d \
|
|||||||
|
|
||||||
### Updates
|
### Updates
|
||||||
|
|
||||||
Keep Palmr. current with the latest features and security fixes:
|
Keep Palmr up to date with the latest features and security patches:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose pull
|
docker-compose pull
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### Backup & Restore
|
### Backup Your Data
|
||||||
|
|
||||||
The backup method depends on which storage option you're using:
|
**Named Volumes:**
|
||||||
|
|
||||||
**Named Volume Backup:**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run --rm \
|
docker run --rm -v palmr_data:/data -v $(pwd):/backup alpine tar czf /backup/palmr-backup.tar.gz -C /data .
|
||||||
-v palmr_data:/data \
|
|
||||||
-v $(pwd):/backup \
|
|
||||||
alpine tar czf /backup/palmr-backup.tar.gz -C /data .
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Named Volume Restore:**
|
**Bind Mounts:**
|
||||||
|
|
||||||
```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
|
```bash
|
||||||
tar czf palmr-backup.tar.gz ./data
|
tar czf palmr-backup.tar.gz ./data
|
||||||
```
|
```
|
||||||
|
|
||||||
**Bind Mount Restore:**
|
### Restore From Backup
|
||||||
|
|
||||||
|
**Named Volumes:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
docker-compose down
|
||||||
|
docker run --rm -v palmr_data:/data -v $(pwd):/backup alpine tar xzf /backup/palmr-backup.tar.gz -C /data
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**Bind Mounts:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose down
|
||||||
tar xzf palmr-backup.tar.gz
|
tar xzf palmr-backup.tar.gz
|
||||||
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Next Steps
|
## What's Next?
|
||||||
|
|
||||||
Your Palmr. instance is now ready! Explore additional configuration options:
|
Your Palmr instance is ready! Here's what you can explore:
|
||||||
|
|
||||||
### Advanced Configuration
|
### Advanced Configuration
|
||||||
|
|
||||||
@@ -275,8 +308,11 @@ Your Palmr. instance is now ready! Explore additional configuration options:
|
|||||||
### Integration & Development
|
### Integration & Development
|
||||||
|
|
||||||
- **[API Reference](/docs/3.1-beta/api)** - Integrate Palmr. with your applications
|
- **[API Reference](/docs/3.1-beta/api)** - Integrate Palmr. with your applications
|
||||||
- **[Architecture Guide](/docs/3.1-beta/architecture)** - Understanding Palmr. components and design
|
|
||||||
|
<Callout type="info">
|
||||||
|
**Need help?** Check our [Troubleshooting Guide](/docs/3.1-beta/troubleshooting) for common issues and solutions.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Need help? Visit our [GitHub Issues](https://github.com/kyantech/Palmr/issues) or community discussions.
|
**Questions?** Visit our [GitHub Issues](https://github.com/kyantech/Palmr/issues) or join the community discussions.
|
||||||
|
|||||||
@@ -127,7 +127,8 @@ proxy_pass_header Set-Cookie;
|
|||||||
environment:
|
environment:
|
||||||
- PALMR_UID=1000 # Your host UID (check with: id)
|
- PALMR_UID=1000 # Your host UID (check with: id)
|
||||||
- PALMR_GID=1000 # Your host GID
|
- PALMR_GID=1000 # Your host GID
|
||||||
- ENCRYPTION_KEY=your-key-here
|
- DISABLE_FILESYSTEM_ENCRYPTION=true # Set to false to enable file encryption
|
||||||
|
# - ENCRYPTION_KEY=your-key-here # Required only if encryption is enabled
|
||||||
```
|
```
|
||||||
|
|
||||||
> **💡 Note**: Check your host UID/GID with `id` command and use those values. See [UID/GID Configuration](/docs/3.1-beta/uid-gid-configuration) for detailed setup.
|
> **💡 Note**: Check your host UID/GID with `id` command and use those values. See [UID/GID Configuration](/docs/3.1-beta/uid-gid-configuration) for detailed setup.
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ docker-compose logs palmr | grep -i "permission\|denied\|eacces"
|
|||||||
|
|
||||||
# Common error messages:
|
# Common error messages:
|
||||||
# EACCES: permission denied, open '/app/server/uploads/file.txt'
|
# EACCES: permission denied, open '/app/server/uploads/file.txt'
|
||||||
# Error: EACCES: permission denied, mkdir '/app/server/temp-chunks'
|
# Error: EACCES: permission denied, mkdir '/app/server/temp-uploads'
|
||||||
```
|
```
|
||||||
|
|
||||||
### The Root Cause
|
### The Root Cause
|
||||||
@@ -25,7 +25,7 @@ docker-compose logs palmr | grep -i "permission\|denied\|eacces"
|
|||||||
**Palmr. defaults**: UID 1001, GID 1001
|
**Palmr. defaults**: UID 1001, GID 1001
|
||||||
**Linux standard**: UID 1000, GID 1000
|
**Linux standard**: UID 1000, GID 1000
|
||||||
|
|
||||||
When using bind mounts, your host directories are owned by UID 1000, but Palmr. runs as UID 1001.
|
When using bind mounts, your host directories may have different ownership than Palmr's default UID/GID.
|
||||||
|
|
||||||
### Solution 1: Environment Variables (Recommended)
|
### Solution 1: Environment Variables (Recommended)
|
||||||
|
|
||||||
@@ -63,8 +63,8 @@ If you prefer to keep Palmr's defaults:
|
|||||||
chown -R 1001:1001 ./data
|
chown -R 1001:1001 ./data
|
||||||
|
|
||||||
# For separate upload/temp directories
|
# For separate upload/temp directories
|
||||||
mkdir -p uploads temp-chunks
|
mkdir -p uploads temp-uploads
|
||||||
chown -R 1001:1001 uploads temp-chunks
|
chown -R 1001:1001 uploads temp-uploads
|
||||||
```
|
```
|
||||||
|
|
||||||
### Solution 3: Docker Volume (Avoid the Issue)
|
### Solution 3: Docker Volume (Avoid the Issue)
|
||||||
@@ -109,16 +109,19 @@ docker-compose logs palmr
|
|||||||
2. **Invalid encryption key**
|
2. **Invalid encryption key**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Error: Encryption key must be at least 32 characters
|
# Error: Encryption key must be at least 32 characters (only if encryption is enabled)
|
||||||
# Fix: Update ENCRYPTION_KEY in docker-compose.yaml
|
# Fix: Either disable encryption or provide a valid key
|
||||||
environment:
|
environment:
|
||||||
- ENCRYPTION_KEY=your-very-long-secure-key-at-least-32-characters
|
- DISABLE_FILESYSTEM_ENCRYPTION=true # Disable encryption (default)
|
||||||
|
# OR enable encryption with:
|
||||||
|
# - DISABLE_FILESYSTEM_ENCRYPTION=false
|
||||||
|
# - ENCRYPTION_KEY=your-very-long-secure-key-at-least-32-characters
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Missing environment variables**
|
3. **Missing environment variables**
|
||||||
```bash
|
```bash
|
||||||
# Check required variables are set
|
# Check variables are set (encryption is optional)
|
||||||
docker exec palmr env | grep -E "ENCRYPTION_KEY|DATABASE_URL"
|
docker exec palmr env | grep -E "DISABLE_FILESYSTEM_ENCRYPTION|ENCRYPTION_KEY|DATABASE_URL"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Container Starts But App Doesn't Load
|
### Container Starts But App Doesn't Load
|
||||||
@@ -151,7 +154,7 @@ curl http://localhost:3333/health
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker exec palmr ls -la /app/server/uploads/
|
docker exec palmr ls -la /app/server/uploads/
|
||||||
# Should show ownership by palmr user
|
# Should show ownership by palmr user (UID 1001)
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Check upload limits:**
|
3. **Check upload limits:**
|
||||||
@@ -178,13 +181,13 @@ docker exec palmr stat /app/server/uploads/your-file.txt
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Using the built-in reset script
|
# Using the built-in reset script
|
||||||
docker exec -it palmr /app/reset-password.sh
|
docker exec -it palmr /app/palmr-app/reset-password.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Check database permissions:**
|
2. **Check database permissions:**
|
||||||
```bash
|
```bash
|
||||||
docker exec palmr ls -la /app/server/prisma/
|
docker exec palmr ls -la /app/server/prisma/
|
||||||
# palmr.db should be writable by palmr user
|
# palmr.db should be writable by palmr user (UID 1001)
|
||||||
```
|
```
|
||||||
|
|
||||||
### OIDC Authentication Not Working
|
### OIDC Authentication Not Working
|
||||||
@@ -243,8 +246,8 @@ docker exec palmr ls -la /app/server/prisma/palmr.db
|
|||||||
# Check database logs
|
# Check database logs
|
||||||
docker-compose logs palmr | grep -i database
|
docker-compose logs palmr | grep -i database
|
||||||
|
|
||||||
# Verify Prisma schema
|
# Verify Prisma schema (run from palmr-app directory)
|
||||||
docker exec palmr npx prisma db push --schema=./prisma/schema.prisma
|
docker exec palmr sh -c "cd /app/palmr-app && npx prisma db push"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Database Corruption
|
### Database Corruption
|
||||||
@@ -283,7 +286,7 @@ docker-compose up -d
|
|||||||
|
|
||||||
3. **Check temp directory permissions:**
|
3. **Check temp directory permissions:**
|
||||||
```bash
|
```bash
|
||||||
docker exec palmr ls -la /app/server/temp-chunks/
|
docker exec palmr ls -la /app/server/temp-uploads/
|
||||||
```
|
```
|
||||||
|
|
||||||
### High Memory Usage
|
### High Memory Usage
|
||||||
@@ -318,16 +321,19 @@ docker port palmr
|
|||||||
echo "4. File Permissions:"
|
echo "4. File Permissions:"
|
||||||
docker exec palmr ls -la /app/server/
|
docker exec palmr ls -la /app/server/
|
||||||
|
|
||||||
echo "5. Environment Variables:"
|
echo "5. Application Files:"
|
||||||
docker exec palmr env | grep -E "PALMR_|ENCRYPTION_|DATABASE_"
|
docker exec palmr ls -la /app/palmr-app/
|
||||||
|
|
||||||
echo "6. API Health:"
|
echo "6. Environment Variables:"
|
||||||
|
docker exec palmr env | grep -E "PALMR_|DISABLE_FILESYSTEM_ENCRYPTION|ENCRYPTION_|DATABASE_"
|
||||||
|
|
||||||
|
echo "7. API Health:"
|
||||||
curl -s http://localhost:3333/health || echo "API not accessible"
|
curl -s http://localhost:3333/health || echo "API not accessible"
|
||||||
|
|
||||||
echo "7. Web Interface:"
|
echo "8. Web Interface:"
|
||||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:5487 || echo "Web interface not accessible"
|
curl -s -o /dev/null -w "%{http_code}" http://localhost:5487 || echo "Web interface not accessible"
|
||||||
|
|
||||||
echo "8. Disk Space:"
|
echo "9. Disk Space:"
|
||||||
df -h
|
df -h
|
||||||
|
|
||||||
echo "=== End Health Check ==="
|
echo "=== End Health Check ==="
|
||||||
|
|||||||
@@ -9,15 +9,15 @@ Configure user and group permissions for seamless bind mount compatibility acros
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
**⚠️ Important**: Palmr uses **UID 1001, GID 1001** by default, which is different from the standard Linux convention of **UID 1000, GID 1000**. This is the most common cause of permission issues with bind mounts.
|
**⚠️ Important**: Palmr uses **UID 1000, GID 1000** by default, which matches the standard Linux convention. However, some systems may use different UID/GID values, which can cause permission issues with bind mounts.
|
||||||
|
|
||||||
## The Permission Problem
|
## The Permission Problem
|
||||||
|
|
||||||
### Why This Happens
|
### Why This Happens
|
||||||
|
|
||||||
- **Palmr Default**: UID 1001, GID 1001 (container)
|
- **Palmr Default**: UID 1000, GID 1000 (container)
|
||||||
- **Linux Standard**: UID 1000, GID 1000 (most host systems)
|
- **Linux Standard**: UID 1000, GID 1000 (most host systems)
|
||||||
- **Result**: Container can't write to host directories
|
- **Result**: Usually compatible, but some systems may use different values
|
||||||
|
|
||||||
### Common Error Scenarios
|
### Common Error Scenarios
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ EACCES: permission denied, open '/app/server/uploads/file.txt'
|
|||||||
# Or when checking permissions:
|
# Or when checking permissions:
|
||||||
$ ls -la uploads/
|
$ ls -la uploads/
|
||||||
drwxr-xr-x 2 user user 4096 Jan 15 10:00 uploads/
|
drwxr-xr-x 2 user user 4096 Jan 15 10:00 uploads/
|
||||||
# Container tries to write as UID 1001, but directory is owned by UID 1000
|
# Container tries to write with different UID/GID than directory owner
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Fix
|
## Quick Fix
|
||||||
@@ -45,15 +45,13 @@ services:
|
|||||||
image: kyantech/palmr:latest
|
image: kyantech/palmr:latest
|
||||||
container_name: palmr
|
container_name: palmr
|
||||||
environment:
|
environment:
|
||||||
- ENABLE_S3=false
|
|
||||||
- ENCRYPTION_KEY=your-secure-key-min-32-chars
|
|
||||||
- PALMR_UID=1000
|
- PALMR_UID=1000
|
||||||
- PALMR_GID=1000
|
- PALMR_GID=1000
|
||||||
ports:
|
ports:
|
||||||
- "5487:5487"
|
- "5487:5487"
|
||||||
volumes:
|
volumes:
|
||||||
- ./uploads:/app/server/uploads:rw
|
- ./uploads:/app/server/uploads:rw
|
||||||
- ./temp-chunks:/app/server/temp-chunks:rw
|
- ./temp-uploads:/app/server/temp-uploads:rw
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -63,8 +61,8 @@ If you prefer to keep Palmr's defaults:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create directories with correct ownership
|
# Create directories with correct ownership
|
||||||
mkdir -p uploads temp-chunks
|
mkdir -p uploads temp-uploads
|
||||||
chown -R 1001:1001 uploads temp-chunks
|
chown -R 1001:1001 uploads temp-uploads
|
||||||
```
|
```
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
@@ -104,8 +102,6 @@ services:
|
|||||||
image: kyantech/palmr:latest
|
image: kyantech/palmr:latest
|
||||||
container_name: palmr
|
container_name: palmr
|
||||||
environment:
|
environment:
|
||||||
- ENABLE_S3=false
|
|
||||||
- ENCRYPTION_KEY=your-secure-key-min-32-chars
|
|
||||||
- PALMR_UID=1000
|
- PALMR_UID=1000
|
||||||
- PALMR_GID=1000
|
- PALMR_GID=1000
|
||||||
ports:
|
ports:
|
||||||
@@ -123,8 +119,6 @@ services:
|
|||||||
image: kyantech/palmr:latest
|
image: kyantech/palmr:latest
|
||||||
container_name: palmr
|
container_name: palmr
|
||||||
environment:
|
environment:
|
||||||
- ENABLE_S3=false
|
|
||||||
- ENCRYPTION_KEY=your-secure-key-min-32-chars
|
|
||||||
- PALMR_UID=1026
|
- PALMR_UID=1026
|
||||||
- PALMR_GID=100
|
- PALMR_GID=100
|
||||||
ports:
|
ports:
|
||||||
@@ -142,8 +136,6 @@ services:
|
|||||||
image: kyantech/palmr:latest
|
image: kyantech/palmr:latest
|
||||||
container_name: palmr
|
container_name: palmr
|
||||||
environment:
|
environment:
|
||||||
- ENABLE_S3=false
|
|
||||||
- ENCRYPTION_KEY=your-secure-key-min-32-chars
|
|
||||||
- PALMR_UID=1000
|
- PALMR_UID=1000
|
||||||
- PALMR_GID=100
|
- PALMR_GID=100
|
||||||
ports:
|
ports:
|
||||||
@@ -166,7 +158,7 @@ services:
|
|||||||
id
|
id
|
||||||
|
|
||||||
# 2. Check directory ownership
|
# 2. Check directory ownership
|
||||||
ls -la uploads/ temp-chunks/
|
ls -la uploads/ temp-uploads/
|
||||||
|
|
||||||
# 3. Fix via environment variables (preferred)
|
# 3. Fix via environment variables (preferred)
|
||||||
# Add to docker-compose.yaml:
|
# Add to docker-compose.yaml:
|
||||||
@@ -174,7 +166,7 @@ ls -la uploads/ temp-chunks/
|
|||||||
# - PALMR_GID=1000
|
# - PALMR_GID=1000
|
||||||
|
|
||||||
# 4. Or fix via chown (alternative)
|
# 4. Or fix via chown (alternative)
|
||||||
chown -R 1001:1001 uploads temp-chunks
|
chown -R 1001:1001 uploads temp-uploads
|
||||||
```
|
```
|
||||||
|
|
||||||
**Error**: Container starts but files aren't accessible
|
**Error**: Container starts but files aren't accessible
|
||||||
@@ -225,11 +217,11 @@ cat /etc/passwd | grep -v nobody
|
|||||||
```bash
|
```bash
|
||||||
# Check if directories exist and are writable
|
# Check if directories exist and are writable
|
||||||
test -w uploads && echo "uploads writable" || echo "uploads NOT writable"
|
test -w uploads && echo "uploads writable" || echo "uploads NOT writable"
|
||||||
test -w temp-chunks && echo "temp-chunks writable" || echo "temp-chunks NOT writable"
|
test -w temp-uploads && echo "temp-uploads writable" || echo "temp-uploads NOT writable"
|
||||||
|
|
||||||
# Create directories with correct permissions
|
# Create directories with correct permissions
|
||||||
mkdir -p uploads temp-chunks
|
mkdir -p uploads temp-uploads
|
||||||
sudo chown -R $(id -u):$(id -g) uploads temp-chunks
|
sudo chown -R $(id -u):$(id -g) uploads temp-uploads
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -270,7 +262,7 @@ To add UID/GID configuration to running installations:
|
|||||||
cp -r ./data ./data-backup
|
cp -r ./data ./data-backup
|
||||||
# or
|
# or
|
||||||
cp -r ./uploads ./uploads-backup
|
cp -r ./uploads ./uploads-backup
|
||||||
cp -r ./temp-chunks ./temp-chunks-backup
|
cp -r ./temp-uploads ./temp-uploads-backup
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Check your UID/GID**
|
3. **Check your UID/GID**
|
||||||
@@ -344,4 +336,4 @@ For most users experiencing permission issues with bind mounts:
|
|||||||
```
|
```
|
||||||
3. **Restart**: `docker-compose down && docker-compose up -d`
|
3. **Restart**: `docker-compose down && docker-compose up -d`
|
||||||
|
|
||||||
This resolves the mismatch between Palmr's default UID 1001 and the standard Linux UID 1000.
|
This ensures compatibility between Palmr's UID/GID and your host system's file ownership.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "palmr-docs",
|
"name": "palmr-docs",
|
||||||
"version": "3.1.2-beta",
|
"version": "3.1.8-beta",
|
||||||
"description": "Docs for Palmr",
|
"description": "Docs for Palmr",
|
||||||
"private": true,
|
"private": true,
|
||||||
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
|
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
LayoutIcon,
|
LayoutIcon,
|
||||||
LockIcon,
|
LockIcon,
|
||||||
MousePointer,
|
MousePointer,
|
||||||
RadioIcon,
|
|
||||||
RocketIcon,
|
RocketIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
TimerIcon,
|
TimerIcon,
|
||||||
@@ -82,23 +81,6 @@ function Hero() {
|
|||||||
<Link href={docsLink}>Documentation</Link>
|
<Link href={docsLink}>Documentation</Link>
|
||||||
</div>
|
</div>
|
||||||
</PulsatingButton>
|
</PulsatingButton>
|
||||||
<RippleButton
|
|
||||||
onClick={() => {
|
|
||||||
const demoId = `${Math.random().toString(36).substr(2, 9)}`;
|
|
||||||
const token = `${Math.random().toString(36).substr(2, 12)}`;
|
|
||||||
|
|
||||||
sessionStorage.setItem("demo_token", token);
|
|
||||||
sessionStorage.setItem("demo_id", demoId);
|
|
||||||
sessionStorage.setItem("demo_expires", (Date.now() + 5 * 60 * 1000).toString());
|
|
||||||
|
|
||||||
window.location.href = `/demo?id=${demoId}&token=${token}`;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
<RadioIcon size={18} />
|
|
||||||
Live Demo
|
|
||||||
</div>
|
|
||||||
</RippleButton>
|
|
||||||
<RippleButton>
|
<RippleButton>
|
||||||
<a
|
<a
|
||||||
href="https://github.com/kyantech/Palmr"
|
href="https://github.com/kyantech/Palmr"
|
||||||
|
|||||||
@@ -1,225 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useSearchParams } from "next/navigation";
|
|
||||||
import { Palmtree } from "lucide-react";
|
|
||||||
import { motion } from "motion/react";
|
|
||||||
|
|
||||||
import { BackgroundLights } from "@/components/ui/background-lights";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
interface DemoStatus {
|
|
||||||
status: "waiting" | "ready";
|
|
||||||
url: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CreateDemoResponse {
|
|
||||||
message: string;
|
|
||||||
url: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DemoClientInner() {
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const demoId = searchParams.get("id");
|
|
||||||
const token = searchParams.get("token");
|
|
||||||
|
|
||||||
const [status, setStatus] = useState<DemoStatus | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const validateAccess = () => {
|
|
||||||
const storedToken = sessionStorage.getItem("demo_token");
|
|
||||||
const storedId = sessionStorage.getItem("demo_id");
|
|
||||||
const expiresAt = sessionStorage.getItem("demo_expires");
|
|
||||||
|
|
||||||
if (!demoId || !token || !storedToken || !storedId || !expiresAt) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (token !== storedToken || demoId !== storedId || Date.now() > parseInt(expiresAt)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!validateAccess()) {
|
|
||||||
setError("Unauthorized access. Please use the Live Demo button to access this page.");
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const createDemo = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch("https://palmr-demo-manager.kyantech.com.br/create-demo", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
palmr_demo_instance_id: demoId,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to create demo");
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: CreateDemoResponse = await response.json();
|
|
||||||
console.log("Demo creation response:", data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error creating demo:", err);
|
|
||||||
setError("Failed to create demo. Please try again.");
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkStatus = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`https://palmr-demo-manager.kyantech.com.br/status/${demoId}`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to check demo status");
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: DemoStatus = await response.json();
|
|
||||||
setStatus(data);
|
|
||||||
|
|
||||||
if (data.status === "ready" && data.url) {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error checking status:", err);
|
|
||||||
setError("Failed to check demo status. Please try again.");
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
createDemo();
|
|
||||||
|
|
||||||
const interval = setInterval(checkStatus, 5000); // Check every 5 seconds
|
|
||||||
|
|
||||||
checkStatus();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(interval);
|
|
||||||
sessionStorage.removeItem("demo_token");
|
|
||||||
sessionStorage.removeItem("demo_id");
|
|
||||||
sessionStorage.removeItem("demo_expires");
|
|
||||||
};
|
|
||||||
}, [demoId, token]);
|
|
||||||
|
|
||||||
const handleGoToDemo = () => {
|
|
||||||
if (status?.url) {
|
|
||||||
window.open(status.url, "_blank");
|
|
||||||
}
|
|
||||||
window.location.href = "/";
|
|
||||||
};
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-background">
|
|
||||||
<BackgroundLights />
|
|
||||||
<div className="relative flex flex-col items-center justify-center h-full">
|
|
||||||
<div className="text-center space-y-6 max-w-md">
|
|
||||||
<h1 className="text-2xl font-bold text-destructive">Error</h1>
|
|
||||||
<p className="text-muted-foreground">{error}</p>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
sessionStorage.removeItem("demo_token");
|
|
||||||
sessionStorage.removeItem("demo_id");
|
|
||||||
sessionStorage.removeItem("demo_expires");
|
|
||||||
window.location.href = "/";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Go Back
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-background">
|
|
||||||
<BackgroundLights />
|
|
||||||
<div className="flex flex-col items-center gap-6 text-center h-full justify-center">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h1 className="text-2xl font-bold">Your demo is being generated, please wait...</h1>
|
|
||||||
<p className="text-muted-foreground max-w-lg">
|
|
||||||
This demo will be available for 30 minutes for testing. After that, all data will be permanently deleted
|
|
||||||
and become inaccessible. You can test Palmr. with a 200MB storage limit.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-background">
|
|
||||||
<BackgroundLights />
|
|
||||||
<div className="relative flex flex-col items-center justify-center h-full">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.5 }}
|
|
||||||
className="container mx-auto max-w-7xl px-6 flex-grow"
|
|
||||||
>
|
|
||||||
<section className="relative flex flex-col items-center justify-center gap-6 m-auto h-full">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.5, delay: 0.2 }}
|
|
||||||
className="inline-block max-w-xl text-center justify-center"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-8">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<motion.span
|
|
||||||
initial={{ opacity: 0, x: -20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ delay: 0.4, duration: 0.5 }}
|
|
||||||
className="text-4xl lg:text-3xl font-semibold tracking-tight text-primary"
|
|
||||||
>
|
|
||||||
Your demo is ready!
|
|
||||||
</motion.span>
|
|
||||||
<motion.span
|
|
||||||
initial={{ opacity: 0, x: 20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ delay: 0.6, duration: 0.5 }}
|
|
||||||
className="text-3xl leading-9 font-semibold tracking-tight"
|
|
||||||
>
|
|
||||||
Click the button below to test
|
|
||||||
</motion.span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.5, delay: 0.8 }}
|
|
||||||
className="flex flex-col items-center gap-6"
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
transition={{ delay: 1.2, duration: 0.5 }}
|
|
||||||
>
|
|
||||||
<Button onClick={handleGoToDemo} className="flex items-center gap-2 px-8 py-4 text-lg">
|
|
||||||
<Palmtree className="h-5 w-5" />
|
|
||||||
Go to Palmr. Demo
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
</section>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DemoClient() {
|
|
||||||
return <DemoClientInner />;
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Suspense } from "react";
|
|
||||||
|
|
||||||
import DemoClient from "./components/demo-client";
|
|
||||||
|
|
||||||
export default function DemoPage() {
|
|
||||||
return (
|
|
||||||
<Suspense>
|
|
||||||
<DemoClient />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,14 +5,15 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
interface CardProps {
|
interface CardProps {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description?: string;
|
||||||
href?: string;
|
href?: string;
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
children?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Card = ({ title, description, href, icon, className, onClick }: CardProps) => {
|
export const Card = ({ title, description, href, icon, className, onClick, children }: CardProps) => {
|
||||||
const cardContent = (
|
const cardContent = (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -37,9 +38,16 @@ export const Card = ({ title, description, href, icon, className, onClick }: Car
|
|||||||
<h3 className="font-medium text-sm text-foreground mb-1 group-hover:text-primary transition-colors duration-200 mt-3 text-decoration-none">
|
<h3 className="font-medium text-sm text-foreground mb-1 group-hover:text-primary transition-colors duration-200 mt-3 text-decoration-none">
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
|
{description && (
|
||||||
<p className="text-xs text-muted-foreground/80 leading-relaxed line-clamp-2 group-hover:text-muted-foreground transition-colors duration-200">
|
<p className="text-xs text-muted-foreground/80 leading-relaxed line-clamp-2 group-hover:text-muted-foreground transition-colors duration-200">
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
|
{children && (
|
||||||
|
<div className="text-xs text-muted-foreground/80 leading-relaxed group-hover:text-muted-foreground transition-colors duration-200 mt-2">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0 ml-2">
|
<div className="flex-shrink-0 ml-2">
|
||||||
<div className="w-5 h-5 rounded-full bg-muted/40 flex items-center justify-center opacity-0 group-hover:opacity-100 group-hover:bg-primary/10 transition-all duration-200">
|
<div className="w-5 h-5 rounded-full bg-muted/40 flex items-center justify-center opacity-0 group-hover:opacity-100 group-hover:bg-primary/10 transition-all duration-200">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# FOR FILESYSTEM STORAGE ENV VARS
|
# FOR FILESYSTEM STORAGE ENV VARS
|
||||||
ENABLE_S3=false
|
ENABLE_S3=false
|
||||||
ENCRYPTION_KEY=change-this-key-in-production-min-32-chars
|
DISABLE_FILESYSTEM_ENCRYPTION=true
|
||||||
|
# ENCRYPTION_KEY=change-this-key-in-production-min-32-chars # Required only if encryption is enabled (DISABLE_FILESYSTEM_ENCRYPTION=false)
|
||||||
DATABASE_URL="file:./palmr.db"
|
DATABASE_URL="file:./palmr.db"
|
||||||
|
|
||||||
# FOR USE WITH S3 COMPATIBLE STORAGE
|
# FOR USE WITH S3 COMPATIBLE STORAGE
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "palmr-api",
|
"name": "palmr-api",
|
||||||
"version": "3.1.2-beta",
|
"version": "3.1.8-beta",
|
||||||
"description": "API for Palmr",
|
"description": "API for Palmr",
|
||||||
"private": true,
|
"private": true,
|
||||||
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
|
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
|
||||||
|
|||||||
@@ -147,6 +147,12 @@ const defaultConfigs = [
|
|||||||
type: "boolean",
|
type: "boolean",
|
||||||
group: "auth-providers",
|
group: "auth-providers",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "passwordAuthEnabled",
|
||||||
|
value: "true",
|
||||||
|
type: "boolean",
|
||||||
|
group: "security",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "serverUrl",
|
key: "serverUrl",
|
||||||
value: "http://localhost:3333",
|
value: "http://localhost:3333",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
echo "🔐 Palmr Password Reset Tool"
|
echo "🔐 Palmr Password Reset Tool"
|
||||||
echo "============================="
|
echo "============================="
|
||||||
|
|
||||||
# Check if we're in the right directory
|
# Check if we're in the right directory and set DATABASE_URL
|
||||||
if [ ! -f "package.json" ]; then
|
if [ ! -f "package.json" ]; then
|
||||||
echo "❌ Error: This script must be run from the server directory (/app/server)"
|
echo "❌ Error: This script must be run from the server directory (/app/server)"
|
||||||
echo " Current directory: $(pwd)"
|
echo " Current directory: $(pwd)"
|
||||||
@@ -14,6 +14,14 @@ if [ ! -f "package.json" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Set DATABASE_URL if not already set
|
||||||
|
if [ -z "$DATABASE_URL" ]; then
|
||||||
|
export DATABASE_URL="file:/app/server/prisma/palmr.db"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure database directory exists
|
||||||
|
mkdir -p /app/server/prisma
|
||||||
|
|
||||||
# Function to check if tsx is available
|
# Function to check if tsx is available
|
||||||
check_tsx() {
|
check_tsx() {
|
||||||
# Check if tsx binary exists in node_modules
|
# Check if tsx binary exists in node_modules
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { z } from "zod";
|
|||||||
|
|
||||||
const envSchema = z.object({
|
const envSchema = z.object({
|
||||||
ENABLE_S3: z.union([z.literal("true"), z.literal("false")]).default("false"),
|
ENABLE_S3: z.union([z.literal("true"), z.literal("false")]).default("false"),
|
||||||
ENCRYPTION_KEY: z.string().optional().default("palmr-default-encryption-key-2025"),
|
ENCRYPTION_KEY: z.string().optional(),
|
||||||
DISABLE_FILESYSTEM_ENCRYPTION: z.union([z.literal("true"), z.literal("false")]).default("false"),
|
DISABLE_FILESYSTEM_ENCRYPTION: z.union([z.literal("true"), z.literal("false")]).default("true"),
|
||||||
S3_ENDPOINT: z.string().optional(),
|
S3_ENDPOINT: z.string().optional(),
|
||||||
S3_PORT: z.string().optional(),
|
S3_PORT: z.string().optional(),
|
||||||
S3_USE_SSL: z.string().optional(),
|
S3_USE_SSL: z.string().optional(),
|
||||||
@@ -14,7 +14,6 @@ const envSchema = z.object({
|
|||||||
S3_FORCE_PATH_STYLE: z.union([z.literal("true"), z.literal("false")]).default("false"),
|
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"),
|
SECURE_SITE: z.union([z.literal("true"), z.literal("false")]).default("false"),
|
||||||
DATABASE_URL: z.string().optional().default("file:/app/server/prisma/palmr.db"),
|
DATABASE_URL: z.string().optional().default("file:/app/server/prisma/palmr.db"),
|
||||||
DEMO_MODE: z.union([z.literal("true"), z.literal("false")]).default("false"),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const env = envSchema.parse(process.env);
|
export const env = envSchema.parse(process.env);
|
||||||
|
|||||||
@@ -18,6 +18,15 @@ export class AppController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getSystemInfo(request: FastifyRequest, reply: FastifyReply) {
|
||||||
|
try {
|
||||||
|
const systemInfo = await this.appService.getSystemInfo();
|
||||||
|
return reply.send(systemInfo);
|
||||||
|
} catch (error: any) {
|
||||||
|
return reply.status(400).send({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getAllConfigs(request: FastifyRequest, reply: FastifyReply) {
|
async getAllConfigs(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const configs = await this.appService.getAllConfigs();
|
const configs = await this.appService.getAllConfigs();
|
||||||
|
|||||||
@@ -53,6 +53,26 @@ export async function appRoutes(app: FastifyInstance) {
|
|||||||
appController.getAppInfo.bind(appController)
|
appController.getAppInfo.bind(appController)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
"/app/system-info",
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ["App"],
|
||||||
|
operationId: "getSystemInfo",
|
||||||
|
summary: "Get system information",
|
||||||
|
description: "Get system information including storage provider",
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
storageProvider: z.enum(["s3", "filesystem"]).describe("The active storage provider"),
|
||||||
|
s3Enabled: z.boolean().describe("Whether S3 storage is enabled"),
|
||||||
|
}),
|
||||||
|
400: z.object({ error: z.string().describe("Error message") }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
appController.getSystemInfo.bind(appController)
|
||||||
|
);
|
||||||
|
|
||||||
app.patch(
|
app.patch(
|
||||||
"/app/configs/:key",
|
"/app/configs/:key",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isS3Enabled } from "../../config/storage.config";
|
||||||
import { prisma } from "../../shared/prisma";
|
import { prisma } from "../../shared/prisma";
|
||||||
import { ConfigService } from "../config/service";
|
import { ConfigService } from "../config/service";
|
||||||
|
|
||||||
@@ -20,6 +21,13 @@ export class AppService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getSystemInfo() {
|
||||||
|
return {
|
||||||
|
storageProvider: isS3Enabled ? "s3" : "filesystem",
|
||||||
|
s3Enabled: isS3Enabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async getAllConfigs() {
|
async getAllConfigs() {
|
||||||
return prisma.appConfig.findMany({
|
return prisma.appConfig.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -38,6 +46,17 @@ export class AppService {
|
|||||||
throw new Error("JWT Secret cannot be updated through this endpoint");
|
throw new Error("JWT Secret cannot be updated through this endpoint");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key === "passwordAuthEnabled") {
|
||||||
|
if (value === "false") {
|
||||||
|
const canDisable = await this.configService.validatePasswordAuthDisable();
|
||||||
|
if (!canDisable) {
|
||||||
|
throw new Error(
|
||||||
|
"Password authentication cannot be disabled. At least one authentication provider must be active."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const config = await prisma.appConfig.findUnique({
|
const config = await prisma.appConfig.findUnique({
|
||||||
where: { key },
|
where: { key },
|
||||||
});
|
});
|
||||||
@@ -56,6 +75,15 @@ export class AppService {
|
|||||||
if (updates.some((update) => update.key === "jwtSecret")) {
|
if (updates.some((update) => update.key === "jwtSecret")) {
|
||||||
throw new Error("JWT Secret cannot be updated through this endpoint");
|
throw new Error("JWT Secret cannot be updated through this endpoint");
|
||||||
}
|
}
|
||||||
|
const passwordAuthUpdate = updates.find((update) => update.key === "passwordAuthEnabled");
|
||||||
|
if (passwordAuthUpdate && passwordAuthUpdate.value === "false") {
|
||||||
|
const canDisable = await this.configService.validatePasswordAuthDisable();
|
||||||
|
if (!canDisable) {
|
||||||
|
throw new Error(
|
||||||
|
"Password authentication cannot be disabled. At least one authentication provider must be active."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const keys = updates.map((update) => update.key);
|
const keys = updates.map((update) => update.key);
|
||||||
const existingConfigs = await prisma.appConfig.findMany({
|
const existingConfigs = await prisma.appConfig.findMany({
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { FastifyReply, FastifyRequest } from "fastify";
|
import { FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
|
||||||
|
import { ConfigService } from "../config/service";
|
||||||
import { UpdateAuthProviderSchema } from "./dto";
|
import { UpdateAuthProviderSchema } from "./dto";
|
||||||
import { AuthProvidersService } from "./service";
|
import { AuthProvidersService } from "./service";
|
||||||
import {
|
import {
|
||||||
@@ -39,9 +40,11 @@ const ERROR_MESSAGES = {
|
|||||||
|
|
||||||
export class AuthProvidersController {
|
export class AuthProvidersController {
|
||||||
private authProvidersService: AuthProvidersService;
|
private authProvidersService: AuthProvidersService;
|
||||||
|
private configService: ConfigService;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.authProvidersService = new AuthProvidersService();
|
this.authProvidersService = new AuthProvidersService();
|
||||||
|
this.configService = new ConfigService();
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildRequestContext(request: FastifyRequest): RequestContext {
|
private buildRequestContext(request: FastifyRequest): RequestContext {
|
||||||
@@ -223,13 +226,24 @@ export class AuthProvidersController {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
const data = request.body;
|
const data = request.body as any;
|
||||||
|
|
||||||
const existingProvider = await this.authProvidersService.getProviderById(id);
|
const existingProvider = await this.authProvidersService.getProviderById(id);
|
||||||
if (!existingProvider) {
|
if (!existingProvider) {
|
||||||
return this.sendErrorResponse(reply, 404, ERROR_MESSAGES.PROVIDER_NOT_FOUND);
|
return this.sendErrorResponse(reply, 404, ERROR_MESSAGES.PROVIDER_NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.enabled === false && existingProvider.enabled === true) {
|
||||||
|
const canDisable = await this.configService.validateAllProvidersDisable();
|
||||||
|
if (!canDisable) {
|
||||||
|
return this.sendErrorResponse(
|
||||||
|
reply,
|
||||||
|
400,
|
||||||
|
"Cannot disable the last authentication provider when password authentication is disabled"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isOfficial = this.authProvidersService.isOfficialProvider(existingProvider.name);
|
const isOfficial = this.authProvidersService.isOfficialProvider(existingProvider.name);
|
||||||
|
|
||||||
if (isOfficial) {
|
if (isOfficial) {
|
||||||
@@ -300,6 +314,17 @@ export class AuthProvidersController {
|
|||||||
return this.sendErrorResponse(reply, 400, ERROR_MESSAGES.OFFICIAL_CANNOT_DELETE);
|
return this.sendErrorResponse(reply, 400, ERROR_MESSAGES.OFFICIAL_CANNOT_DELETE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (provider.enabled) {
|
||||||
|
const canDisable = await this.configService.validateAllProvidersDisable();
|
||||||
|
if (!canDisable) {
|
||||||
|
return this.sendErrorResponse(
|
||||||
|
reply,
|
||||||
|
400,
|
||||||
|
"Cannot delete the last authentication provider when password authentication is disabled"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await this.authProvidersService.deleteProvider(id);
|
await this.authProvidersService.deleteProvider(id);
|
||||||
return this.sendSuccessResponse(reply, undefined, "Provider deleted successfully");
|
return this.sendSuccessResponse(reply, undefined, "Provider deleted successfully");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { FastifyReply, FastifyRequest } from "fastify";
|
import { FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
|
||||||
import { env } from "../../env";
|
import { env } from "../../env";
|
||||||
|
import { ConfigService } from "../config/service";
|
||||||
import {
|
import {
|
||||||
CompleteTwoFactorLoginSchema,
|
CompleteTwoFactorLoginSchema,
|
||||||
createResetPasswordSchema,
|
createResetPasswordSchema,
|
||||||
@@ -11,6 +12,7 @@ import { AuthService } from "./service";
|
|||||||
|
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
private authService = new AuthService();
|
private authService = new AuthService();
|
||||||
|
private configService = new ConfigService();
|
||||||
|
|
||||||
private getClientInfo(request: FastifyRequest) {
|
private getClientInfo(request: FastifyRequest) {
|
||||||
const realIP = request.headers["x-real-ip"] as string;
|
const realIP = request.headers["x-real-ip"] as string;
|
||||||
@@ -111,14 +113,21 @@ export class AuthController {
|
|||||||
|
|
||||||
async getCurrentUser(request: FastifyRequest, reply: FastifyReply) {
|
async getCurrentUser(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user?.userId;
|
let userId: string | null = null;
|
||||||
|
try {
|
||||||
|
await request.jwtVerify();
|
||||||
|
userId = (request as any).user?.userId;
|
||||||
|
} catch (err) {
|
||||||
|
return reply.send({ user: null });
|
||||||
|
}
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
|
return reply.send({ user: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.authService.getUserById(userId);
|
const user = await this.authService.getUserById(userId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return reply.status(404).send({ error: "User not found" });
|
return reply.send({ user: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
return reply.send({ user });
|
return reply.send({ user });
|
||||||
@@ -169,4 +178,15 @@ export class AuthController {
|
|||||||
return reply.status(400).send({ error: error.message });
|
return reply.status(400).send({ error: error.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAuthConfig(request: FastifyRequest, reply: FastifyReply) {
|
||||||
|
try {
|
||||||
|
const passwordAuthEnabled = await this.configService.getValue("passwordAuthEnabled");
|
||||||
|
return reply.send({
|
||||||
|
passwordAuthEnabled: passwordAuthEnabled === "true",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
return reply.status(400).send({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,9 +153,10 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
tags: ["Authentication"],
|
tags: ["Authentication"],
|
||||||
operationId: "getCurrentUser",
|
operationId: "getCurrentUser",
|
||||||
summary: "Get Current User",
|
summary: "Get Current User",
|
||||||
description: "Returns the current authenticated user's information",
|
description: "Returns the current authenticated user's information or null if not authenticated",
|
||||||
response: {
|
response: {
|
||||||
200: z.object({
|
200: z.union([
|
||||||
|
z.object({
|
||||||
user: z.object({
|
user: z.object({
|
||||||
id: z.string().describe("User ID"),
|
id: z.string().describe("User ID"),
|
||||||
firstName: z.string().describe("User first name"),
|
firstName: z.string().describe("User first name"),
|
||||||
@@ -169,17 +170,12 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
updatedAt: z.date().describe("User last update date"),
|
updatedAt: z.date().describe("User last update date"),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
401: z.object({ error: z.string().describe("Error message") }),
|
z.object({
|
||||||
|
user: z.null().describe("No user when not authenticated"),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
preValidation: async (request: FastifyRequest, reply: FastifyReply) => {
|
|
||||||
try {
|
|
||||||
await request.jwtVerify();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
authController.getCurrentUser.bind(authController)
|
authController.getCurrentUser.bind(authController)
|
||||||
);
|
);
|
||||||
@@ -280,4 +276,23 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
authController.removeAllTrustedDevices.bind(authController)
|
authController.removeAllTrustedDevices.bind(authController)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
"/auth/config",
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ["Authentication"],
|
||||||
|
operationId: "getAuthConfig",
|
||||||
|
summary: "Get Authentication Configuration",
|
||||||
|
description: "Get authentication configuration settings",
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
passwordAuthEnabled: z.boolean().describe("Whether password authentication is enabled"),
|
||||||
|
}),
|
||||||
|
400: z.object({ error: z.string().describe("Error message") }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
authController.getAuthConfig.bind(authController)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export class AuthService {
|
|||||||
private trustedDeviceService = new TrustedDeviceService();
|
private trustedDeviceService = new TrustedDeviceService();
|
||||||
|
|
||||||
async login(data: LoginInput, userAgent?: string, ipAddress?: string) {
|
async login(data: LoginInput, userAgent?: string, ipAddress?: string) {
|
||||||
|
const passwordAuthEnabled = await this.configService.getValue("passwordAuthEnabled");
|
||||||
|
if (passwordAuthEnabled === "false") {
|
||||||
|
throw new Error("Password authentication is disabled. Please use an external authentication provider.");
|
||||||
|
}
|
||||||
|
|
||||||
const user = await this.userRepository.findUserByEmailOrUsername(data.emailOrUsername);
|
const user = await this.userRepository.findUserByEmailOrUsername(data.emailOrUsername);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error("Invalid credentials");
|
throw new Error("Invalid credentials");
|
||||||
@@ -146,6 +151,11 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async requestPasswordReset(email: string, origin: string) {
|
async requestPasswordReset(email: string, origin: string) {
|
||||||
|
const passwordAuthEnabled = await this.configService.getValue("passwordAuthEnabled");
|
||||||
|
if (passwordAuthEnabled === "false") {
|
||||||
|
throw new Error("Password authentication is disabled. Password reset is not available.");
|
||||||
|
}
|
||||||
|
|
||||||
const user = await this.userRepository.findUserByEmail(email);
|
const user = await this.userRepository.findUserByEmail(email);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return;
|
return;
|
||||||
@@ -171,6 +181,11 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async resetPassword(token: string, newPassword: string) {
|
async resetPassword(token: string, newPassword: string) {
|
||||||
|
const passwordAuthEnabled = await this.configService.getValue("passwordAuthEnabled");
|
||||||
|
if (passwordAuthEnabled === "false") {
|
||||||
|
throw new Error("Password authentication is disabled. Password reset is not available.");
|
||||||
|
}
|
||||||
|
|
||||||
const resetRequest = await prisma.passwordReset.findFirst({
|
const resetRequest = await prisma.passwordReset.findFirst({
|
||||||
where: {
|
where: {
|
||||||
token,
|
token,
|
||||||
|
|||||||
@@ -13,6 +13,26 @@ export class ConfigService {
|
|||||||
return config.value;
|
return config.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setValue(key: string, value: string): Promise<void> {
|
||||||
|
await prisma.appConfig.update({
|
||||||
|
where: { key },
|
||||||
|
data: { value },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validatePasswordAuthDisable(): Promise<boolean> {
|
||||||
|
const enabledProviders = await prisma.authProvider.findMany({
|
||||||
|
where: { enabled: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return enabledProviders.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateAllProvidersDisable(): Promise<boolean> {
|
||||||
|
const passwordAuthEnabled = await this.getValue("passwordAuthEnabled");
|
||||||
|
return passwordAuthEnabled === "true";
|
||||||
|
}
|
||||||
|
|
||||||
async getGroupConfigs(group: string) {
|
async getGroupConfigs(group: string) {
|
||||||
const configs = await prisma.appConfig.findMany({
|
const configs = await prisma.appConfig.findMany({
|
||||||
where: { group },
|
where: { group },
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ export class EmailService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendShareNotification(to: string, shareLink: string, shareName?: string) {
|
async sendShareNotification(to: string, shareLink: string, shareName?: string, senderName?: string) {
|
||||||
const transporter = await this.createTransporter();
|
const transporter = await this.createTransporter();
|
||||||
if (!transporter) {
|
if (!transporter) {
|
||||||
throw new Error("SMTP is not enabled");
|
throw new Error("SMTP is not enabled");
|
||||||
@@ -178,19 +178,151 @@ export class EmailService {
|
|||||||
const appName = await this.configService.getValue("appName");
|
const appName = await this.configService.getValue("appName");
|
||||||
|
|
||||||
const shareTitle = shareName || "Files";
|
const shareTitle = shareName || "Files";
|
||||||
|
const sender = senderName || "Someone";
|
||||||
|
|
||||||
await transporter.sendMail({
|
await transporter.sendMail({
|
||||||
from: `"${fromName}" <${fromEmail}>`,
|
from: `"${fromName}" <${fromEmail}>`,
|
||||||
to,
|
to,
|
||||||
subject: `${appName} - ${shareTitle} shared with you`,
|
subject: `${appName} - ${shareTitle} shared with you`,
|
||||||
html: `
|
html: `
|
||||||
<h1>${appName} - Shared Files</h1>
|
<!DOCTYPE html>
|
||||||
<p>Someone has shared "${shareTitle}" with you.</p>
|
<html lang="en">
|
||||||
<p>Click the link below to access the shared files:</p>
|
<head>
|
||||||
<a href="${shareLink}">
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${appName} - Shared Files</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5; color: #333333;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); overflow: hidden; margin-top: 40px; margin-bottom: 40px;">
|
||||||
|
<!-- Header -->
|
||||||
|
<div style="background-color: #22B14C; padding: 30px 20px; text-align: center;">
|
||||||
|
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;">${appName}</h1>
|
||||||
|
<p style="margin: 2px 0 0 0; color: #ffffff; font-size: 16px; opacity: 0.9;">Shared Files</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div style="padding: 40px 30px;">
|
||||||
|
<div style="text-align: center; margin-bottom: 32px;">
|
||||||
|
<h2 style="margin: 0 0 12px 0; color: #1f2937; font-size: 24px; font-weight: 600;">Files Shared With You</h2>
|
||||||
|
<p style="margin: 0; color: #6b7280; font-size: 16px; line-height: 1.6;">
|
||||||
|
<strong style="color: #374151;">${sender}</strong> has shared <strong style="color: #374151;">"${shareTitle}"</strong> with you.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<div style="text-align: center; margin: 32px 0;">
|
||||||
|
<a href="${shareLink}" style="display: inline-block; background-color: #22B14C; color: #ffffff; text-decoration: none; padding: 12px 24px; font-weight: 600; font-size: 16px; border: 2px solid #22B14C; border-radius: 8px; transition: all 0.3s ease;">
|
||||||
Access Shared Files
|
Access Shared Files
|
||||||
</a>
|
</a>
|
||||||
<p>Note: This share may have an expiration date or view limit.</p>
|
</div>
|
||||||
|
|
||||||
|
<!-- Info Box -->
|
||||||
|
<div style="background-color: #f9fafb; border-left: 4px solid #22B14C; padding: 16px 20px; margin-top: 32px;">
|
||||||
|
<p style="margin: 0; color: #4b5563; font-size: 14px; line-height: 1.5;">
|
||||||
|
<strong>Important:</strong> This share may have an expiration date or view limit. Access it as soon as possible to ensure availability.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div style="background-color: #f9fafb; padding: 24px 30px; text-align: center; border-top: 1px solid #e5e7eb;">
|
||||||
|
<p style="margin: 0; color: #6b7280; font-size: 14px;">
|
||||||
|
This email was sent by <strong>${appName}</strong>
|
||||||
|
</p>
|
||||||
|
<p style="margin: 8px 0 0 0; color: #9ca3af; font-size: 12px;">
|
||||||
|
If you didn't expect this email, you can safely ignore it.
|
||||||
|
</p>
|
||||||
|
<p style="margin: 4px 0 0 0; color: #9ca3af; font-size: 10px;">
|
||||||
|
Powered by <a href="https://kyantech.com.br" style="color: #9ca3af; text-decoration: none;">Kyantech Solutions</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendReverseShareBatchFileNotification(
|
||||||
|
recipientEmail: string,
|
||||||
|
reverseShareName: string,
|
||||||
|
fileCount: number,
|
||||||
|
fileList: string,
|
||||||
|
uploaderName: string
|
||||||
|
) {
|
||||||
|
const transporter = await this.createTransporter();
|
||||||
|
if (!transporter) {
|
||||||
|
throw new Error("SMTP is not enabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromName = await this.configService.getValue("smtpFromName");
|
||||||
|
const fromEmail = await this.configService.getValue("smtpFromEmail");
|
||||||
|
const appName = await this.configService.getValue("appName");
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `"${fromName}" <${fromEmail}>`,
|
||||||
|
to: recipientEmail,
|
||||||
|
subject: `${appName} - ${fileCount} file${fileCount > 1 ? "s" : ""} uploaded to "${reverseShareName}"`,
|
||||||
|
html: `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${appName} - File Upload Notification</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5; color: #333333;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); overflow: hidden; margin-top: 40px; margin-bottom: 40px;">
|
||||||
|
<!-- Header -->
|
||||||
|
<div style="background-color: #22B14C; padding: 30px 20px; text-align: center;">
|
||||||
|
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;">${appName}</h1>
|
||||||
|
<p style="margin: 2px 0 0 0; color: #ffffff; font-size: 16px; opacity: 0.9;">File Upload Notification</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div style="padding: 40px 30px;">
|
||||||
|
<div style="text-align: center; margin-bottom: 32px;">
|
||||||
|
<h2 style="margin: 0 0 12px 0; color: #1f2937; font-size: 24px; font-weight: 600;">New File Uploaded</h2>
|
||||||
|
<p style="margin: 0; color: #6b7280; font-size: 16px; line-height: 1.6;">
|
||||||
|
<strong style="color: #374151;">${uploaderName}</strong> has uploaded <strong style="color: #374151;">${fileCount} file${fileCount > 1 ? "s" : ""}</strong> to your reverse share <strong style="color: #374151;">"${reverseShareName}"</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File List -->
|
||||||
|
<div style="background-color: #f9fafb; border-radius: 8px; padding: 16px; margin: 32px 0; border-left: 4px solid #22B14C;">
|
||||||
|
<p style="margin: 0 0 8px 0; color: #374151; font-size: 14px;"><strong>Files (${fileCount}):</strong></p>
|
||||||
|
<ul style="margin: 0; padding-left: 20px; color: #6b7280; font-size: 14px; line-height: 1.5;">
|
||||||
|
${fileList
|
||||||
|
.split(", ")
|
||||||
|
.map((file) => `<li style="margin: 4px 0;">${file}</li>`)
|
||||||
|
.join("")}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info Text -->
|
||||||
|
<div style="text-align: center; margin-top: 32px;">
|
||||||
|
<p style="margin: 0; color: #9ca3af; font-size: 12px;">
|
||||||
|
You can now access and manage these files through your dashboard.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div style="background-color: #f9fafb; padding: 24px 30px; text-align: center; border-top: 1px solid #e5e7eb;">
|
||||||
|
<p style="margin: 0; color: #6b7280; font-size: 14px;">
|
||||||
|
This email was sent by <strong>${appName}</strong>
|
||||||
|
</p>
|
||||||
|
<p style="margin: 8px 0 0 0; color: #9ca3af; font-size: 12px;">
|
||||||
|
If you didn't expect this email, you can safely ignore it.
|
||||||
|
</p>
|
||||||
|
<p style="margin: 4px 0 0 0; color: #9ca3af; font-size: 10px;">
|
||||||
|
Powered by <a href="https://kyantech.com.br" style="color: #9ca3af; text-decoration: none;">Kyantech Solutions</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,17 +56,7 @@ export class FileController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if DEMO_MODE is enabled
|
const maxTotalStorage = BigInt(await this.configService.getValue("maxTotalStoragePerUser"));
|
||||||
const isDemoMode = env.DEMO_MODE === "true";
|
|
||||||
|
|
||||||
let maxTotalStorage: bigint;
|
|
||||||
if (isDemoMode) {
|
|
||||||
// In demo mode, limit all users to 200MB
|
|
||||||
maxTotalStorage = BigInt(200 * 1024 * 1024); // 200MB in bytes
|
|
||||||
} else {
|
|
||||||
// Normal behavior - use maxTotalStoragePerUser configuration
|
|
||||||
maxTotalStorage = BigInt(await this.configService.getValue("maxTotalStoragePerUser"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const userFiles = await prisma.file.findMany({
|
const userFiles = await prisma.file.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
@@ -138,17 +128,7 @@ export class FileController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if DEMO_MODE is enabled
|
const maxTotalStorage = BigInt(await this.configService.getValue("maxTotalStoragePerUser"));
|
||||||
const isDemoMode = env.DEMO_MODE === "true";
|
|
||||||
|
|
||||||
let maxTotalStorage: bigint;
|
|
||||||
if (isDemoMode) {
|
|
||||||
// In demo mode, limit all users to 200MB
|
|
||||||
maxTotalStorage = BigInt(200 * 1024 * 1024); // 200MB in bytes
|
|
||||||
} else {
|
|
||||||
// Normal behavior - use maxTotalStoragePerUser configuration
|
|
||||||
maxTotalStorage = BigInt(await this.configService.getValue("maxTotalStoragePerUser"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const userFiles = await prisma.file.findMany({
|
const userFiles = await prisma.file.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ import { ChunkManager, ChunkMetadata } from "./chunk-manager";
|
|||||||
export class FilesystemController {
|
export class FilesystemController {
|
||||||
private chunkManager = ChunkManager.getInstance();
|
private chunkManager = ChunkManager.getInstance();
|
||||||
|
|
||||||
/**
|
|
||||||
* Safely encode filename for Content-Disposition header
|
|
||||||
*/
|
|
||||||
private encodeFilenameForHeader(filename: string): string {
|
private encodeFilenameForHeader(filename: string): string {
|
||||||
if (!filename || filename.trim() === "") {
|
if (!filename || filename.trim() === "") {
|
||||||
return 'attachment; filename="download"';
|
return 'attachment; filename="download"';
|
||||||
@@ -103,9 +100,6 @@ export class FilesystemController {
|
|||||||
await provider.uploadFileFromStream(objectName, request.raw);
|
await provider.uploadFileFromStream(objectName, request.raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract chunk metadata from request headers
|
|
||||||
*/
|
|
||||||
private extractChunkMetadata(request: FastifyRequest): ChunkMetadata | null {
|
private extractChunkMetadata(request: FastifyRequest): ChunkMetadata | null {
|
||||||
const fileId = request.headers["x-file-id"] as string;
|
const fileId = request.headers["x-file-id"] as string;
|
||||||
const chunkIndex = request.headers["x-chunk-index"] as string;
|
const chunkIndex = request.headers["x-chunk-index"] as string;
|
||||||
@@ -132,9 +126,6 @@ export class FilesystemController {
|
|||||||
return metadata;
|
return metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle chunked upload with streaming
|
|
||||||
*/
|
|
||||||
private async handleChunkedUpload(request: FastifyRequest, metadata: ChunkMetadata, originalObjectName: string) {
|
private async handleChunkedUpload(request: FastifyRequest, metadata: ChunkMetadata, originalObjectName: string) {
|
||||||
const stream = request.raw;
|
const stream = request.raw;
|
||||||
|
|
||||||
@@ -145,9 +136,6 @@ export class FilesystemController {
|
|||||||
return await this.chunkManager.processChunk(metadata, stream, originalObjectName);
|
return await this.chunkManager.processChunk(metadata, stream, originalObjectName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get upload progress for chunked uploads
|
|
||||||
*/
|
|
||||||
async getUploadProgress(request: FastifyRequest, reply: FastifyReply) {
|
async getUploadProgress(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const { fileId } = request.params as { fileId: string };
|
const { fileId } = request.params as { fileId: string };
|
||||||
@@ -164,9 +152,6 @@ export class FilesystemController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel chunked upload
|
|
||||||
*/
|
|
||||||
async cancelUpload(request: FastifyRequest, reply: FastifyReply) {
|
async cancelUpload(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const { fileId } = request.params as { fileId: string };
|
const { fileId } = request.params as { fileId: string };
|
||||||
@@ -194,7 +179,6 @@ export class FilesystemController {
|
|||||||
const filePath = provider.getFilePath(tokenData.objectName);
|
const filePath = provider.getFilePath(tokenData.objectName);
|
||||||
const stats = await fs.promises.stat(filePath);
|
const stats = await fs.promises.stat(filePath);
|
||||||
const fileSize = stats.size;
|
const fileSize = stats.size;
|
||||||
const isLargeFile = fileSize > 50 * 1024 * 1024;
|
|
||||||
|
|
||||||
const fileName = tokenData.fileName || "download";
|
const fileName = tokenData.fileName || "download";
|
||||||
const range = request.headers.range;
|
const range = request.headers.range;
|
||||||
@@ -207,28 +191,15 @@ export class FilesystemController {
|
|||||||
const parts = range.replace(/bytes=/, "").split("-");
|
const parts = range.replace(/bytes=/, "").split("-");
|
||||||
const start = parseInt(parts[0], 10);
|
const start = parseInt(parts[0], 10);
|
||||||
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||||
const chunkSize = end - start + 1;
|
|
||||||
|
|
||||||
reply.status(206);
|
reply.status(206);
|
||||||
reply.header("Content-Range", `bytes ${start}-${end}/${fileSize}`);
|
reply.header("Content-Range", `bytes ${start}-${end}/${fileSize}`);
|
||||||
reply.header("Content-Length", chunkSize);
|
reply.header("Content-Length", end - start + 1);
|
||||||
|
|
||||||
if (isLargeFile) {
|
await this.downloadFileRange(reply, provider, tokenData.objectName, start, end);
|
||||||
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 {
|
} else {
|
||||||
reply.header("Content-Length", fileSize);
|
reply.header("Content-Length", fileSize);
|
||||||
|
await this.downloadFileStream(reply, provider, tokenData.objectName);
|
||||||
if (isLargeFile) {
|
|
||||||
await this.downloadLargeFile(reply, provider, filePath);
|
|
||||||
} else {
|
|
||||||
const buffer = await provider.downloadFile(tokenData.objectName);
|
|
||||||
reply.send(buffer);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
provider.consumeDownloadToken(token);
|
provider.consumeDownloadToken(token);
|
||||||
@@ -237,26 +208,75 @@ export class FilesystemController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async downloadLargeFile(reply: FastifyReply, provider: FilesystemStorageProvider, filePath: string) {
|
private async downloadFileStream(reply: FastifyReply, provider: FilesystemStorageProvider, objectName: string) {
|
||||||
const readStream = fs.createReadStream(filePath);
|
|
||||||
const decryptStream = provider.createDecryptStream();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await pipeline(readStream, decryptStream, reply.raw);
|
FilesystemStorageProvider.logMemoryUsage(`Download start: ${objectName}`);
|
||||||
|
|
||||||
|
const downloadStream = provider.createDownloadStream(objectName);
|
||||||
|
|
||||||
|
downloadStream.on("error", (error) => {
|
||||||
|
console.error("Download stream error:", error);
|
||||||
|
FilesystemStorageProvider.logMemoryUsage(`Download error: ${objectName}`);
|
||||||
|
if (!reply.sent) {
|
||||||
|
reply.status(500).send({ error: "Download failed" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.raw.on("close", () => {
|
||||||
|
if (downloadStream.readable && typeof (downloadStream as any).destroy === "function") {
|
||||||
|
(downloadStream as any).destroy();
|
||||||
|
}
|
||||||
|
FilesystemStorageProvider.logMemoryUsage(`Download client disconnect: ${objectName}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await pipeline(downloadStream, reply.raw);
|
||||||
|
|
||||||
|
FilesystemStorageProvider.logMemoryUsage(`Download complete: ${objectName}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
console.error("Download error:", error);
|
||||||
|
FilesystemStorageProvider.logMemoryUsage(`Download failed: ${objectName}`);
|
||||||
|
if (!reply.sent) {
|
||||||
|
reply.status(500).send({ error: "Download failed" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async downloadLargeFileRange(
|
private async downloadFileRange(
|
||||||
reply: FastifyReply,
|
reply: FastifyReply,
|
||||||
provider: FilesystemStorageProvider,
|
provider: FilesystemStorageProvider,
|
||||||
objectName: string,
|
objectName: string,
|
||||||
start: number,
|
start: number,
|
||||||
end: number
|
end: number
|
||||||
) {
|
) {
|
||||||
const buffer = await provider.downloadFile(objectName);
|
try {
|
||||||
const chunk = buffer.slice(start, end + 1);
|
FilesystemStorageProvider.logMemoryUsage(`Range download start: ${objectName} (${start}-${end})`);
|
||||||
reply.send(chunk);
|
|
||||||
|
const rangeStream = await provider.createDownloadRangeStream(objectName, start, end);
|
||||||
|
|
||||||
|
rangeStream.on("error", (error) => {
|
||||||
|
console.error("Range download stream error:", error);
|
||||||
|
FilesystemStorageProvider.logMemoryUsage(`Range download error: ${objectName} (${start}-${end})`);
|
||||||
|
if (!reply.sent) {
|
||||||
|
reply.status(500).send({ error: "Download failed" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.raw.on("close", () => {
|
||||||
|
if (rangeStream.readable && typeof (rangeStream as any).destroy === "function") {
|
||||||
|
(rangeStream as any).destroy();
|
||||||
|
}
|
||||||
|
FilesystemStorageProvider.logMemoryUsage(`Range download client disconnect: ${objectName} (${start}-${end})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await pipeline(rangeStream, reply.raw);
|
||||||
|
|
||||||
|
FilesystemStorageProvider.logMemoryUsage(`Range download complete: ${objectName} (${start}-${end})`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Range download error:", error);
|
||||||
|
FilesystemStorageProvider.logMemoryUsage(`Range download failed: ${objectName} (${start}-${end})`);
|
||||||
|
if (!reply.sent) {
|
||||||
|
reply.status(500).send({ error: "Download failed" });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
import { env } from "../../env";
|
import { env } from "../../env";
|
||||||
|
import { EmailService } from "../email/service";
|
||||||
import { FileService } from "../file/service";
|
import { FileService } from "../file/service";
|
||||||
|
import { UserService } from "../user/service";
|
||||||
import {
|
import {
|
||||||
CreateReverseShareInput,
|
CreateReverseShareInput,
|
||||||
ReverseShareResponseSchema,
|
ReverseShareResponseSchema,
|
||||||
@@ -41,6 +43,19 @@ const prisma = new PrismaClient();
|
|||||||
export class ReverseShareService {
|
export class ReverseShareService {
|
||||||
private reverseShareRepository = new ReverseShareRepository();
|
private reverseShareRepository = new ReverseShareRepository();
|
||||||
private fileService = new FileService();
|
private fileService = new FileService();
|
||||||
|
private emailService = new EmailService();
|
||||||
|
private userService = new UserService();
|
||||||
|
|
||||||
|
private uploadSessions = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
reverseShareId: string;
|
||||||
|
uploaderName: string;
|
||||||
|
uploaderEmail?: string;
|
||||||
|
files: string[];
|
||||||
|
timeout: NodeJS.Timeout;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
async createReverseShare(data: CreateReverseShareInput, creatorId: string) {
|
async createReverseShare(data: CreateReverseShareInput, creatorId: string) {
|
||||||
const reverseShare = await this.reverseShareRepository.create(data, creatorId);
|
const reverseShare = await this.reverseShareRepository.create(data, creatorId);
|
||||||
@@ -295,6 +310,8 @@ export class ReverseShareService {
|
|||||||
size: BigInt(fileData.size),
|
size: BigInt(fileData.size),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.addFileToUploadSession(reverseShare, fileData);
|
||||||
|
|
||||||
return this.formatFileResponse(file);
|
return this.formatFileResponse(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,6 +362,8 @@ export class ReverseShareService {
|
|||||||
size: BigInt(fileData.size),
|
size: BigInt(fileData.size),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.addFileToUploadSession(reverseShare, fileData);
|
||||||
|
|
||||||
return this.formatFileResponse(file);
|
return this.formatFileResponse(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,17 +533,7 @@ export class ReverseShareService {
|
|||||||
throw new Error(`File size exceeds the maximum allowed size of ${maxSizeMB}MB`);
|
throw new Error(`File size exceeds the maximum allowed size of ${maxSizeMB}MB`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if DEMO_MODE is enabled
|
const maxTotalStorage = BigInt(await configService.getValue("maxTotalStoragePerUser"));
|
||||||
const isDemoMode = env.DEMO_MODE === "true";
|
|
||||||
|
|
||||||
let maxTotalStorage: bigint;
|
|
||||||
if (isDemoMode) {
|
|
||||||
// In demo mode, limit all users to 200MB
|
|
||||||
maxTotalStorage = BigInt(200 * 1024 * 1024); // 200MB in bytes
|
|
||||||
} else {
|
|
||||||
// Normal behavior - use maxTotalStoragePerUser configuration
|
|
||||||
maxTotalStorage = BigInt(await configService.getValue("maxTotalStoragePerUser"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const userFiles = await prisma.file.findMany({
|
const userFiles = await prisma.file.findMany({
|
||||||
where: { userId: creatorId },
|
where: { userId: creatorId },
|
||||||
@@ -637,6 +646,55 @@ export class ReverseShareService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private generateSessionKey(reverseShareId: string, uploaderIdentifier: string): string {
|
||||||
|
return `${reverseShareId}-${uploaderIdentifier}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendBatchFileUploadNotification(reverseShare: any, uploaderName: string, fileNames: string[]) {
|
||||||
|
try {
|
||||||
|
const creator = await this.userService.getUserById(reverseShare.creatorId);
|
||||||
|
const reverseShareName = reverseShare.name || "Unnamed Reverse Share";
|
||||||
|
const fileCount = fileNames.length;
|
||||||
|
const fileList = fileNames.join(", ");
|
||||||
|
|
||||||
|
await this.emailService.sendReverseShareBatchFileNotification(
|
||||||
|
creator.email,
|
||||||
|
reverseShareName,
|
||||||
|
fileCount,
|
||||||
|
fileList,
|
||||||
|
uploaderName
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send reverse share batch file notification:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private addFileToUploadSession(reverseShare: any, fileData: UploadToReverseShareInput) {
|
||||||
|
const uploaderIdentifier = fileData.uploaderEmail || fileData.uploaderName || "anonymous";
|
||||||
|
const sessionKey = this.generateSessionKey(reverseShare.id, uploaderIdentifier);
|
||||||
|
const uploaderName = fileData.uploaderName || "Someone";
|
||||||
|
|
||||||
|
const existingSession = this.uploadSessions.get(sessionKey);
|
||||||
|
if (existingSession) {
|
||||||
|
clearTimeout(existingSession.timeout);
|
||||||
|
existingSession.files.push(fileData.name);
|
||||||
|
} else {
|
||||||
|
this.uploadSessions.set(sessionKey, {
|
||||||
|
reverseShareId: reverseShare.id,
|
||||||
|
uploaderName,
|
||||||
|
uploaderEmail: fileData.uploaderEmail,
|
||||||
|
files: [fileData.name],
|
||||||
|
timeout: null as any,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = this.uploadSessions.get(sessionKey)!;
|
||||||
|
session.timeout = setTimeout(async () => {
|
||||||
|
await this.sendBatchFileUploadNotification(reverseShare, session.uploaderName, session.files);
|
||||||
|
this.uploadSessions.delete(sessionKey);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
private formatReverseShareResponse(reverseShare: ReverseShareData) {
|
private formatReverseShareResponse(reverseShare: ReverseShareData) {
|
||||||
const result = {
|
const result = {
|
||||||
id: reverseShare.id,
|
id: reverseShare.id,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import bcrypt from "bcryptjs";
|
|||||||
|
|
||||||
import { prisma } from "../../shared/prisma";
|
import { prisma } from "../../shared/prisma";
|
||||||
import { EmailService } from "../email/service";
|
import { EmailService } from "../email/service";
|
||||||
|
import { UserService } from "../user/service";
|
||||||
import { CreateShareInput, ShareResponseSchema, UpdateShareInput } from "./dto";
|
import { CreateShareInput, ShareResponseSchema, UpdateShareInput } from "./dto";
|
||||||
import { IShareRepository, PrismaShareRepository } from "./repository";
|
import { IShareRepository, PrismaShareRepository } from "./repository";
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ export class ShareService {
|
|||||||
constructor(private readonly shareRepository: IShareRepository = new PrismaShareRepository()) {}
|
constructor(private readonly shareRepository: IShareRepository = new PrismaShareRepository()) {}
|
||||||
|
|
||||||
private emailService = new EmailService();
|
private emailService = new EmailService();
|
||||||
|
private userService = new UserService();
|
||||||
|
|
||||||
private formatShareResponse(share: any) {
|
private formatShareResponse(share: any) {
|
||||||
return {
|
return {
|
||||||
@@ -339,11 +341,26 @@ export class ShareService {
|
|||||||
throw new Error("No recipients found for this share");
|
throw new Error("No recipients found for this share");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get sender information
|
||||||
|
let senderName = "Someone";
|
||||||
|
try {
|
||||||
|
const sender = await this.userService.getUserById(userId);
|
||||||
|
if (sender.firstName && sender.lastName) {
|
||||||
|
senderName = `${sender.firstName} ${sender.lastName}`;
|
||||||
|
} else if (sender.firstName) {
|
||||||
|
senderName = sender.firstName;
|
||||||
|
} else if (sender.username) {
|
||||||
|
senderName = sender.username;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to get sender information for user ${userId}:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
const notifiedRecipients: string[] = [];
|
const notifiedRecipients: string[] = [];
|
||||||
|
|
||||||
for (const recipient of share.recipients) {
|
for (const recipient of share.recipients) {
|
||||||
try {
|
try {
|
||||||
await this.emailService.sendShareNotification(recipient.email, shareLink, share.name || undefined);
|
await this.emailService.sendShareNotification(recipient.email, shareLink, share.name || undefined, senderName);
|
||||||
notifiedRecipients.push(recipient.email);
|
notifiedRecipients.push(recipient.email);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to send email to ${recipient.email}:`, error);
|
console.error(`Failed to send email to ${recipient.email}:`, error);
|
||||||
|
|||||||
@@ -324,29 +324,7 @@ export class StorageService {
|
|||||||
uploadAllowed: boolean;
|
uploadAllowed: boolean;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const isDemoMode = env.DEMO_MODE === "true";
|
|
||||||
|
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
if (isDemoMode) {
|
|
||||||
const demoMaxStorage = 200 * 1024 * 1024;
|
|
||||||
const demoMaxStorageGB = this._ensureNumber(demoMaxStorage / (1024 * 1024 * 1024), 0);
|
|
||||||
|
|
||||||
const userFiles = await prisma.file.findMany({
|
|
||||||
where: { userId },
|
|
||||||
select: { size: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalUsedStorage = userFiles.reduce((acc, file) => acc + file.size, BigInt(0));
|
|
||||||
const usedStorageGB = this._ensureNumber(Number(totalUsedStorage) / (1024 * 1024 * 1024), 0);
|
|
||||||
const availableStorageGB = this._ensureNumber(demoMaxStorageGB - usedStorageGB, 0);
|
|
||||||
|
|
||||||
return {
|
|
||||||
diskSizeGB: Number(demoMaxStorageGB.toFixed(2)),
|
|
||||||
diskUsedGB: Number(usedStorageGB.toFixed(2)),
|
|
||||||
diskAvailableGB: Number(availableStorageGB.toFixed(2)),
|
|
||||||
uploadAllowed: availableStorageGB > 0,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
const diskInfo = await this._getDiskSpaceMultiplePaths();
|
const diskInfo = await this._getDiskSpaceMultiplePaths();
|
||||||
|
|
||||||
if (!diskInfo) {
|
if (!diskInfo) {
|
||||||
@@ -366,28 +344,7 @@ export class StorageService {
|
|||||||
diskAvailableGB: Number(diskAvailableGB.toFixed(2)),
|
diskAvailableGB: Number(diskAvailableGB.toFixed(2)),
|
||||||
uploadAllowed: diskAvailableGB > 0.1,
|
uploadAllowed: diskAvailableGB > 0.1,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
} else if (userId) {
|
} else if (userId) {
|
||||||
if (isDemoMode) {
|
|
||||||
const demoMaxStorage = 200 * 1024 * 1024;
|
|
||||||
const demoMaxStorageGB = this._ensureNumber(demoMaxStorage / (1024 * 1024 * 1024), 0);
|
|
||||||
|
|
||||||
const userFiles = await prisma.file.findMany({
|
|
||||||
where: { userId },
|
|
||||||
select: { size: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalUsedStorage = userFiles.reduce((acc, file) => acc + file.size, BigInt(0));
|
|
||||||
const usedStorageGB = this._ensureNumber(Number(totalUsedStorage) / (1024 * 1024 * 1024), 0);
|
|
||||||
const availableStorageGB = this._ensureNumber(demoMaxStorageGB - usedStorageGB, 0);
|
|
||||||
|
|
||||||
return {
|
|
||||||
diskSizeGB: Number(demoMaxStorageGB.toFixed(2)),
|
|
||||||
diskUsedGB: Number(usedStorageGB.toFixed(2)),
|
|
||||||
diskAvailableGB: Number(availableStorageGB.toFixed(2)),
|
|
||||||
uploadAllowed: availableStorageGB > 0,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
const maxTotalStorage = BigInt(await this.configService.getValue("maxTotalStoragePerUser"));
|
const maxTotalStorage = BigInt(await this.configService.getValue("maxTotalStoragePerUser"));
|
||||||
const maxStorageGB = this._ensureNumber(Number(maxTotalStorage) / (1024 * 1024 * 1024), 10);
|
const maxStorageGB = this._ensureNumber(Number(maxTotalStorage) / (1024 * 1024 * 1024), 10);
|
||||||
|
|
||||||
@@ -407,7 +364,6 @@ export class StorageService {
|
|||||||
uploadAllowed: availableStorageGB > 0,
|
uploadAllowed: availableStorageGB > 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("User ID is required for non-admin users");
|
throw new Error("User ID is required for non-admin users");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -20,9 +20,16 @@ export class FilesystemStorageProvider implements StorageProvider {
|
|||||||
private constructor() {
|
private constructor() {
|
||||||
this.uploadsDir = directoriesConfig.uploads;
|
this.uploadsDir = directoriesConfig.uploads;
|
||||||
|
|
||||||
|
if (!this.isEncryptionDisabled && !this.encryptionKey) {
|
||||||
|
throw new Error(
|
||||||
|
"Encryption is enabled but ENCRYPTION_KEY is not provided. " +
|
||||||
|
"Please set ENCRYPTION_KEY environment variable or set DISABLE_FILESYSTEM_ENCRYPTION=true to disable encryption."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
this.ensureUploadsDir();
|
this.ensureUploadsDir();
|
||||||
setInterval(() => this.cleanExpiredTokens(), 5 * 60 * 1000);
|
setInterval(() => this.cleanExpiredTokens(), 5 * 60 * 1000);
|
||||||
setInterval(() => this.cleanupEmptyTempDirs(), 10 * 60 * 1000); // Every 10 minutes
|
setInterval(() => this.cleanupEmptyTempDirs(), 10 * 60 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static getInstance(): FilesystemStorageProvider {
|
public static getInstance(): FilesystemStorageProvider {
|
||||||
@@ -62,6 +69,11 @@ export class FilesystemStorageProvider implements StorageProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private createEncryptionKey(): Buffer {
|
private createEncryptionKey(): Buffer {
|
||||||
|
if (!this.encryptionKey) {
|
||||||
|
throw new Error(
|
||||||
|
"Encryption key is required when encryption is enabled. Please set ENCRYPTION_KEY environment variable."
|
||||||
|
);
|
||||||
|
}
|
||||||
return crypto.scryptSync(this.encryptionKey, "salt", 32);
|
return crypto.scryptSync(this.encryptionKey, "salt", 32);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,6 +265,183 @@ export class FilesystemStorageProvider implements StorageProvider {
|
|||||||
return this.decryptFileLegacy(fileBuffer);
|
return this.decryptFileLegacy(fileBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createDownloadStream(objectName: string): NodeJS.ReadableStream {
|
||||||
|
const filePath = this.getFilePath(objectName);
|
||||||
|
const fileStream = fsSync.createReadStream(filePath);
|
||||||
|
|
||||||
|
if (this.isEncryptionDisabled) {
|
||||||
|
fileStream.on("end", () => {
|
||||||
|
if (global.gc) {
|
||||||
|
global.gc();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fileStream.on("close", () => {
|
||||||
|
if (global.gc) {
|
||||||
|
global.gc();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return fileStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decryptStream = this.createDecryptStream();
|
||||||
|
let isDestroyed = false;
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (isDestroyed) return;
|
||||||
|
isDestroyed = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fileStream && !fileStream.destroyed) {
|
||||||
|
fileStream.destroy();
|
||||||
|
}
|
||||||
|
if (decryptStream && !decryptStream.destroyed) {
|
||||||
|
decryptStream.destroy();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Error during download stream cleanup:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (global.gc) {
|
||||||
|
global.gc();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fileStream.on("error", cleanup);
|
||||||
|
decryptStream.on("error", cleanup);
|
||||||
|
decryptStream.on("end", cleanup);
|
||||||
|
decryptStream.on("close", cleanup);
|
||||||
|
|
||||||
|
decryptStream.on("pipe", (src: any) => {
|
||||||
|
if (src && src.on) {
|
||||||
|
src.on("close", cleanup);
|
||||||
|
src.on("error", cleanup);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return fileStream.pipe(decryptStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createDownloadRangeStream(objectName: string, start: number, end: number): Promise<NodeJS.ReadableStream> {
|
||||||
|
if (!this.isEncryptionDisabled) {
|
||||||
|
return this.createRangeStreamFromDecrypted(objectName, start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = this.getFilePath(objectName);
|
||||||
|
return fsSync.createReadStream(filePath, { start, end });
|
||||||
|
}
|
||||||
|
|
||||||
|
private createRangeStreamFromDecrypted(objectName: string, start: number, end: number): NodeJS.ReadableStream {
|
||||||
|
const { Transform, PassThrough } = require("stream");
|
||||||
|
const filePath = this.getFilePath(objectName);
|
||||||
|
const fileStream = fsSync.createReadStream(filePath);
|
||||||
|
const decryptStream = this.createDecryptStream();
|
||||||
|
const rangeStream = new PassThrough();
|
||||||
|
|
||||||
|
let bytesRead = 0;
|
||||||
|
let rangeEnded = false;
|
||||||
|
let isDestroyed = false;
|
||||||
|
|
||||||
|
const rangeTransform = new Transform({
|
||||||
|
transform(chunk: Buffer, encoding: any, callback: any) {
|
||||||
|
if (rangeEnded || isDestroyed) {
|
||||||
|
callback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkStart = bytesRead;
|
||||||
|
const chunkEnd = bytesRead + chunk.length - 1;
|
||||||
|
bytesRead += chunk.length;
|
||||||
|
|
||||||
|
if (chunkEnd < start) {
|
||||||
|
callback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunkStart > end) {
|
||||||
|
rangeEnded = true;
|
||||||
|
this.end();
|
||||||
|
callback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sliceStart = 0;
|
||||||
|
let sliceEnd = chunk.length;
|
||||||
|
|
||||||
|
if (chunkStart < start) {
|
||||||
|
sliceStart = start - chunkStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunkEnd > end) {
|
||||||
|
sliceEnd = end - chunkStart + 1;
|
||||||
|
rangeEnded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slicedChunk = chunk.slice(sliceStart, sliceEnd);
|
||||||
|
this.push(slicedChunk);
|
||||||
|
|
||||||
|
if (rangeEnded) {
|
||||||
|
this.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
|
||||||
|
flush(callback: any) {
|
||||||
|
if (global.gc) {
|
||||||
|
global.gc();
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (isDestroyed) return;
|
||||||
|
isDestroyed = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fileStream && !fileStream.destroyed) {
|
||||||
|
fileStream.destroy();
|
||||||
|
}
|
||||||
|
if (decryptStream && !decryptStream.destroyed) {
|
||||||
|
decryptStream.destroy();
|
||||||
|
}
|
||||||
|
if (rangeTransform && !rangeTransform.destroyed) {
|
||||||
|
rangeTransform.destroy();
|
||||||
|
}
|
||||||
|
if (rangeStream && !rangeStream.destroyed) {
|
||||||
|
rangeStream.destroy();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Error during stream cleanup:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (global.gc) {
|
||||||
|
global.gc();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fileStream.on("error", cleanup);
|
||||||
|
decryptStream.on("error", cleanup);
|
||||||
|
rangeTransform.on("error", cleanup);
|
||||||
|
rangeStream.on("error", cleanup);
|
||||||
|
|
||||||
|
rangeStream.on("close", cleanup);
|
||||||
|
rangeStream.on("end", cleanup);
|
||||||
|
|
||||||
|
rangeStream.on("pipe", (src: any) => {
|
||||||
|
if (src && src.on) {
|
||||||
|
src.on("close", cleanup);
|
||||||
|
src.on("error", cleanup);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fileStream.pipe(decryptStream).pipe(rangeTransform).pipe(rangeStream);
|
||||||
|
|
||||||
|
return rangeStream;
|
||||||
|
}
|
||||||
|
|
||||||
private decryptFileBuffer(encryptedBuffer: Buffer): Buffer {
|
private decryptFileBuffer(encryptedBuffer: Buffer): Buffer {
|
||||||
const key = this.createEncryptionKey();
|
const key = this.createEncryptionKey();
|
||||||
const iv = encryptedBuffer.slice(0, 16);
|
const iv = encryptedBuffer.slice(0, 16);
|
||||||
@@ -264,11 +453,69 @@ export class FilesystemStorageProvider implements StorageProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private decryptFileLegacy(encryptedBuffer: Buffer): Buffer {
|
private decryptFileLegacy(encryptedBuffer: Buffer): Buffer {
|
||||||
|
if (!this.encryptionKey) {
|
||||||
|
throw new Error(
|
||||||
|
"Encryption key is required when encryption is enabled. Please set ENCRYPTION_KEY environment variable."
|
||||||
|
);
|
||||||
|
}
|
||||||
const CryptoJS = require("crypto-js");
|
const CryptoJS = require("crypto-js");
|
||||||
const decrypted = CryptoJS.AES.decrypt(encryptedBuffer.toString("utf8"), this.encryptionKey);
|
const decrypted = CryptoJS.AES.decrypt(encryptedBuffer.toString("utf8"), this.encryptionKey);
|
||||||
return Buffer.from(decrypted.toString(CryptoJS.enc.Utf8), "base64");
|
return Buffer.from(decrypted.toString(CryptoJS.enc.Utf8), "base64");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static logMemoryUsage(context: string = "Unknown"): void {
|
||||||
|
const memUsage = process.memoryUsage();
|
||||||
|
const formatBytes = (bytes: number) => {
|
||||||
|
const mb = bytes / 1024 / 1024;
|
||||||
|
return `${mb.toFixed(2)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rssInMB = memUsage.rss / 1024 / 1024;
|
||||||
|
const heapUsedInMB = memUsage.heapUsed / 1024 / 1024;
|
||||||
|
|
||||||
|
if (rssInMB > 1024 || heapUsedInMB > 512) {
|
||||||
|
console.warn(`[MEMORY WARNING] ${context} - High memory usage detected:`);
|
||||||
|
console.warn(` RSS: ${formatBytes(memUsage.rss)}`);
|
||||||
|
console.warn(` Heap Used: ${formatBytes(memUsage.heapUsed)}`);
|
||||||
|
console.warn(` Heap Total: ${formatBytes(memUsage.heapTotal)}`);
|
||||||
|
console.warn(` External: ${formatBytes(memUsage.external)}`);
|
||||||
|
|
||||||
|
if (global.gc) {
|
||||||
|
console.warn(" Forcing garbage collection...");
|
||||||
|
global.gc();
|
||||||
|
|
||||||
|
const afterGC = process.memoryUsage();
|
||||||
|
console.warn(` After GC - RSS: ${formatBytes(afterGC.rss)}, Heap: ${formatBytes(afterGC.heapUsed)}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`[MEMORY INFO] ${context} - RSS: ${formatBytes(memUsage.rss)}, Heap: ${formatBytes(memUsage.heapUsed)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static forceGarbageCollection(context: string = "Manual"): void {
|
||||||
|
if (global.gc) {
|
||||||
|
const beforeGC = process.memoryUsage();
|
||||||
|
global.gc();
|
||||||
|
const afterGC = process.memoryUsage();
|
||||||
|
|
||||||
|
const formatBytes = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||||
|
|
||||||
|
console.log(`[GC] ${context} - Before: RSS ${formatBytes(beforeGC.rss)}, Heap ${formatBytes(beforeGC.heapUsed)}`);
|
||||||
|
console.log(`[GC] ${context} - After: RSS ${formatBytes(afterGC.rss)}, Heap ${formatBytes(afterGC.heapUsed)}`);
|
||||||
|
|
||||||
|
const rssSaved = beforeGC.rss - afterGC.rss;
|
||||||
|
const heapSaved = beforeGC.heapUsed - afterGC.heapUsed;
|
||||||
|
|
||||||
|
if (rssSaved > 0 || heapSaved > 0) {
|
||||||
|
console.log(`[GC] ${context} - Freed: RSS ${formatBytes(rssSaved)}, Heap ${formatBytes(heapSaved)}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`[GC] ${context} - Garbage collection not available. Start Node.js with --expose-gc flag.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fileExists(objectName: string): Promise<boolean> {
|
async fileExists(objectName: string): Promise<boolean> {
|
||||||
const filePath = this.getFilePath(objectName);
|
const filePath = this.getFilePath(objectName);
|
||||||
try {
|
try {
|
||||||
@@ -313,9 +560,6 @@ export class FilesystemStorageProvider implements StorageProvider {
|
|||||||
this.downloadTokens.delete(token);
|
this.downloadTokens.delete(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up temporary file and its parent directory if empty
|
|
||||||
*/
|
|
||||||
private async cleanupTempFile(tempPath: string): Promise<void> {
|
private async cleanupTempFile(tempPath: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await fs.unlink(tempPath);
|
await fs.unlink(tempPath);
|
||||||
@@ -338,9 +582,6 @@ export class FilesystemStorageProvider implements StorageProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up empty temporary directories periodically
|
|
||||||
*/
|
|
||||||
private async cleanupEmptyTempDirs(): Promise<void> {
|
private async cleanupEmptyTempDirs(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const tempUploadsDir = directoriesConfig.tempUploads;
|
const tempUploadsDir = directoriesConfig.tempUploads;
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
API_BASE_URL=http:localhost:3333
|
API_BASE_URL=http:localhost:3333
|
||||||
|
NEXT_PUBLIC_DEFAULT_LANGUAGE=en-US
|
||||||
@@ -159,7 +159,8 @@
|
|||||||
"passwordLabel": "كلمة المرور",
|
"passwordLabel": "كلمة المرور",
|
||||||
"create": "إنشاء مشاركة",
|
"create": "إنشاء مشاركة",
|
||||||
"success": "تم إنشاء المشاركة بنجاح",
|
"success": "تم إنشاء المشاركة بنجاح",
|
||||||
"error": "فشل في إنشاء المشاركة"
|
"error": "فشل في إنشاء المشاركة",
|
||||||
|
"namePlaceholder": "أدخل اسمًا لمشاركتك"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"loadError": "فشل في تحميل بيانات لوحة التحكم",
|
"loadError": "فشل في تحميل بيانات لوحة التحكم",
|
||||||
@@ -313,7 +314,8 @@
|
|||||||
"title": "نسيت كلمة المرور",
|
"title": "نسيت كلمة المرور",
|
||||||
"description": "أدخل بريدك الإلكتروني وسنرسل لك تعليمات إعادة تعيين كلمة المرور.",
|
"description": "أدخل بريدك الإلكتروني وسنرسل لك تعليمات إعادة تعيين كلمة المرور.",
|
||||||
"resetInstructions": "تم إرسال تعليمات إعادة التعيين إلى بريدك الإلكتروني",
|
"resetInstructions": "تم إرسال تعليمات إعادة التعيين إلى بريدك الإلكتروني",
|
||||||
"pageTitle": "نسيت كلمة المرور"
|
"pageTitle": "نسيت كلمة المرور",
|
||||||
|
"passwordAuthDisabled": "تم تعطيل المصادقة بكلمة المرور. يرجى الاتصال بالمسؤول أو استخدام مزود مصادقة خارجي."
|
||||||
},
|
},
|
||||||
"generateShareLink": {
|
"generateShareLink": {
|
||||||
"generateTitle": "إنشاء رابط المشاركة",
|
"generateTitle": "إنشاء رابط المشاركة",
|
||||||
@@ -327,7 +329,12 @@
|
|||||||
"copyButton": "نسخ الرابط",
|
"copyButton": "نسخ الرابط",
|
||||||
"success": "تم إنشاء الرابط بنجاح",
|
"success": "تم إنشاء الرابط بنجاح",
|
||||||
"error": "فشل في إنشاء الرابط",
|
"error": "فشل في إنشاء الرابط",
|
||||||
"copied": "تم نسخ الرابط إلى الحافظة"
|
"copied": "تم نسخ الرابط إلى الحافظة",
|
||||||
|
"readyDescription": "رابط المشاركة الخاص بك جاهز. يمكنك مسح رمز QR مباشرة، أو تنزيله للاستخدام لاحقًا، أو نسخ الرابط أدناه.",
|
||||||
|
"tabs": {
|
||||||
|
"link": "الرابط",
|
||||||
|
"qrcode": "رمز QR"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"description": "البديل مفتوح المصدر لـ WeTransfer. شارك ملفاتك بأمان، دون تتبع أو قيود.",
|
"description": "البديل مفتوح المصدر لـ WeTransfer. شارك ملفاتك بأمان، دون تتبع أو قيود.",
|
||||||
@@ -355,6 +362,12 @@
|
|||||||
"stats": "{iconCount} أيقونة من {libraryCount} مكتبة",
|
"stats": "{iconCount} أيقونة من {libraryCount} مكتبة",
|
||||||
"categoryBadge": "{category} ({count} أيقونات)"
|
"categoryBadge": "{category} ({count} أيقونات)"
|
||||||
},
|
},
|
||||||
|
"imageEdit": {
|
||||||
|
"title": "تعديل الصورة",
|
||||||
|
"rotate": "تدوير",
|
||||||
|
"zoom": "تكبير/تصغير",
|
||||||
|
"cropInstructions": "اسحب لإعادة تحديد الموضع، غير حجم الزوايا لضبط منطقة القص"
|
||||||
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"welcome": "مرحبا بك",
|
"welcome": "مرحبا بك",
|
||||||
"signInToContinue": "قم بتسجيل الدخول للمتابعة",
|
"signInToContinue": "قم بتسجيل الدخول للمتابعة",
|
||||||
@@ -442,6 +455,11 @@
|
|||||||
},
|
},
|
||||||
"pageTitle": "الملف الشخصي"
|
"pageTitle": "الملف الشخصي"
|
||||||
},
|
},
|
||||||
|
"qrCodeModal": {
|
||||||
|
"title": "مشاركة رمز QR",
|
||||||
|
"description": "امسح رمز QR هذا للوصول إلى الرابط.",
|
||||||
|
"download": "تحميل رمز QR"
|
||||||
|
},
|
||||||
"quickAccess": {
|
"quickAccess": {
|
||||||
"files": {
|
"files": {
|
||||||
"title": "ملفاتي",
|
"title": "ملفاتي",
|
||||||
@@ -613,7 +631,8 @@
|
|||||||
"createLink": "إنشاء رابط",
|
"createLink": "إنشاء رابط",
|
||||||
"delete": "حذف",
|
"delete": "حذف",
|
||||||
"copyLinkTitle": "نسخ الرابط",
|
"copyLinkTitle": "نسخ الرابط",
|
||||||
"createLinkCTA": "إنشاء رابط استلام"
|
"createLinkCTA": "إنشاء رابط استلام",
|
||||||
|
"viewQrCode": "عرض رمز QR"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"active": "نشط",
|
"active": "نشط",
|
||||||
@@ -629,7 +648,8 @@
|
|||||||
"viewDetails": "عرض التفاصيل",
|
"viewDetails": "عرض التفاصيل",
|
||||||
"edit": "تحرير",
|
"edit": "تحرير",
|
||||||
"delete": "حذف",
|
"delete": "حذف",
|
||||||
"viewFiles": "الملفات المستلمة"
|
"viewFiles": "الملفات المستلمة",
|
||||||
|
"viewQrCode": "عرض رمز QR"
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "لم يتم إنشاء روابط استلام",
|
"title": "لم يتم إنشاء روابط استلام",
|
||||||
@@ -1117,6 +1137,10 @@
|
|||||||
"smtpTrustSelfSigned": {
|
"smtpTrustSelfSigned": {
|
||||||
"title": "الوثوق بالشهادات الموقعة ذاتياً",
|
"title": "الوثوق بالشهادات الموقعة ذاتياً",
|
||||||
"description": "قم بتمكين هذا للوثوق بشهادات SSL/TLS الموقعة ذاتياً (مفيد لبيئات التطوير)"
|
"description": "قم بتمكين هذا للوثوق بشهادات SSL/TLS الموقعة ذاتياً (مفيد لبيئات التطوير)"
|
||||||
|
},
|
||||||
|
"passwordAuthEnabled": {
|
||||||
|
"title": "المصادقة بالكلمة السرية",
|
||||||
|
"description": "تمكين أو تعطيل المصادقة بالكلمة السرية"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
@@ -1126,7 +1150,8 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadFailed": "فشل في تحميل الإعدادات",
|
"loadFailed": "فشل في تحميل الإعدادات",
|
||||||
"updateFailed": "فشل في تحديث الإعدادات"
|
"updateFailed": "فشل في تحديث الإعدادات",
|
||||||
|
"passwordAuthRequiresProvider": "لا يمكن تعطيل المصادقة بالكلمة السرية دون وجود على الأقل موفرين مصادقة مفعلين"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"noChanges": "لا توجد تغييرات للحفظ",
|
"noChanges": "لا توجد تغييرات للحفظ",
|
||||||
@@ -1232,7 +1257,10 @@
|
|||||||
"invalidDate": "تاريخ غير صحيح",
|
"invalidDate": "تاريخ غير صحيح",
|
||||||
"loadError": "فشل في تحميل تفاصيل المشاركة",
|
"loadError": "فشل في تحميل تفاصيل المشاركة",
|
||||||
"editSecurity": "تحرير الأمان",
|
"editSecurity": "تحرير الأمان",
|
||||||
"editExpiration": "تحرير انتهاء الصلاحية"
|
"editExpiration": "تحرير انتهاء الصلاحية",
|
||||||
|
"clickToEnlargeQrCode": "انقر لتكبير رمز QR",
|
||||||
|
"downloadQrCode": "تحميل رمز QR",
|
||||||
|
"qrCode": "رمز QR"
|
||||||
},
|
},
|
||||||
"shareExpiration": {
|
"shareExpiration": {
|
||||||
"neverExpires": "لا تنتهي صلاحيته أبداً",
|
"neverExpires": "لا تنتهي صلاحيته أبداً",
|
||||||
@@ -1419,7 +1447,8 @@
|
|||||||
"copyLink": "نسخ الرابط",
|
"copyLink": "نسخ الرابط",
|
||||||
"notifyRecipients": "إشعار المستقبلين",
|
"notifyRecipients": "إشعار المستقبلين",
|
||||||
"delete": "حذف",
|
"delete": "حذف",
|
||||||
"downloadShareFiles": "قم بتنزيل جميع الملفات"
|
"downloadShareFiles": "قم بتنزيل جميع الملفات",
|
||||||
|
"viewQrCode": "عرض رمز QR"
|
||||||
},
|
},
|
||||||
"bulkActions": {
|
"bulkActions": {
|
||||||
"delete": "حذف",
|
"delete": "حذف",
|
||||||
@@ -1636,7 +1665,8 @@
|
|||||||
"title": "إفلات الملفات للرفع",
|
"title": "إفلات الملفات للرفع",
|
||||||
"description": "حرر للرفع ملفاتك"
|
"description": "حرر للرفع ملفاتك"
|
||||||
},
|
},
|
||||||
"pasteSuccess": "{count, plural, =1 {تم لصق الصورة ورفعها بنجاح} other {تم لصق # صور ورفعها بنجاح}}"
|
"pasteSuccess": "{count, plural, =1 {تم لصق الصورة ورفعها بنجاح} other {تم لصق # صور ورفعها بنجاح}}",
|
||||||
|
"filesQueued": "{count, plural, one {# ملف في الصف} other {# ملفات في الصف}}"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"modes": {
|
"modes": {
|
||||||
@@ -1721,11 +1751,5 @@
|
|||||||
"passwordRequired": "كلمة المرور مطلوبة",
|
"passwordRequired": "كلمة المرور مطلوبة",
|
||||||
"nameRequired": "الاسم مطلوب",
|
"nameRequired": "الاسم مطلوب",
|
||||||
"required": "هذا الحقل مطلوب"
|
"required": "هذا الحقل مطلوب"
|
||||||
},
|
|
||||||
"imageEdit": {
|
|
||||||
"title": "تعديل الصورة",
|
|
||||||
"rotate": "تدوير",
|
|
||||||
"zoom": "تكبير/تصغير",
|
|
||||||
"cropInstructions": "اسحب لإعادة تحديد الموضع، غير حجم الزوايا لضبط منطقة القص"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,8 @@
|
|||||||
"passwordLabel": "Passwort",
|
"passwordLabel": "Passwort",
|
||||||
"create": "Freigabe Erstellen",
|
"create": "Freigabe Erstellen",
|
||||||
"success": "Freigabe erfolgreich erstellt",
|
"success": "Freigabe erfolgreich erstellt",
|
||||||
"error": "Fehler beim Erstellen der Freigabe"
|
"error": "Fehler beim Erstellen der Freigabe",
|
||||||
|
"namePlaceholder": "Geben Sie einen Namen für Ihre Freigabe ein"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"loadError": "Fehler beim Laden der Dashboard-Daten",
|
"loadError": "Fehler beim Laden der Dashboard-Daten",
|
||||||
@@ -313,7 +314,8 @@
|
|||||||
"title": "Passwort vergessen",
|
"title": "Passwort vergessen",
|
||||||
"description": "Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen Anweisungen zum Zurücksetzen Ihres Passworts.",
|
"description": "Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen Anweisungen zum Zurücksetzen Ihres Passworts.",
|
||||||
"resetInstructions": "Anweisungen zum Zurücksetzen wurden an Ihre E-Mail gesendet",
|
"resetInstructions": "Anweisungen zum Zurücksetzen wurden an Ihre E-Mail gesendet",
|
||||||
"pageTitle": "Passwort vergessen"
|
"pageTitle": "Passwort vergessen",
|
||||||
|
"passwordAuthDisabled": "Passwortauthentifizierung ist deaktiviert. Bitte kontaktieren Sie Ihren Administrator oder verwenden Sie einen externen Authentifizierungsanbieter."
|
||||||
},
|
},
|
||||||
"generateShareLink": {
|
"generateShareLink": {
|
||||||
"generateTitle": "Freigabe-Link generieren",
|
"generateTitle": "Freigabe-Link generieren",
|
||||||
@@ -327,7 +329,12 @@
|
|||||||
"copyButton": "Link kopieren",
|
"copyButton": "Link kopieren",
|
||||||
"success": "Link erfolgreich generiert",
|
"success": "Link erfolgreich generiert",
|
||||||
"error": "Fehler beim Generieren des Links",
|
"error": "Fehler beim Generieren des Links",
|
||||||
"copied": "Link in die Zwischenablage kopiert"
|
"copied": "Link in die Zwischenablage kopiert",
|
||||||
|
"readyDescription": "Ihr Freigabe-Link ist bereit. Sie können den QR-Code direkt scannen, ihn für die spätere Verwendung herunterladen oder den Link unten kopieren.",
|
||||||
|
"tabs": {
|
||||||
|
"link": "Link",
|
||||||
|
"qrcode": "QR-Code"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"description": "Die Open-Source-Alternative zu WeTransfer. Teilen Sie Dateien sicher, ohne Tracking oder Einschränkungen.",
|
"description": "Die Open-Source-Alternative zu WeTransfer. Teilen Sie Dateien sicher, ohne Tracking oder Einschränkungen.",
|
||||||
@@ -355,6 +362,12 @@
|
|||||||
"stats": "{iconCount} Symbole aus {libraryCount} Bibliotheken",
|
"stats": "{iconCount} Symbole aus {libraryCount} Bibliotheken",
|
||||||
"categoryBadge": "{category} ({count} Symbole)"
|
"categoryBadge": "{category} ({count} Symbole)"
|
||||||
},
|
},
|
||||||
|
"imageEdit": {
|
||||||
|
"title": "Bild bearbeiten",
|
||||||
|
"rotate": "Drehen",
|
||||||
|
"zoom": "Zoom",
|
||||||
|
"cropInstructions": "Ziehen Sie, um die Position zu ändern, ändern Sie die Größe der Ecken, um die Zuschneidefläche anzupassen"
|
||||||
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"welcome": "Willkommen zu",
|
"welcome": "Willkommen zu",
|
||||||
"signInToContinue": "Melden Sie sich an, um fortzufahren",
|
"signInToContinue": "Melden Sie sich an, um fortzufahren",
|
||||||
@@ -442,6 +455,11 @@
|
|||||||
},
|
},
|
||||||
"pageTitle": "Profil"
|
"pageTitle": "Profil"
|
||||||
},
|
},
|
||||||
|
"qrCodeModal": {
|
||||||
|
"title": "QR-Code teilen",
|
||||||
|
"description": "Scannen Sie diesen QR-Code, um auf den Link zuzugreifen.",
|
||||||
|
"download": "QR-Code herunterladen"
|
||||||
|
},
|
||||||
"quickAccess": {
|
"quickAccess": {
|
||||||
"files": {
|
"files": {
|
||||||
"title": "Meine Dateien",
|
"title": "Meine Dateien",
|
||||||
@@ -613,7 +631,8 @@
|
|||||||
"createLink": "Link erstellen",
|
"createLink": "Link erstellen",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
"copyLinkTitle": "Link kopieren",
|
"copyLinkTitle": "Link kopieren",
|
||||||
"createLinkCTA": "Empfangslink erstellen"
|
"createLinkCTA": "Empfangslink erstellen",
|
||||||
|
"viewQrCode": "QR-Code anzeigen"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"active": "Aktiv",
|
"active": "Aktiv",
|
||||||
@@ -629,7 +648,8 @@
|
|||||||
"viewDetails": "Details anzeigen",
|
"viewDetails": "Details anzeigen",
|
||||||
"edit": "Bearbeiten",
|
"edit": "Bearbeiten",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
"viewFiles": "Empfangene Dateien"
|
"viewFiles": "Empfangene Dateien",
|
||||||
|
"viewQrCode": "QR-Code anzeigen"
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "Keine Empfangslinks erstellt",
|
"title": "Keine Empfangslinks erstellt",
|
||||||
@@ -1115,6 +1135,10 @@
|
|||||||
"tls": "STARTTLS (Port 587)",
|
"tls": "STARTTLS (Port 587)",
|
||||||
"none": "Keine (Unsicher)"
|
"none": "Keine (Unsicher)"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"passwordAuthEnabled": {
|
||||||
|
"title": "Passwort-Authentifizierung",
|
||||||
|
"description": "Passwort-basierte Authentifizierung aktivieren oder deaktivieren"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
@@ -1124,7 +1148,8 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadFailed": "Fehler beim Laden der Einstellungen",
|
"loadFailed": "Fehler beim Laden der Einstellungen",
|
||||||
"updateFailed": "Fehler beim Aktualisieren der Einstellungen"
|
"updateFailed": "Fehler beim Aktualisieren der Einstellungen",
|
||||||
|
"passwordAuthRequiresProvider": "Passwort-basierte Authentifizierung kann nicht deaktiviert werden, wenn kein aktiver Authentifizierungsanbieter vorhanden ist"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"noChanges": "Keine Änderungen zum Speichern",
|
"noChanges": "Keine Änderungen zum Speichern",
|
||||||
@@ -1230,7 +1255,10 @@
|
|||||||
"invalidDate": "Ungültiges Datum",
|
"invalidDate": "Ungültiges Datum",
|
||||||
"loadError": "Fehler beim Laden der Freigabe-Details",
|
"loadError": "Fehler beim Laden der Freigabe-Details",
|
||||||
"editSecurity": "Sicherheit bearbeiten",
|
"editSecurity": "Sicherheit bearbeiten",
|
||||||
"editExpiration": "Ablauf bearbeiten"
|
"editExpiration": "Ablauf bearbeiten",
|
||||||
|
"clickToEnlargeQrCode": "Klicken Sie zum Vergrößern des QR-Codes",
|
||||||
|
"downloadQrCode": "QR-Code herunterladen",
|
||||||
|
"qrCode": "QR-Code"
|
||||||
},
|
},
|
||||||
"shareExpiration": {
|
"shareExpiration": {
|
||||||
"neverExpires": "Läuft nie ab",
|
"neverExpires": "Läuft nie ab",
|
||||||
@@ -1417,7 +1445,8 @@
|
|||||||
"copyLink": "Link Kopieren",
|
"copyLink": "Link Kopieren",
|
||||||
"notifyRecipients": "Empfänger Benachrichtigen",
|
"notifyRecipients": "Empfänger Benachrichtigen",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
"downloadShareFiles": "Laden Sie alle Dateien herunter"
|
"downloadShareFiles": "Laden Sie alle Dateien herunter",
|
||||||
|
"viewQrCode": "QR-Code anzeigen"
|
||||||
},
|
},
|
||||||
"bulkActions": {
|
"bulkActions": {
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
@@ -1634,7 +1663,8 @@
|
|||||||
"title": "Dateien zum Hochladen ablegen",
|
"title": "Dateien zum Hochladen ablegen",
|
||||||
"description": "Loslassen, um Ihre Dateien hochzuladen"
|
"description": "Loslassen, um Ihre Dateien hochzuladen"
|
||||||
},
|
},
|
||||||
"pasteSuccess": "{count, plural, =1 {Bild erfolgreich eingefügt und hochgeladen} other {# Bilder erfolgreich eingefügt und hochgeladen}}"
|
"pasteSuccess": "{count, plural, =1 {Bild erfolgreich eingefügt und hochgeladen} other {# Bilder erfolgreich eingefügt und hochgeladen}}",
|
||||||
|
"filesQueued": "{count, plural, one {# Datei in der Warteschlange} other {# Dateien in der Warteschlange}}"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"modes": {
|
"modes": {
|
||||||
@@ -1719,11 +1749,5 @@
|
|||||||
"passwordRequired": "Passwort ist erforderlich",
|
"passwordRequired": "Passwort ist erforderlich",
|
||||||
"nameRequired": "Name ist erforderlich",
|
"nameRequired": "Name ist erforderlich",
|
||||||
"required": "Dieses Feld ist erforderlich"
|
"required": "Dieses Feld ist erforderlich"
|
||||||
},
|
|
||||||
"imageEdit": {
|
|
||||||
"title": "Bild bearbeiten",
|
|
||||||
"rotate": "Drehen",
|
|
||||||
"zoom": "Zoom",
|
|
||||||
"cropInstructions": "Ziehen Sie, um die Position zu ändern, ändern Sie die Größe der Ecken, um die Zuschneidefläche anzupassen"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,6 +149,7 @@
|
|||||||
"createShare": {
|
"createShare": {
|
||||||
"title": "Create Share",
|
"title": "Create Share",
|
||||||
"nameLabel": "Share Name",
|
"nameLabel": "Share Name",
|
||||||
|
"namePlaceholder": "Enter a name for your share",
|
||||||
"descriptionLabel": "Description",
|
"descriptionLabel": "Description",
|
||||||
"descriptionPlaceholder": "Enter a description (optional)",
|
"descriptionPlaceholder": "Enter a description (optional)",
|
||||||
"expirationLabel": "Expiration Date",
|
"expirationLabel": "Expiration Date",
|
||||||
@@ -313,21 +314,27 @@
|
|||||||
"title": "Forgot Password",
|
"title": "Forgot Password",
|
||||||
"description": "Enter your email address and we'll send you instructions to reset your password",
|
"description": "Enter your email address and we'll send you instructions to reset your password",
|
||||||
"resetInstructions": "Reset instructions sent to your email",
|
"resetInstructions": "Reset instructions sent to your email",
|
||||||
"pageTitle": "Forgot Password"
|
"pageTitle": "Forgot Password",
|
||||||
|
"passwordAuthDisabled": "Password authentication is disabled. Please contact your administrator or use an external authentication provider."
|
||||||
},
|
},
|
||||||
"generateShareLink": {
|
"generateShareLink": {
|
||||||
"generateTitle": "Generate Share Link",
|
"generateTitle": "Generate Share Link",
|
||||||
"updateTitle": "Update Share Link",
|
"updateTitle": "Update Share Link",
|
||||||
"generateDescription": "Generate a link to share your files",
|
"generateDescription": "Generate a custom link for this share. You can customize the URL to make it more memorable.",
|
||||||
"updateDescription": "Update the alias for this share link",
|
"updateDescription": "Update the custom link for this share. You can customize the URL to make it more memorable.",
|
||||||
"aliasPlaceholder": "Enter alias",
|
"aliasPlaceholder": "Custom ID for link",
|
||||||
"linkReady": "Your share link is ready:",
|
"linkReady": "Your share link is ready. You can copy it now.",
|
||||||
|
"readyDescription": "Your share link is ready. You can scan the QR code directly, download it for later use, or copy the link below.",
|
||||||
"generateButton": "Generate Link",
|
"generateButton": "Generate Link",
|
||||||
"updateButton": "Update Link",
|
"updateButton": "Update Link",
|
||||||
"copyButton": "Copy Link",
|
"copyButton": "Copy Link",
|
||||||
"success": "Link generated successfully",
|
"success": "Link generated successfully",
|
||||||
"error": "Failed to generate link",
|
"error": "Failed to generate link",
|
||||||
"copied": "Link copied to clipboard"
|
"copied": "Link copied to clipboard",
|
||||||
|
"tabs": {
|
||||||
|
"link": "Link",
|
||||||
|
"qrcode": "QR Code"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"description": "The open-source alternative to WeTransfer. Share files securely, without tracking or limitations.",
|
"description": "The open-source alternative to WeTransfer. Share files securely, without tracking or limitations.",
|
||||||
@@ -355,6 +362,12 @@
|
|||||||
"stats": "{iconCount} icons from {libraryCount} libraries",
|
"stats": "{iconCount} icons from {libraryCount} libraries",
|
||||||
"categoryBadge": "{category} ({count} icons)"
|
"categoryBadge": "{category} ({count} icons)"
|
||||||
},
|
},
|
||||||
|
"imageEdit": {
|
||||||
|
"title": "Edit Image",
|
||||||
|
"rotate": "Rotate",
|
||||||
|
"zoom": "Zoom",
|
||||||
|
"cropInstructions": "Drag to reposition, resize corners to adjust crop area"
|
||||||
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"welcome": "Welcome to",
|
"welcome": "Welcome to",
|
||||||
"signInToContinue": "Sign in to continue",
|
"signInToContinue": "Sign in to continue",
|
||||||
@@ -400,12 +413,6 @@
|
|||||||
"navigation": {
|
"navigation": {
|
||||||
"dashboard": "Dashboard"
|
"dashboard": "Dashboard"
|
||||||
},
|
},
|
||||||
"imageEdit": {
|
|
||||||
"title": "Edit Image",
|
|
||||||
"rotate": "Rotate",
|
|
||||||
"zoom": "Zoom",
|
|
||||||
"cropInstructions": "Drag to reposition, resize corners to adjust crop area"
|
|
||||||
},
|
|
||||||
"profile": {
|
"profile": {
|
||||||
"password": {
|
"password": {
|
||||||
"title": "Change Password",
|
"title": "Change Password",
|
||||||
@@ -448,6 +455,11 @@
|
|||||||
},
|
},
|
||||||
"pageTitle": "Profile"
|
"pageTitle": "Profile"
|
||||||
},
|
},
|
||||||
|
"qrCodeModal": {
|
||||||
|
"title": "Share QR Code",
|
||||||
|
"description": "Scan this QR code to access the link.",
|
||||||
|
"download": "Download QR Code"
|
||||||
|
},
|
||||||
"quickAccess": {
|
"quickAccess": {
|
||||||
"files": {
|
"files": {
|
||||||
"title": "My Files",
|
"title": "My Files",
|
||||||
@@ -613,6 +625,7 @@
|
|||||||
"expired": "Expired",
|
"expired": "Expired",
|
||||||
"expires": "Expires",
|
"expires": "Expires",
|
||||||
"viewDetails": "View details",
|
"viewDetails": "View details",
|
||||||
|
"viewQrCode": "View QR Code",
|
||||||
"copyLink": "Copy Link",
|
"copyLink": "Copy Link",
|
||||||
"openInNewTab": "Open in New Tab",
|
"openInNewTab": "Open in New Tab",
|
||||||
"editLink": "Edit Link",
|
"editLink": "Edit Link",
|
||||||
@@ -635,7 +648,8 @@
|
|||||||
"viewDetails": "View Details",
|
"viewDetails": "View Details",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"viewFiles": "Received Files"
|
"viewFiles": "Received Files",
|
||||||
|
"viewQrCode": "View QR Code"
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "No receive links created",
|
"title": "No receive links created",
|
||||||
@@ -1119,6 +1133,10 @@
|
|||||||
"serverUrl": {
|
"serverUrl": {
|
||||||
"title": "Server URL",
|
"title": "Server URL",
|
||||||
"description": "Base URL of the Palmr server (e.g.: https://palmr.example.com)"
|
"description": "Base URL of the Palmr server (e.g.: https://palmr.example.com)"
|
||||||
|
},
|
||||||
|
"passwordAuthEnabled": {
|
||||||
|
"title": "Password Authentication",
|
||||||
|
"description": "Enable or disable password-based authentication"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
@@ -1128,7 +1146,8 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadFailed": "Failed to load settings",
|
"loadFailed": "Failed to load settings",
|
||||||
"updateFailed": "Failed to update settings"
|
"updateFailed": "Failed to update settings",
|
||||||
|
"passwordAuthRequiresProvider": "Cannot disable password authentication without having at least one active authentication provider"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"noChanges": "No changes to save",
|
"noChanges": "No changes to save",
|
||||||
@@ -1206,34 +1225,36 @@
|
|||||||
},
|
},
|
||||||
"shareDetails": {
|
"shareDetails": {
|
||||||
"title": "Share Details",
|
"title": "Share Details",
|
||||||
"subtitle": "Detailed information about this share",
|
"subtitle": "View and manage details for this share",
|
||||||
"basicInfo": "Basic Information",
|
"basicInfo": "Basic Information",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
"noDescription": "No description provided",
|
|
||||||
"untitled": "Untitled",
|
|
||||||
"shareLink": "Share Link",
|
"shareLink": "Share Link",
|
||||||
"editLink": "Edit Link",
|
|
||||||
"generateLink": "Generate Link",
|
|
||||||
"noLink": "No link generated yet",
|
|
||||||
"copyLink": "Copy link",
|
|
||||||
"openLink": "Open in new tab",
|
|
||||||
"linkCopied": "Link copied to clipboard",
|
|
||||||
"views": "Views",
|
|
||||||
"dates": "Dates",
|
"dates": "Dates",
|
||||||
|
"security": "Security",
|
||||||
|
"files": "Files",
|
||||||
|
"recipients": "Recipients",
|
||||||
|
"views": "Views",
|
||||||
"created": "Created",
|
"created": "Created",
|
||||||
"expires": "Expires",
|
"expires": "Expires",
|
||||||
"never": "Never",
|
"never": "Never",
|
||||||
"security": "Security",
|
"untitled": "Untitled Share",
|
||||||
"editSecurity": "Edit Security",
|
"noDescription": "No description",
|
||||||
"editExpiration": "Edit Expiration",
|
"notAvailable": "N/A",
|
||||||
|
"invalidDate": "Invalid date",
|
||||||
"passwordProtected": "Password Protected",
|
"passwordProtected": "Password Protected",
|
||||||
"publicAccess": "Public Access",
|
"publicAccess": "Public Access",
|
||||||
"maxViews": "Max Views:",
|
"maxViews": "Max Views:",
|
||||||
"files": "Files",
|
"noLink": "No link generated",
|
||||||
"recipients": "Recipients",
|
"generateLink": "Generate Link",
|
||||||
"notAvailable": "N/A",
|
"editLink": "Edit Link",
|
||||||
"invalidDate": "Invalid date",
|
"copyLink": "Copy Link",
|
||||||
|
"openLink": "Open Link",
|
||||||
|
"editSecurity": "Edit Security",
|
||||||
|
"editExpiration": "Edit Expiration",
|
||||||
|
"qrCode": "QR Code",
|
||||||
|
"downloadQrCode": "Download QR Code",
|
||||||
|
"clickToEnlargeQrCode": "Click to enlarge QR Code",
|
||||||
"loadError": "Failed to load share details"
|
"loadError": "Failed to load share details"
|
||||||
},
|
},
|
||||||
"shareExpiration": {
|
"shareExpiration": {
|
||||||
@@ -1419,6 +1440,7 @@
|
|||||||
"generateLink": "Generate Link",
|
"generateLink": "Generate Link",
|
||||||
"editLink": "Edit Link",
|
"editLink": "Edit Link",
|
||||||
"copyLink": "Copy Link",
|
"copyLink": "Copy Link",
|
||||||
|
"viewQrCode": "View QR Code",
|
||||||
"notifyRecipients": "Notify Recipients",
|
"notifyRecipients": "Notify Recipients",
|
||||||
"downloadShareFiles": "Download All Files",
|
"downloadShareFiles": "Download All Files",
|
||||||
"delete": "Delete"
|
"delete": "Delete"
|
||||||
@@ -1613,6 +1635,7 @@
|
|||||||
"selectFile": "Click to select a file",
|
"selectFile": "Click to select a file",
|
||||||
"selectMultipleFiles": "Click to select one or multiple files",
|
"selectMultipleFiles": "Click to select one or multiple files",
|
||||||
"dragAndDrop": "or drag and drop files here",
|
"dragAndDrop": "or drag and drop files here",
|
||||||
|
"filesQueued": "{count, plural, one {# file queued for upload} other {# files queued for upload}}",
|
||||||
"preview": "Preview",
|
"preview": "Preview",
|
||||||
"uploadProgress": "Upload progress",
|
"uploadProgress": "Upload progress",
|
||||||
"upload": "Upload",
|
"upload": "Upload",
|
||||||
@@ -1688,8 +1711,8 @@
|
|||||||
"status": {
|
"status": {
|
||||||
"title": "Confirm Status Change",
|
"title": "Confirm Status Change",
|
||||||
"confirmation": "Are you sure you want to {action} user {firstName} {lastName}?",
|
"confirmation": "Are you sure you want to {action} user {firstName} {lastName}?",
|
||||||
"activate": "activate",
|
"activate": "Activate",
|
||||||
"deactivate": "deactivate",
|
"deactivate": "Deactivate",
|
||||||
"user": "User"
|
"user": "User"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
|
|||||||
@@ -159,7 +159,8 @@
|
|||||||
"passwordLabel": "Contraseña",
|
"passwordLabel": "Contraseña",
|
||||||
"create": "Crear Compartir",
|
"create": "Crear Compartir",
|
||||||
"success": "Compartir creado exitosamente",
|
"success": "Compartir creado exitosamente",
|
||||||
"error": "Error al crear compartir"
|
"error": "Error al crear compartir",
|
||||||
|
"namePlaceholder": "Ingrese un nombre para su compartir"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"loadError": "Error al cargar los datos del tablero",
|
"loadError": "Error al cargar los datos del tablero",
|
||||||
@@ -313,7 +314,8 @@
|
|||||||
"title": "Recuperar contraseña",
|
"title": "Recuperar contraseña",
|
||||||
"description": "Introduce tu dirección de correo electrónico y te enviaremos instrucciones para restablecer tu contraseña.",
|
"description": "Introduce tu dirección de correo electrónico y te enviaremos instrucciones para restablecer tu contraseña.",
|
||||||
"resetInstructions": "Instrucciones de restablecimiento enviadas a tu correo electrónico",
|
"resetInstructions": "Instrucciones de restablecimiento enviadas a tu correo electrónico",
|
||||||
"pageTitle": "Recuperar contraseña"
|
"pageTitle": "Recuperar contraseña",
|
||||||
|
"passwordAuthDisabled": "La autenticación por contraseña está deshabilitada. Por favor, contacta a tu administrador o usa un proveedor de autenticación externo."
|
||||||
},
|
},
|
||||||
"generateShareLink": {
|
"generateShareLink": {
|
||||||
"generateTitle": "Generar enlace de compartir",
|
"generateTitle": "Generar enlace de compartir",
|
||||||
@@ -327,7 +329,12 @@
|
|||||||
"copyButton": "Copiar enlace",
|
"copyButton": "Copiar enlace",
|
||||||
"success": "Enlace generado exitosamente",
|
"success": "Enlace generado exitosamente",
|
||||||
"error": "Error al generar enlace",
|
"error": "Error al generar enlace",
|
||||||
"copied": "Enlace copiado al portapapeles"
|
"copied": "Enlace copiado al portapapeles",
|
||||||
|
"readyDescription": "Tu enlace de compartir está listo. Puedes escanear el código QR directamente, descargarlo para usarlo más tarde, o copiar el enlace a continuación.",
|
||||||
|
"tabs": {
|
||||||
|
"link": "Enlace",
|
||||||
|
"qrcode": "Código QR"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"description": "La alternativa de código abierto a WeTransfer. Comparte archivos de forma segura, sin rastreo ni limitaciones.",
|
"description": "La alternativa de código abierto a WeTransfer. Comparte archivos de forma segura, sin rastreo ni limitaciones.",
|
||||||
@@ -355,6 +362,12 @@
|
|||||||
"stats": "{iconCount} iconos de {libraryCount} bibliotecas",
|
"stats": "{iconCount} iconos de {libraryCount} bibliotecas",
|
||||||
"categoryBadge": "{category} ({count} iconos)"
|
"categoryBadge": "{category} ({count} iconos)"
|
||||||
},
|
},
|
||||||
|
"imageEdit": {
|
||||||
|
"title": "Editar Imagen",
|
||||||
|
"rotate": "Rotar",
|
||||||
|
"zoom": "Zoom",
|
||||||
|
"cropInstructions": "Arrastra para reubicar, ajusta las esquinas para ajustar el área de recorte"
|
||||||
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"welcome": "Bienvenido a",
|
"welcome": "Bienvenido a",
|
||||||
"signInToContinue": "Inicia sesión para continuar",
|
"signInToContinue": "Inicia sesión para continuar",
|
||||||
@@ -442,6 +455,11 @@
|
|||||||
},
|
},
|
||||||
"pageTitle": "Perfil"
|
"pageTitle": "Perfil"
|
||||||
},
|
},
|
||||||
|
"qrCodeModal": {
|
||||||
|
"title": "Compartir Código QR",
|
||||||
|
"description": "Escanea este código QR para acceder al enlace.",
|
||||||
|
"download": "Descargar Código QR"
|
||||||
|
},
|
||||||
"quickAccess": {
|
"quickAccess": {
|
||||||
"files": {
|
"files": {
|
||||||
"title": "Mis archivos",
|
"title": "Mis archivos",
|
||||||
@@ -613,7 +631,8 @@
|
|||||||
"createLink": "Crear Enlace",
|
"createLink": "Crear Enlace",
|
||||||
"delete": "Eliminar",
|
"delete": "Eliminar",
|
||||||
"copyLinkTitle": "Copiar enlace",
|
"copyLinkTitle": "Copiar enlace",
|
||||||
"createLinkCTA": "Crear Enlace de Recepción"
|
"createLinkCTA": "Crear Enlace de Recepción",
|
||||||
|
"viewQrCode": "Ver Código QR"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"active": "Activo",
|
"active": "Activo",
|
||||||
@@ -629,7 +648,8 @@
|
|||||||
"viewDetails": "Ver Detalles",
|
"viewDetails": "Ver Detalles",
|
||||||
"edit": "Editar",
|
"edit": "Editar",
|
||||||
"delete": "Eliminar",
|
"delete": "Eliminar",
|
||||||
"viewFiles": "Archivos Recibidos"
|
"viewFiles": "Archivos Recibidos",
|
||||||
|
"viewQrCode": "Ver Código QR"
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "Ningún enlace de recepción creado",
|
"title": "Ningún enlace de recepción creado",
|
||||||
@@ -1115,6 +1135,10 @@
|
|||||||
"tls": "STARTTLS (Puerto 587)",
|
"tls": "STARTTLS (Puerto 587)",
|
||||||
"none": "Ninguno (Inseguro)"
|
"none": "Ninguno (Inseguro)"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"passwordAuthEnabled": {
|
||||||
|
"title": "Autenticación por Contraseña",
|
||||||
|
"description": "Habilitar o deshabilitar la autenticación basada en contraseña"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
@@ -1124,7 +1148,8 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadFailed": "Error al cargar la configuración",
|
"loadFailed": "Error al cargar la configuración",
|
||||||
"updateFailed": "Error al actualizar la configuración"
|
"updateFailed": "Error al actualizar la configuración",
|
||||||
|
"passwordAuthRequiresProvider": "No se puede deshabilitar la autenticación por contraseña sin tener al menos un proveedor de autenticación activo"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"noChanges": "No hay cambios para guardar",
|
"noChanges": "No hay cambios para guardar",
|
||||||
@@ -1230,7 +1255,10 @@
|
|||||||
"invalidDate": "Fecha inválida",
|
"invalidDate": "Fecha inválida",
|
||||||
"loadError": "Error al cargar detalles del compartir",
|
"loadError": "Error al cargar detalles del compartir",
|
||||||
"editSecurity": "Editar Seguridad",
|
"editSecurity": "Editar Seguridad",
|
||||||
"editExpiration": "Editar Expiración"
|
"editExpiration": "Editar Expiración",
|
||||||
|
"clickToEnlargeQrCode": "Haz clic para ampliar el Código QR",
|
||||||
|
"downloadQrCode": "Descargar Código QR",
|
||||||
|
"qrCode": "Código QR"
|
||||||
},
|
},
|
||||||
"shareExpiration": {
|
"shareExpiration": {
|
||||||
"neverExpires": "Nunca Expira",
|
"neverExpires": "Nunca Expira",
|
||||||
@@ -1417,7 +1445,8 @@
|
|||||||
"copyLink": "Copiar Enlace",
|
"copyLink": "Copiar Enlace",
|
||||||
"notifyRecipients": "Notificar Destinatarios",
|
"notifyRecipients": "Notificar Destinatarios",
|
||||||
"delete": "Eliminar",
|
"delete": "Eliminar",
|
||||||
"downloadShareFiles": "Descargar todos los archivos"
|
"downloadShareFiles": "Descargar todos los archivos",
|
||||||
|
"viewQrCode": "Ver Código QR"
|
||||||
},
|
},
|
||||||
"bulkActions": {
|
"bulkActions": {
|
||||||
"delete": "Eliminar",
|
"delete": "Eliminar",
|
||||||
@@ -1634,7 +1663,8 @@
|
|||||||
"title": "Suelta archivos para subir",
|
"title": "Suelta archivos para subir",
|
||||||
"description": "Suelta para subir tus archivos"
|
"description": "Suelta para subir tus archivos"
|
||||||
},
|
},
|
||||||
"pasteSuccess": "{count, plural, =1 {Imagen pegada y subida exitosamente} other {# imágenes pegadas y subidas exitosamente}}"
|
"pasteSuccess": "{count, plural, =1 {Imagen pegada y subida exitosamente} other {# imágenes pegadas y subidas exitosamente}}",
|
||||||
|
"filesQueued": "{count, plural, one {# archivo en cola para subir} other {# archivos en cola para subir}}"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"modes": {
|
"modes": {
|
||||||
@@ -1719,11 +1749,5 @@
|
|||||||
"passwordRequired": "Se requiere la contraseña",
|
"passwordRequired": "Se requiere la contraseña",
|
||||||
"nameRequired": "El nombre es obligatorio",
|
"nameRequired": "El nombre es obligatorio",
|
||||||
"required": "Este campo es obligatorio"
|
"required": "Este campo es obligatorio"
|
||||||
},
|
|
||||||
"imageEdit": {
|
|
||||||
"title": "Editar Imagen",
|
|
||||||
"rotate": "Rotar",
|
|
||||||
"zoom": "Zoom",
|
|
||||||
"cropInstructions": "Arrastra para reubicar, ajusta las esquinas para ajustar el área de recorte"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,8 @@
|
|||||||
"passwordLabel": "Mot de Passe",
|
"passwordLabel": "Mot de Passe",
|
||||||
"create": "Créer un Partage",
|
"create": "Créer un Partage",
|
||||||
"success": "Partage créé avec succès",
|
"success": "Partage créé avec succès",
|
||||||
"error": "Échec de la création du partage"
|
"error": "Échec de la création du partage",
|
||||||
|
"namePlaceholder": "Entrez un nom pour votre partage"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"loadError": "Échec du chargement des données du tableau de bord",
|
"loadError": "Échec du chargement des données du tableau de bord",
|
||||||
@@ -313,7 +314,8 @@
|
|||||||
"title": "Mot de Passe Oublié",
|
"title": "Mot de Passe Oublié",
|
||||||
"description": "Entrez votre adresse email et nous vous enverrons les instructions pour réinitialiser votre mot de passe.",
|
"description": "Entrez votre adresse email et nous vous enverrons les instructions pour réinitialiser votre mot de passe.",
|
||||||
"resetInstructions": "Instructions de réinitialisation envoyées à votre email",
|
"resetInstructions": "Instructions de réinitialisation envoyées à votre email",
|
||||||
"pageTitle": "Mot de Passe Oublié"
|
"pageTitle": "Mot de Passe Oublié",
|
||||||
|
"passwordAuthDisabled": "L'authentification par mot de passe est désactivée. Veuillez contacter votre administrateur ou utiliser un fournisseur d'authentification externe."
|
||||||
},
|
},
|
||||||
"generateShareLink": {
|
"generateShareLink": {
|
||||||
"generateTitle": "Générer un lien de partage",
|
"generateTitle": "Générer un lien de partage",
|
||||||
@@ -327,7 +329,12 @@
|
|||||||
"copyButton": "Copier le lien",
|
"copyButton": "Copier le lien",
|
||||||
"success": "Lien généré avec succès",
|
"success": "Lien généré avec succès",
|
||||||
"error": "Échec de la génération du lien",
|
"error": "Échec de la génération du lien",
|
||||||
"copied": "Lien copié dans le presse-papiers"
|
"copied": "Lien copié dans le presse-papiers",
|
||||||
|
"readyDescription": "Votre lien de partage est prêt. Vous pouvez scanner le QR code directement, le télécharger pour une utilisation ultérieure, ou copier le lien ci-dessous.",
|
||||||
|
"tabs": {
|
||||||
|
"link": "Lien",
|
||||||
|
"qrcode": "QR Code"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"description": "L'alternative open-source à WeTransfer. Partagez des fichiers en toute sécurité, sans suivi ni limitations.",
|
"description": "L'alternative open-source à WeTransfer. Partagez des fichiers en toute sécurité, sans suivi ni limitations.",
|
||||||
@@ -355,6 +362,12 @@
|
|||||||
"stats": "{iconCount} icônes de {libraryCount} bibliothèques",
|
"stats": "{iconCount} icônes de {libraryCount} bibliothèques",
|
||||||
"categoryBadge": "{category} ({count} icônes)"
|
"categoryBadge": "{category} ({count} icônes)"
|
||||||
},
|
},
|
||||||
|
"imageEdit": {
|
||||||
|
"title": "Modifier l'Image",
|
||||||
|
"rotate": "Tourner",
|
||||||
|
"zoom": "Zoom",
|
||||||
|
"cropInstructions": "Glisser pour répositionner, redimensionner les coins pour ajuster la zone de découpe"
|
||||||
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"welcome": "Bienvenue à",
|
"welcome": "Bienvenue à",
|
||||||
"signInToContinue": "Connectez-vous pour continuer",
|
"signInToContinue": "Connectez-vous pour continuer",
|
||||||
@@ -442,6 +455,11 @@
|
|||||||
},
|
},
|
||||||
"pageTitle": "Profil"
|
"pageTitle": "Profil"
|
||||||
},
|
},
|
||||||
|
"qrCodeModal": {
|
||||||
|
"title": "Code QR de Partage",
|
||||||
|
"description": "Scannez ce code QR pour accéder au lien.",
|
||||||
|
"download": "Télécharger le Code QR"
|
||||||
|
},
|
||||||
"quickAccess": {
|
"quickAccess": {
|
||||||
"files": {
|
"files": {
|
||||||
"title": "Mes Fichiers",
|
"title": "Mes Fichiers",
|
||||||
@@ -613,7 +631,8 @@
|
|||||||
"createLink": "Créer un Lien",
|
"createLink": "Créer un Lien",
|
||||||
"delete": "Supprimer",
|
"delete": "Supprimer",
|
||||||
"copyLinkTitle": "Copier le lien",
|
"copyLinkTitle": "Copier le lien",
|
||||||
"createLinkCTA": "Créer un Lien de Réception"
|
"createLinkCTA": "Créer un Lien de Réception",
|
||||||
|
"viewQrCode": "Voir le QR Code"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"active": "Actif",
|
"active": "Actif",
|
||||||
@@ -629,7 +648,8 @@
|
|||||||
"viewDetails": "Voir les Détails",
|
"viewDetails": "Voir les Détails",
|
||||||
"edit": "Modifier",
|
"edit": "Modifier",
|
||||||
"delete": "Supprimer",
|
"delete": "Supprimer",
|
||||||
"viewFiles": "Fichiers Reçus"
|
"viewFiles": "Fichiers Reçus",
|
||||||
|
"viewQrCode": "Voir le QR Code"
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "Aucun lien de réception créé",
|
"title": "Aucun lien de réception créé",
|
||||||
@@ -1118,6 +1138,10 @@
|
|||||||
"smtpTrustSelfSigned": {
|
"smtpTrustSelfSigned": {
|
||||||
"title": "Faire Confiance aux Certificats Auto-signés",
|
"title": "Faire Confiance aux Certificats Auto-signés",
|
||||||
"description": "Activez cette option pour faire confiance aux certificats SSL/TLS auto-signés (utile pour les environnements de développement)"
|
"description": "Activez cette option pour faire confiance aux certificats SSL/TLS auto-signés (utile pour les environnements de développement)"
|
||||||
|
},
|
||||||
|
"passwordAuthEnabled": {
|
||||||
|
"title": "Authentification par Mot de Passe",
|
||||||
|
"description": "Activer ou désactiver l'authentification basée sur mot de passe"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
@@ -1127,7 +1151,8 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadFailed": "Échec du chargement des paramètres",
|
"loadFailed": "Échec du chargement des paramètres",
|
||||||
"updateFailed": "Échec de la mise à jour des paramètres"
|
"updateFailed": "Échec de la mise à jour des paramètres",
|
||||||
|
"passwordAuthRequiresProvider": "Impossible de désactiver l'authentification par mot de passe sans avoir au moins un fournisseur d'authentification actif"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"noChanges": "Aucun changement à enregistrer",
|
"noChanges": "Aucun changement à enregistrer",
|
||||||
@@ -1230,7 +1255,10 @@
|
|||||||
"invalidDate": "Date invalide",
|
"invalidDate": "Date invalide",
|
||||||
"loadError": "Échec du chargement des détails du partage",
|
"loadError": "Échec du chargement des détails du partage",
|
||||||
"editSecurity": "Modifier la Sécurité",
|
"editSecurity": "Modifier la Sécurité",
|
||||||
"editExpiration": "Modifier l'Expiration"
|
"editExpiration": "Modifier l'Expiration",
|
||||||
|
"clickToEnlargeQrCode": "Cliquez pour agrandir le Code QR",
|
||||||
|
"downloadQrCode": "Télécharger le Code QR",
|
||||||
|
"qrCode": "Code QR"
|
||||||
},
|
},
|
||||||
"shareExpiration": {
|
"shareExpiration": {
|
||||||
"neverExpires": "N'expire Jamais",
|
"neverExpires": "N'expire Jamais",
|
||||||
@@ -1417,7 +1445,8 @@
|
|||||||
"copyLink": "Copier le Lien",
|
"copyLink": "Copier le Lien",
|
||||||
"notifyRecipients": "Notifier les Destinataires",
|
"notifyRecipients": "Notifier les Destinataires",
|
||||||
"delete": "Supprimer",
|
"delete": "Supprimer",
|
||||||
"downloadShareFiles": "Télécharger tous les fichiers"
|
"downloadShareFiles": "Télécharger tous les fichiers",
|
||||||
|
"viewQrCode": "Voir le QR Code"
|
||||||
},
|
},
|
||||||
"bulkActions": {
|
"bulkActions": {
|
||||||
"delete": "Supprimer",
|
"delete": "Supprimer",
|
||||||
@@ -1634,7 +1663,8 @@
|
|||||||
"title": "Déposer des fichiers pour télécharger",
|
"title": "Déposer des fichiers pour télécharger",
|
||||||
"description": "Relâchez pour télécharger vos fichiers"
|
"description": "Relâchez pour télécharger vos fichiers"
|
||||||
},
|
},
|
||||||
"pasteSuccess": "{count, plural, =1 {Image collée et téléchargée avec succès} other {# images collées et téléchargées avec succès}}"
|
"pasteSuccess": "{count, plural, =1 {Image collée et téléchargée avec succès} other {# images collées et téléchargées avec succès}}",
|
||||||
|
"filesQueued": "{count, plural, one {# fichier en attente de téléchargement} other {# fichiers en attente de téléchargement}}"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"modes": {
|
"modes": {
|
||||||
@@ -1719,11 +1749,5 @@
|
|||||||
"passwordRequired": "Le mot de passe est requis",
|
"passwordRequired": "Le mot de passe est requis",
|
||||||
"nameRequired": "Nome é obrigatório",
|
"nameRequired": "Nome é obrigatório",
|
||||||
"required": "Este campo é obrigatório"
|
"required": "Este campo é obrigatório"
|
||||||
},
|
|
||||||
"imageEdit": {
|
|
||||||
"title": "Modifier l'Image",
|
|
||||||
"rotate": "Tourner",
|
|
||||||
"zoom": "Zoom",
|
|
||||||
"cropInstructions": "Glisser pour répositionner, redimensionner les coins pour ajuster la zone de découpe"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,8 @@
|
|||||||
"passwordLabel": "पासवर्ड",
|
"passwordLabel": "पासवर्ड",
|
||||||
"create": "साझाकरण बनाएं",
|
"create": "साझाकरण बनाएं",
|
||||||
"success": "साझाकरण सफलतापूर्वक बनाया गया",
|
"success": "साझाकरण सफलतापूर्वक बनाया गया",
|
||||||
"error": "साझाकरण बनाने में विफल"
|
"error": "साझाकरण बनाने में विफल",
|
||||||
|
"namePlaceholder": "अपने साझाकरण के लिए एक नाम दर्ज करें"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"loadError": "डैशबोर्ड डेटा लोड करने में त्रुटि",
|
"loadError": "डैशबोर्ड डेटा लोड करने में त्रुटि",
|
||||||
@@ -313,7 +314,8 @@
|
|||||||
"title": "पासवर्ड भूल गए",
|
"title": "पासवर्ड भूल गए",
|
||||||
"description": "अपना ईमेल पता दर्ज करें और हम आपको पासवर्ड रीसेट करने के निर्देश भेजेंगे।",
|
"description": "अपना ईमेल पता दर्ज करें और हम आपको पासवर्ड रीसेट करने के निर्देश भेजेंगे।",
|
||||||
"resetInstructions": "रीसेट निर्देश आपके ईमेल पर भेज दिए गए हैं",
|
"resetInstructions": "रीसेट निर्देश आपके ईमेल पर भेज दिए गए हैं",
|
||||||
"pageTitle": "पासवर्ड भूल गए"
|
"pageTitle": "पासवर्ड भूल गए",
|
||||||
|
"passwordAuthDisabled": "पासवर्ड ऑथेंटिकेशन अक्टिवेटेड है। कृपया अपने एडमिन से संपर्क करें या एक बाहरी ऑथेंटिकेशन प्रोवाइडर का उपयोग करें।"
|
||||||
},
|
},
|
||||||
"generateShareLink": {
|
"generateShareLink": {
|
||||||
"generateTitle": "साझाकरण लिंक उत्पन्न करें",
|
"generateTitle": "साझाकरण लिंक उत्पन्न करें",
|
||||||
@@ -327,7 +329,12 @@
|
|||||||
"copyButton": "लिंक कॉपी करें",
|
"copyButton": "लिंक कॉपी करें",
|
||||||
"success": "लिंक सफलतापूर्वक उत्पन्न हुआ",
|
"success": "लिंक सफलतापूर्वक उत्पन्न हुआ",
|
||||||
"error": "लिंक उत्पन्न करने में विफल",
|
"error": "लिंक उत्पन्न करने में विफल",
|
||||||
"copied": "लिंक क्लिपबोर्ड में कॉपी किया गया"
|
"copied": "लिंक क्लिपबोर्ड में कॉपी किया गया",
|
||||||
|
"readyDescription": "आपका साझाकरण लिंक तैयार है। आप डायरेक्ट रूप से QR कोड स्कैन कर सकते हैं, इसे बाद में उपयोग के लिए डाउनलोड कर सकते हैं, या नीचे लिंक कॉपी कर सकते हैं।",
|
||||||
|
"tabs": {
|
||||||
|
"link": "लिंक",
|
||||||
|
"qrcode": "QR कोड"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"description": "WeTransfer का ओपन-सोर्स विकल्प। फाइलें सुरक्षित रूप से साझा करें, बिना ट्रैकिंग या सीमाओं के।",
|
"description": "WeTransfer का ओपन-सोर्स विकल्प। फाइलें सुरक्षित रूप से साझा करें, बिना ट्रैकिंग या सीमाओं के।",
|
||||||
@@ -355,6 +362,12 @@
|
|||||||
"stats": "{libraryCount} लाइब्रेरी से {iconCount} आइकन",
|
"stats": "{libraryCount} लाइब्रेरी से {iconCount} आइकन",
|
||||||
"categoryBadge": "{category} ({count} आइकन)"
|
"categoryBadge": "{category} ({count} आइकन)"
|
||||||
},
|
},
|
||||||
|
"imageEdit": {
|
||||||
|
"title": "छवि संपादित करें",
|
||||||
|
"rotate": "घुमाएं",
|
||||||
|
"zoom": "ज़ूम",
|
||||||
|
"cropInstructions": "छवि को पुनः स्थानांतरित करने के लिए खींचें, कोणों को समायोजित करने के लिए आकार बदलें"
|
||||||
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"welcome": "स्वागत है में",
|
"welcome": "स्वागत है में",
|
||||||
"signInToContinue": "जारी रखने के लिए साइन इन करें",
|
"signInToContinue": "जारी रखने के लिए साइन इन करें",
|
||||||
@@ -442,6 +455,11 @@
|
|||||||
},
|
},
|
||||||
"pageTitle": "प्रोफ़ाइल"
|
"pageTitle": "प्रोफ़ाइल"
|
||||||
},
|
},
|
||||||
|
"qrCodeModal": {
|
||||||
|
"title": "QR कोड साझा करें",
|
||||||
|
"description": "इस QR कोड को स्कैन करके लिंक तक पहुंच सकते हैं।",
|
||||||
|
"download": "QR कोड डाउनलोड करें"
|
||||||
|
},
|
||||||
"quickAccess": {
|
"quickAccess": {
|
||||||
"files": {
|
"files": {
|
||||||
"title": "मेरी फाइलें",
|
"title": "मेरी फाइलें",
|
||||||
@@ -613,7 +631,8 @@
|
|||||||
"createLink": "लिंक बनाएं",
|
"createLink": "लिंक बनाएं",
|
||||||
"delete": "हटाएं",
|
"delete": "हटाएं",
|
||||||
"copyLinkTitle": "लिंक कॉपी करें",
|
"copyLinkTitle": "लिंक कॉपी करें",
|
||||||
"createLinkCTA": "प्राप्ति लिंक बनाएं"
|
"createLinkCTA": "प्राप्ति लिंक बनाएं",
|
||||||
|
"viewQrCode": "QR कोड देखें"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"active": "सक्रिय",
|
"active": "सक्रिय",
|
||||||
@@ -629,7 +648,8 @@
|
|||||||
"viewDetails": "विवरण देखें",
|
"viewDetails": "विवरण देखें",
|
||||||
"edit": "संपादित करें",
|
"edit": "संपादित करें",
|
||||||
"delete": "हटाएं",
|
"delete": "हटाएं",
|
||||||
"viewFiles": "प्राप्त फ़ाइलें"
|
"viewFiles": "प्राप्त फ़ाइलें",
|
||||||
|
"viewQrCode": "QR कोड देखें"
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "कोई प्राप्ति लिंक नहीं बनाया गया",
|
"title": "कोई प्राप्ति लिंक नहीं बनाया गया",
|
||||||
@@ -1115,6 +1135,10 @@
|
|||||||
"smtpTrustSelfSigned": {
|
"smtpTrustSelfSigned": {
|
||||||
"title": "स्व-हस्ताक्षरित प्रमाणपत्रों पर विश्वास करें",
|
"title": "स्व-हस्ताक्षरित प्रमाणपत्रों पर विश्वास करें",
|
||||||
"description": "स्व-हस्ताक्षरित SSL/TLS प्रमाणपत्रों पर विश्वास करने के लिए इसे सक्षम करें (विकास वातावरण के लिए उपयोगी)"
|
"description": "स्व-हस्ताक्षरित SSL/TLS प्रमाणपत्रों पर विश्वास करने के लिए इसे सक्षम करें (विकास वातावरण के लिए उपयोगी)"
|
||||||
|
},
|
||||||
|
"passwordAuthEnabled": {
|
||||||
|
"title": "पासवर्ड प्रमाणीकरण",
|
||||||
|
"description": "पासवर्ड आधारित प्रमाणीकरण सक्षम या अक्षम करें"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
@@ -1124,7 +1148,8 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadFailed": "सेटिंग्स लोड करने में विफल",
|
"loadFailed": "सेटिंग्स लोड करने में विफल",
|
||||||
"updateFailed": "सेटिंग्स अपडेट करने में विफल"
|
"updateFailed": "सेटिंग्स अपडेट करने में विफल",
|
||||||
|
"passwordAuthRequiresProvider": "कम से कम एक सक्रिय प्रमाणीकरण प्रदाता के बिना पासवर्ड प्रमाणीकरण अक्षम नहीं किया जा सकता"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"noChanges": "सहेजने के लिए कोई परिवर्तन नहीं",
|
"noChanges": "सहेजने के लिए कोई परिवर्तन नहीं",
|
||||||
@@ -1230,7 +1255,10 @@
|
|||||||
"invalidDate": "अमान्य तिथि",
|
"invalidDate": "अमान्य तिथि",
|
||||||
"loadError": "साझाकरण विवरण लोड करने में विफल",
|
"loadError": "साझाकरण विवरण लोड करने में विफल",
|
||||||
"editSecurity": "सुरक्षा संपादित करें",
|
"editSecurity": "सुरक्षा संपादित करें",
|
||||||
"editExpiration": "समाप्ति संपादित करें"
|
"editExpiration": "समाप्ति संपादित करें",
|
||||||
|
"clickToEnlargeQrCode": "QR कोड को बड़ा करने के लिए क्लिक करें",
|
||||||
|
"downloadQrCode": "QR कोड डाउनलोड करें",
|
||||||
|
"qrCode": "QR कोड"
|
||||||
},
|
},
|
||||||
"shareExpiration": {
|
"shareExpiration": {
|
||||||
"neverExpires": "कभी समाप्त नहीं होता",
|
"neverExpires": "कभी समाप्त नहीं होता",
|
||||||
@@ -1417,7 +1445,8 @@
|
|||||||
"copyLink": "लिंक कॉपी करें",
|
"copyLink": "लिंक कॉपी करें",
|
||||||
"notifyRecipients": "प्राप्तकर्ताओं को सूचित करें",
|
"notifyRecipients": "प्राप्तकर्ताओं को सूचित करें",
|
||||||
"delete": "हटाएं",
|
"delete": "हटाएं",
|
||||||
"downloadShareFiles": "सभी फ़ाइलें डाउनलोड करें"
|
"downloadShareFiles": "सभी फ़ाइलें डाउनलोड करें",
|
||||||
|
"viewQrCode": "QR कोड देखें"
|
||||||
},
|
},
|
||||||
"bulkActions": {
|
"bulkActions": {
|
||||||
"delete": "हटाएं",
|
"delete": "हटाएं",
|
||||||
@@ -1634,7 +1663,8 @@
|
|||||||
"title": "अपलोड करने के लिए फ़ाइलें छोड़ें",
|
"title": "अपलोड करने के लिए फ़ाइलें छोड़ें",
|
||||||
"description": "अपनी फ़ाइलें अपलोड करने के लिए छोड़ें"
|
"description": "अपनी फ़ाइलें अपलोड करने के लिए छोड़ें"
|
||||||
},
|
},
|
||||||
"pasteSuccess": "{count, plural, =1 {छवि सफलतापूर्वक चिपकाई और अपलोड की गई} other {# छवियाँ सफलतापूर्वक चिपकाई और अपलोड की गईं}}"
|
"pasteSuccess": "{count, plural, =1 {छवि सफलतापूर्वक चिपकाई और अपलोड की गई} other {# छवियाँ सफलतापूर्वक चिपकाई और अपलोड की गईं}}",
|
||||||
|
"filesQueued": "{count, plural, one {# फ़ाइल अपलोड के लिए इंतजार में है} other {# फ़ाइलें अपलोड के लिए इंतजार में हैं}}"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"modes": {
|
"modes": {
|
||||||
@@ -1719,11 +1749,5 @@
|
|||||||
"passwordRequired": "पासवर्ड आवश्यक है",
|
"passwordRequired": "पासवर्ड आवश्यक है",
|
||||||
"nameRequired": "नाम आवश्यक है",
|
"nameRequired": "नाम आवश्यक है",
|
||||||
"required": "यह फ़ील्ड आवश्यक है"
|
"required": "यह फ़ील्ड आवश्यक है"
|
||||||
},
|
|
||||||
"imageEdit": {
|
|
||||||
"title": "छवि संपादित करें",
|
|
||||||
"rotate": "घुमाएं",
|
|
||||||
"zoom": "ज़ूम",
|
|
||||||
"cropInstructions": "छवि को पुनः स्थानांतरित करने के लिए खींचें, कोणों को समायोजित करने के लिए आकार बदलें"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,8 @@
|
|||||||
"passwordLabel": "Password",
|
"passwordLabel": "Password",
|
||||||
"create": "Crea Condivisione",
|
"create": "Crea Condivisione",
|
||||||
"success": "Condivisione creata con successo",
|
"success": "Condivisione creata con successo",
|
||||||
"error": "Errore nella creazione della condivisione"
|
"error": "Errore nella creazione della condivisione",
|
||||||
|
"namePlaceholder": "Inserisci un nome per la tua condivisione"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"loadError": "Errore durante il caricamento dei dati del pannello di controllo",
|
"loadError": "Errore durante il caricamento dei dati del pannello di controllo",
|
||||||
@@ -313,7 +314,8 @@
|
|||||||
"title": "Parola d'accesso Dimenticata",
|
"title": "Parola d'accesso Dimenticata",
|
||||||
"description": "Inserisci il tuo indirizzo email e ti invieremo le istruzioni per reimpostare la parola d'accesso.",
|
"description": "Inserisci il tuo indirizzo email e ti invieremo le istruzioni per reimpostare la parola d'accesso.",
|
||||||
"resetInstructions": "Istruzioni di reimpostazione inviate alla tua email",
|
"resetInstructions": "Istruzioni di reimpostazione inviate alla tua email",
|
||||||
"pageTitle": "Parola d'accesso Dimenticata"
|
"pageTitle": "Parola d'accesso Dimenticata",
|
||||||
|
"passwordAuthDisabled": "L'autenticazione tramite password è disabilitata. Contatta il tuo amministratore o utilizza un provider di autenticazione esterno."
|
||||||
},
|
},
|
||||||
"generateShareLink": {
|
"generateShareLink": {
|
||||||
"generateTitle": "Genera link di condivisione",
|
"generateTitle": "Genera link di condivisione",
|
||||||
@@ -327,7 +329,12 @@
|
|||||||
"copyButton": "Copia link",
|
"copyButton": "Copia link",
|
||||||
"success": "Link generato con successo",
|
"success": "Link generato con successo",
|
||||||
"error": "Errore nella generazione del link",
|
"error": "Errore nella generazione del link",
|
||||||
"copied": "Link copiato negli appunti"
|
"copied": "Link copiato negli appunti",
|
||||||
|
"readyDescription": "Il tuo link di condivisione è pronto. Puoi scansionare il codice QR direttamente, scaricarlo per un uso successivo, o copiare il link qui sotto.",
|
||||||
|
"tabs": {
|
||||||
|
"link": "Link",
|
||||||
|
"qrcode": "QR Code"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"description": "L'alternativa open-source a WeTransfer. Condividi file in sicurezza, senza tracciamento o limitazioni.",
|
"description": "L'alternativa open-source a WeTransfer. Condividi file in sicurezza, senza tracciamento o limitazioni.",
|
||||||
@@ -355,6 +362,12 @@
|
|||||||
"stats": "{iconCount} icone da {libraryCount} librerie",
|
"stats": "{iconCount} icone da {libraryCount} librerie",
|
||||||
"categoryBadge": "{category} ({count} icone)"
|
"categoryBadge": "{category} ({count} icone)"
|
||||||
},
|
},
|
||||||
|
"imageEdit": {
|
||||||
|
"title": "Modifica Immagine",
|
||||||
|
"rotate": "Ruota",
|
||||||
|
"zoom": "Zoom",
|
||||||
|
"cropInstructions": "Trascina per riposizionare, ridimensiona gli angoli per adattare l'area di ritaglio"
|
||||||
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"welcome": "Benvenuto in",
|
"welcome": "Benvenuto in",
|
||||||
"signInToContinue": "Accedi per continuare",
|
"signInToContinue": "Accedi per continuare",
|
||||||
@@ -442,6 +455,11 @@
|
|||||||
},
|
},
|
||||||
"pageTitle": "Profilo"
|
"pageTitle": "Profilo"
|
||||||
},
|
},
|
||||||
|
"qrCodeModal": {
|
||||||
|
"title": "Condividi QR Code",
|
||||||
|
"description": "Scansiona questo codice QR per accedere al link.",
|
||||||
|
"download": "Scarica QR Code"
|
||||||
|
},
|
||||||
"quickAccess": {
|
"quickAccess": {
|
||||||
"files": {
|
"files": {
|
||||||
"title": "I Miei File",
|
"title": "I Miei File",
|
||||||
@@ -613,7 +631,8 @@
|
|||||||
"createLink": "Crea Link",
|
"createLink": "Crea Link",
|
||||||
"delete": "Elimina",
|
"delete": "Elimina",
|
||||||
"copyLinkTitle": "Copia link",
|
"copyLinkTitle": "Copia link",
|
||||||
"createLinkCTA": "Crea Link di Ricezione"
|
"createLinkCTA": "Crea Link di Ricezione",
|
||||||
|
"viewQrCode": "Visualizza QR Code"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"active": "Attivo",
|
"active": "Attivo",
|
||||||
@@ -629,7 +648,8 @@
|
|||||||
"viewDetails": "Vedi Dettagli",
|
"viewDetails": "Vedi Dettagli",
|
||||||
"edit": "Modifica",
|
"edit": "Modifica",
|
||||||
"delete": "Elimina",
|
"delete": "Elimina",
|
||||||
"viewFiles": "File Ricevuti"
|
"viewFiles": "File Ricevuti",
|
||||||
|
"viewQrCode": "Visualizza QR Code"
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "Nessun link di ricezione creato",
|
"title": "Nessun link di ricezione creato",
|
||||||
@@ -1115,6 +1135,10 @@
|
|||||||
"smtpTrustSelfSigned": {
|
"smtpTrustSelfSigned": {
|
||||||
"title": "Accetta Certificati Auto-Firmati",
|
"title": "Accetta Certificati Auto-Firmati",
|
||||||
"description": "Abilita questa opzione per accettare certificati SSL/TLS auto-firmati (utile per ambienti di sviluppo)"
|
"description": "Abilita questa opzione per accettare certificati SSL/TLS auto-firmati (utile per ambienti di sviluppo)"
|
||||||
|
},
|
||||||
|
"passwordAuthEnabled": {
|
||||||
|
"title": "Autenticazione Password",
|
||||||
|
"description": "Abilita o disabilita l'autenticazione basata su password"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
@@ -1124,7 +1148,8 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadFailed": "Errore durante il caricamento delle impostazioni",
|
"loadFailed": "Errore durante il caricamento delle impostazioni",
|
||||||
"updateFailed": "Errore durante l'aggiornamento delle impostazioni"
|
"updateFailed": "Errore durante l'aggiornamento delle impostazioni",
|
||||||
|
"passwordAuthRequiresProvider": "Impossibile disabilitare l'autenticazione password senza avere almeno un provider di autenticazione attivo"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"noChanges": "Nessuna modifica da salvare",
|
"noChanges": "Nessuna modifica da salvare",
|
||||||
@@ -1230,7 +1255,10 @@
|
|||||||
"invalidDate": "Data non valida",
|
"invalidDate": "Data non valida",
|
||||||
"loadError": "Errore nel caricamento dei dettagli della condivisione",
|
"loadError": "Errore nel caricamento dei dettagli della condivisione",
|
||||||
"editSecurity": "Modifica Sicurezza",
|
"editSecurity": "Modifica Sicurezza",
|
||||||
"editExpiration": "Modifica Scadenza"
|
"editExpiration": "Modifica Scadenza",
|
||||||
|
"clickToEnlargeQrCode": "Clicca per ingrandire il QR Code",
|
||||||
|
"downloadQrCode": "Scarica QR Code",
|
||||||
|
"qrCode": "QR Code"
|
||||||
},
|
},
|
||||||
"shareExpiration": {
|
"shareExpiration": {
|
||||||
"neverExpires": "Non Scade Mai",
|
"neverExpires": "Non Scade Mai",
|
||||||
@@ -1417,7 +1445,8 @@
|
|||||||
"copyLink": "Copia Link",
|
"copyLink": "Copia Link",
|
||||||
"notifyRecipients": "Notifica Destinatari",
|
"notifyRecipients": "Notifica Destinatari",
|
||||||
"delete": "Elimina",
|
"delete": "Elimina",
|
||||||
"downloadShareFiles": "Scarica tutti i file"
|
"downloadShareFiles": "Scarica tutti i file",
|
||||||
|
"viewQrCode": "Visualizza QR Code"
|
||||||
},
|
},
|
||||||
"bulkActions": {
|
"bulkActions": {
|
||||||
"delete": "Elimina",
|
"delete": "Elimina",
|
||||||
@@ -1634,7 +1663,8 @@
|
|||||||
"title": "Rilascia i file per caricarli",
|
"title": "Rilascia i file per caricarli",
|
||||||
"description": "Rilascia per caricare i tuoi file"
|
"description": "Rilascia per caricare i tuoi file"
|
||||||
},
|
},
|
||||||
"pasteSuccess": "{count, plural, =1 {Immagine incollata e caricata con successo} other {# immagini incollate e caricate con successo}}"
|
"pasteSuccess": "{count, plural, =1 {Immagine incollata e caricata con successo} other {# immagini incollate e caricate con successo}}",
|
||||||
|
"filesQueued": "{count, plural, one {# file in coda per il caricamento} other {# files in coda per il caricamento}}"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"modes": {
|
"modes": {
|
||||||
@@ -1719,11 +1749,5 @@
|
|||||||
"passwordMinLength": "La password deve contenere almeno 6 caratteri",
|
"passwordMinLength": "La password deve contenere almeno 6 caratteri",
|
||||||
"nameRequired": "Il nome è obbligatorio",
|
"nameRequired": "Il nome è obbligatorio",
|
||||||
"required": "Questo campo è obbligatorio"
|
"required": "Questo campo è obbligatorio"
|
||||||
},
|
|
||||||
"imageEdit": {
|
|
||||||
"title": "Modifica Immagine",
|
|
||||||
"rotate": "Ruota",
|
|
||||||
"zoom": "Zoom",
|
|
||||||
"cropInstructions": "Trascina per riposizionare, ridimensiona gli angoli per adattare l'area di ritaglio"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,8 @@
|
|||||||
"passwordLabel": "パスワード",
|
"passwordLabel": "パスワード",
|
||||||
"create": "共有を作成",
|
"create": "共有を作成",
|
||||||
"success": "共有が正常に作成されました",
|
"success": "共有が正常に作成されました",
|
||||||
"error": "共有の作成に失敗しました"
|
"error": "共有の作成に失敗しました",
|
||||||
|
"namePlaceholder": "共有の名前を入力してください"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"loadError": "ダッシュボードデータの読み込みに失敗しました",
|
"loadError": "ダッシュボードデータの読み込みに失敗しました",
|
||||||
@@ -313,7 +314,8 @@
|
|||||||
"title": "パスワードをお忘れですか?",
|
"title": "パスワードをお忘れですか?",
|
||||||
"description": "メールアドレスを入力すると、パスワードリセットの指示を送信します。",
|
"description": "メールアドレスを入力すると、パスワードリセットの指示を送信します。",
|
||||||
"resetInstructions": "パスワードリセットの指示がメールに送信されました",
|
"resetInstructions": "パスワードリセットの指示がメールに送信されました",
|
||||||
"pageTitle": "パスワードをお忘れですか?"
|
"pageTitle": "パスワードをお忘れですか?",
|
||||||
|
"passwordAuthDisabled": "パスワード認証が無効になっています。管理者に連絡するか、外部認証プロバイダーを使用してください。"
|
||||||
},
|
},
|
||||||
"generateShareLink": {
|
"generateShareLink": {
|
||||||
"generateTitle": "共有リンクを生成",
|
"generateTitle": "共有リンクを生成",
|
||||||
@@ -327,7 +329,12 @@
|
|||||||
"copyButton": "リンクをコピー",
|
"copyButton": "リンクをコピー",
|
||||||
"success": "リンクが正常に生成されました",
|
"success": "リンクが正常に生成されました",
|
||||||
"error": "リンクの生成に失敗しました",
|
"error": "リンクの生成に失敗しました",
|
||||||
"copied": "リンクがクリップボードにコピーされました"
|
"copied": "リンクがクリップボードにコピーされました",
|
||||||
|
"readyDescription": "共有リンクが準備できました。QRコードを直接スキャンして、後で使用するためにダウンロードするか、リンクをコピーしてください。",
|
||||||
|
"tabs": {
|
||||||
|
"link": "リンク",
|
||||||
|
"qrcode": "QRコード"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"description": "WeTransferのオープンソース代替です。トラッキングや制限なしに安全にファイルを共有します。",
|
"description": "WeTransferのオープンソース代替です。トラッキングや制限なしに安全にファイルを共有します。",
|
||||||
@@ -355,6 +362,12 @@
|
|||||||
"stats": "{libraryCount}ライブラリから{iconCount}個のアイコン",
|
"stats": "{libraryCount}ライブラリから{iconCount}個のアイコン",
|
||||||
"categoryBadge": "{category}({count}個のアイコン)"
|
"categoryBadge": "{category}({count}個のアイコン)"
|
||||||
},
|
},
|
||||||
|
"imageEdit": {
|
||||||
|
"title": "画像を編集",
|
||||||
|
"rotate": "回転",
|
||||||
|
"zoom": "ズーム",
|
||||||
|
"cropInstructions": "位置を変更するにはドラッグし、カット領域を調整するには角をリサイズしてください"
|
||||||
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"welcome": "ようこそへ",
|
"welcome": "ようこそへ",
|
||||||
"signInToContinue": "続行するにはサインインしてください",
|
"signInToContinue": "続行するにはサインインしてください",
|
||||||
@@ -442,6 +455,11 @@
|
|||||||
},
|
},
|
||||||
"pageTitle": "プロフィール"
|
"pageTitle": "プロフィール"
|
||||||
},
|
},
|
||||||
|
"qrCodeModal": {
|
||||||
|
"title": "QRコードを共有",
|
||||||
|
"description": "このQRコードをスキャンしてリンクにアクセスしてください。",
|
||||||
|
"download": "QRコードをダウンロード"
|
||||||
|
},
|
||||||
"quickAccess": {
|
"quickAccess": {
|
||||||
"files": {
|
"files": {
|
||||||
"title": "マイファイル",
|
"title": "マイファイル",
|
||||||
@@ -613,7 +631,8 @@
|
|||||||
"createLink": "リンクを作成",
|
"createLink": "リンクを作成",
|
||||||
"delete": "削除",
|
"delete": "削除",
|
||||||
"copyLinkTitle": "リンクをコピー",
|
"copyLinkTitle": "リンクをコピー",
|
||||||
"createLinkCTA": "受信リンクを作成"
|
"createLinkCTA": "受信リンクを作成",
|
||||||
|
"viewQrCode": "QRコードを表示"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"active": "有効",
|
"active": "有効",
|
||||||
@@ -629,7 +648,8 @@
|
|||||||
"viewDetails": "詳細を表示",
|
"viewDetails": "詳細を表示",
|
||||||
"edit": "編集",
|
"edit": "編集",
|
||||||
"delete": "削除",
|
"delete": "削除",
|
||||||
"viewFiles": "受信済みファイル"
|
"viewFiles": "受信済みファイル",
|
||||||
|
"viewQrCode": "QRコードを表示"
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "受信リンクが作成されていません",
|
"title": "受信リンクが作成されていません",
|
||||||
@@ -1115,6 +1135,10 @@
|
|||||||
"smtpTrustSelfSigned": {
|
"smtpTrustSelfSigned": {
|
||||||
"title": "自己署名証明書を信頼",
|
"title": "自己署名証明書を信頼",
|
||||||
"description": "自己署名SSL/TLS証明書を信頼するように設定します(開発環境で便利)"
|
"description": "自己署名SSL/TLS証明書を信頼するように設定します(開発環境で便利)"
|
||||||
|
},
|
||||||
|
"passwordAuthEnabled": {
|
||||||
|
"title": "パスワード認証",
|
||||||
|
"description": "パスワード認証を有効または無効にする"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
@@ -1124,7 +1148,8 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadFailed": "設定の読み込みに失敗しました",
|
"loadFailed": "設定の読み込みに失敗しました",
|
||||||
"updateFailed": "設定の更新に失敗しました"
|
"updateFailed": "設定の更新に失敗しました",
|
||||||
|
"passwordAuthRequiresProvider": "少なくとも1つのアクティブな認証プロバイダーがない場合、パスワード認証を無効にできません"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"noChanges": "保存する変更はありません",
|
"noChanges": "保存する変更はありません",
|
||||||
@@ -1230,7 +1255,10 @@
|
|||||||
"invalidDate": "無効な日付",
|
"invalidDate": "無効な日付",
|
||||||
"loadError": "共有詳細の読み込みに失敗しました",
|
"loadError": "共有詳細の読み込みに失敗しました",
|
||||||
"editSecurity": "セキュリティを編集",
|
"editSecurity": "セキュリティを編集",
|
||||||
"editExpiration": "期限を編集"
|
"editExpiration": "期限を編集",
|
||||||
|
"clickToEnlargeQrCode": "QRコードを拡大",
|
||||||
|
"downloadQrCode": "QRコードをダウンロード",
|
||||||
|
"qrCode": "QRコード"
|
||||||
},
|
},
|
||||||
"shareExpiration": {
|
"shareExpiration": {
|
||||||
"neverExpires": "期限なし",
|
"neverExpires": "期限なし",
|
||||||
@@ -1417,7 +1445,8 @@
|
|||||||
"copyLink": "リンクコピー",
|
"copyLink": "リンクコピー",
|
||||||
"notifyRecipients": "受信者に通知",
|
"notifyRecipients": "受信者に通知",
|
||||||
"delete": "削除",
|
"delete": "削除",
|
||||||
"downloadShareFiles": "すべてのファイルをダウンロードします"
|
"downloadShareFiles": "すべてのファイルをダウンロードします",
|
||||||
|
"viewQrCode": "QRコードを表示"
|
||||||
},
|
},
|
||||||
"bulkActions": {
|
"bulkActions": {
|
||||||
"delete": "削除",
|
"delete": "削除",
|
||||||
@@ -1634,7 +1663,8 @@
|
|||||||
"title": "アップロードするファイルをドロップ",
|
"title": "アップロードするファイルをドロップ",
|
||||||
"description": "ファイルをアップロードするにはリリースしてください"
|
"description": "ファイルをアップロードするにはリリースしてください"
|
||||||
},
|
},
|
||||||
"pasteSuccess": "{count, plural, =1 {画像が貼り付けられ、正常にアップロードされました} other {#個の画像が貼り付けられ、正常にアップロードされました}}"
|
"pasteSuccess": "{count, plural, =1 {画像が貼り付けられ、正常にアップロードされました} other {#個の画像が貼り付けられ、正常にアップロードされました}}",
|
||||||
|
"filesQueued": "{count, plural, one {# ファイルがアップロード待ち} other {# ファイルがアップロード待ち}}"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"modes": {
|
"modes": {
|
||||||
@@ -1719,11 +1749,5 @@
|
|||||||
"passwordRequired": "パスワードは必須です",
|
"passwordRequired": "パスワードは必須です",
|
||||||
"nameRequired": "名前は必須です",
|
"nameRequired": "名前は必須です",
|
||||||
"required": "このフィールドは必須です"
|
"required": "このフィールドは必須です"
|
||||||
},
|
|
||||||
"imageEdit": {
|
|
||||||
"title": "画像を編集",
|
|
||||||
"rotate": "回転",
|
|
||||||
"zoom": "ズーム",
|
|
||||||
"cropInstructions": "位置を変更するにはドラッグし、カット領域を調整するには角をリサイズしてください"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,8 @@
|
|||||||
"passwordLabel": "비밀번호",
|
"passwordLabel": "비밀번호",
|
||||||
"create": "공유 생성",
|
"create": "공유 생성",
|
||||||
"success": "공유가 성공적으로 생성되었습니다",
|
"success": "공유가 성공적으로 생성되었습니다",
|
||||||
"error": "공유 생성에 실패했습니다"
|
"error": "공유 생성에 실패했습니다",
|
||||||
|
"namePlaceholder": "공유 이름을 입력하세요"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"loadError": "대시보드 데이터를 불러오는데 실패했습니다",
|
"loadError": "대시보드 데이터를 불러오는데 실패했습니다",
|
||||||
@@ -313,7 +314,8 @@
|
|||||||
"title": "비밀번호를 잊으셨나요?",
|
"title": "비밀번호를 잊으셨나요?",
|
||||||
"description": "이메일 주소를 입력하면 비밀번호 재설정 지침을 보내드립니다.",
|
"description": "이메일 주소를 입력하면 비밀번호 재설정 지침을 보내드립니다.",
|
||||||
"resetInstructions": "비밀번호 재설정 지침이 이메일로 전송되었습니다",
|
"resetInstructions": "비밀번호 재설정 지침이 이메일로 전송되었습니다",
|
||||||
"pageTitle": "비밀번호를 잊으셨나요?"
|
"pageTitle": "비밀번호를 잊으셨나요?",
|
||||||
|
"passwordAuthDisabled": "비밀번호 인증이 비활성화되어 있습니다. 관리자에게 문의하거나 외부 인증 공급자를 사용하세요."
|
||||||
},
|
},
|
||||||
"generateShareLink": {
|
"generateShareLink": {
|
||||||
"generateTitle": "공유 링크 생성",
|
"generateTitle": "공유 링크 생성",
|
||||||
@@ -327,7 +329,12 @@
|
|||||||
"copyButton": "링크 복사",
|
"copyButton": "링크 복사",
|
||||||
"success": "링크가 성공적으로 생성되었습니다",
|
"success": "링크가 성공적으로 생성되었습니다",
|
||||||
"error": "링크 생성에 실패했습니다",
|
"error": "링크 생성에 실패했습니다",
|
||||||
"copied": "링크가 클립보드에 복사되었습니다"
|
"copied": "링크가 클립보드에 복사되었습니다",
|
||||||
|
"readyDescription": "공유 링크가 준비되었습니다. QR 코드를 직접 스캔하여 링크에 접근하거나, 나중에 사용하기 위해 다운로드하거나, 아래 링크를 복사할 수 있습니다.",
|
||||||
|
"tabs": {
|
||||||
|
"link": "링크",
|
||||||
|
"qrcode": "QR 코드"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"description": "WeTransfer의 오픈소스 대안입니다. 추적이나 제한 없이 파일을 안전하게 공유하세요.",
|
"description": "WeTransfer의 오픈소스 대안입니다. 추적이나 제한 없이 파일을 안전하게 공유하세요.",
|
||||||
@@ -355,6 +362,12 @@
|
|||||||
"stats": "{libraryCount}개의 라이브러리에서 {iconCount}개의 아이콘",
|
"stats": "{libraryCount}개의 라이브러리에서 {iconCount}개의 아이콘",
|
||||||
"categoryBadge": "{category} ({count}개의 아이콘)"
|
"categoryBadge": "{category} ({count}개의 아이콘)"
|
||||||
},
|
},
|
||||||
|
"imageEdit": {
|
||||||
|
"title": "이미지 편집",
|
||||||
|
"rotate": "회전",
|
||||||
|
"zoom": "확대/축소",
|
||||||
|
"cropInstructions": "위치를 변경하려면 드래그하고, 자르기 영역을 조정하려면 모서리를 확대/축소하세요"
|
||||||
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"welcome": "에 오신 것을 환영합니다",
|
"welcome": "에 오신 것을 환영합니다",
|
||||||
"signInToContinue": "계속하려면 로그인하세요",
|
"signInToContinue": "계속하려면 로그인하세요",
|
||||||
@@ -442,6 +455,11 @@
|
|||||||
},
|
},
|
||||||
"pageTitle": "프로필"
|
"pageTitle": "프로필"
|
||||||
},
|
},
|
||||||
|
"qrCodeModal": {
|
||||||
|
"title": "QR 코드 공유",
|
||||||
|
"description": "이 QR 코드를 스캔하여 링크에 접근할 수 있습니다.",
|
||||||
|
"download": "QR 코드 다운로드"
|
||||||
|
},
|
||||||
"quickAccess": {
|
"quickAccess": {
|
||||||
"files": {
|
"files": {
|
||||||
"title": "내 파일",
|
"title": "내 파일",
|
||||||
@@ -613,7 +631,8 @@
|
|||||||
"createLink": "링크 생성",
|
"createLink": "링크 생성",
|
||||||
"delete": "삭제",
|
"delete": "삭제",
|
||||||
"copyLinkTitle": "링크 복사",
|
"copyLinkTitle": "링크 복사",
|
||||||
"createLinkCTA": "수신 링크 생성"
|
"createLinkCTA": "수신 링크 생성",
|
||||||
|
"viewQrCode": "QR 코드 보기"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"active": "활성",
|
"active": "활성",
|
||||||
@@ -629,7 +648,8 @@
|
|||||||
"viewDetails": "상세 보기",
|
"viewDetails": "상세 보기",
|
||||||
"edit": "편집",
|
"edit": "편집",
|
||||||
"delete": "삭제",
|
"delete": "삭제",
|
||||||
"viewFiles": "받은 파일"
|
"viewFiles": "받은 파일",
|
||||||
|
"viewQrCode": "QR 코드 보기"
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "생성된 수신 링크 없음",
|
"title": "생성된 수신 링크 없음",
|
||||||
@@ -1115,6 +1135,10 @@
|
|||||||
"smtpTrustSelfSigned": {
|
"smtpTrustSelfSigned": {
|
||||||
"title": "자체 서명된 인증서 신뢰",
|
"title": "자체 서명된 인증서 신뢰",
|
||||||
"description": "자체 서명된 SSL/TLS 인증서를 신뢰하려면 활성화하세요 (개발 환경에서 유용)"
|
"description": "자체 서명된 SSL/TLS 인증서를 신뢰하려면 활성화하세요 (개발 환경에서 유용)"
|
||||||
|
},
|
||||||
|
"passwordAuthEnabled": {
|
||||||
|
"title": "비밀번호 인증",
|
||||||
|
"description": "비밀번호 기반 인증 활성화 또는 비활성화"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
@@ -1124,7 +1148,8 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadFailed": "설정을 불러오는데 실패했습니다",
|
"loadFailed": "설정을 불러오는데 실패했습니다",
|
||||||
"updateFailed": "설정 업데이트에 실패했습니다"
|
"updateFailed": "설정 업데이트에 실패했습니다",
|
||||||
|
"passwordAuthRequiresProvider": "최소 하나의 활성 인증 제공자가 없으면 비밀번호 인증을 비활성화할 수 없습니다"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"noChanges": "저장할 변경 사항이 없습니다",
|
"noChanges": "저장할 변경 사항이 없습니다",
|
||||||
@@ -1230,7 +1255,10 @@
|
|||||||
"invalidDate": "잘못된 날짜",
|
"invalidDate": "잘못된 날짜",
|
||||||
"loadError": "공유 세부 정보 로드에 실패했습니다",
|
"loadError": "공유 세부 정보 로드에 실패했습니다",
|
||||||
"editSecurity": "보안 편집",
|
"editSecurity": "보안 편집",
|
||||||
"editExpiration": "만료 편집"
|
"editExpiration": "만료 편집",
|
||||||
|
"clickToEnlargeQrCode": "QR 코드 확대",
|
||||||
|
"downloadQrCode": "QR 코드 다운로드",
|
||||||
|
"qrCode": "QR 코드"
|
||||||
},
|
},
|
||||||
"shareExpiration": {
|
"shareExpiration": {
|
||||||
"neverExpires": "만료되지 않음",
|
"neverExpires": "만료되지 않음",
|
||||||
@@ -1417,7 +1445,8 @@
|
|||||||
"copyLink": "링크 복사",
|
"copyLink": "링크 복사",
|
||||||
"notifyRecipients": "받는 사람에게 알림",
|
"notifyRecipients": "받는 사람에게 알림",
|
||||||
"delete": "삭제",
|
"delete": "삭제",
|
||||||
"downloadShareFiles": "모든 파일을 다운로드하십시오"
|
"downloadShareFiles": "모든 파일을 다운로드하십시오",
|
||||||
|
"viewQrCode": "QR 코드 보기"
|
||||||
},
|
},
|
||||||
"bulkActions": {
|
"bulkActions": {
|
||||||
"delete": "삭제",
|
"delete": "삭제",
|
||||||
@@ -1634,7 +1663,8 @@
|
|||||||
"title": "업로드할 파일 드롭",
|
"title": "업로드할 파일 드롭",
|
||||||
"description": "파일을 업로드하려면 놓으세요"
|
"description": "파일을 업로드하려면 놓으세요"
|
||||||
},
|
},
|
||||||
"pasteSuccess": "{count, plural, =1 {이미지가 성공적으로 붙여넣기 및 업로드되었습니다} other {# 개의 이미지가 성공적으로 붙여넣기 및 업로드되었습니다}}"
|
"pasteSuccess": "{count, plural, =1 {이미지가 성공적으로 붙여넣기 및 업로드되었습니다} other {# 개의 이미지가 성공적으로 붙여넣기 및 업로드되었습니다}}",
|
||||||
|
"filesQueued": "{count, plural, one {# 파일이 업로드 대기 중} other {# 파일이 업로드 대기 중}}"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"modes": {
|
"modes": {
|
||||||
@@ -1719,11 +1749,5 @@
|
|||||||
"passwordRequired": "비밀번호는 필수입니다",
|
"passwordRequired": "비밀번호는 필수입니다",
|
||||||
"nameRequired": "이름은 필수입니다",
|
"nameRequired": "이름은 필수입니다",
|
||||||
"required": "이 필드는 필수입니다"
|
"required": "이 필드는 필수입니다"
|
||||||
},
|
|
||||||
"imageEdit": {
|
|
||||||
"title": "이미지 편집",
|
|
||||||
"rotate": "회전",
|
|
||||||
"zoom": "확대/축소",
|
|
||||||
"cropInstructions": "위치를 변경하려면 드래그하고, 자르기 영역을 조정하려면 모서리를 확대/축소하세요"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,8 @@
|
|||||||
"passwordLabel": "Wachtwoord",
|
"passwordLabel": "Wachtwoord",
|
||||||
"create": "Delen Maken",
|
"create": "Delen Maken",
|
||||||
"success": "Delen succesvol aangemaakt",
|
"success": "Delen succesvol aangemaakt",
|
||||||
"error": "Fout bij het aanmaken van delen"
|
"error": "Fout bij het aanmaken van delen",
|
||||||
|
"namePlaceholder": "Voer een naam in voor uw delen"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"loadError": "Fout bij het laden van controlepaneel gegevens",
|
"loadError": "Fout bij het laden van controlepaneel gegevens",
|
||||||
@@ -313,7 +314,8 @@
|
|||||||
"title": "Wachtwoord Vergeten",
|
"title": "Wachtwoord Vergeten",
|
||||||
"description": "Voer je e-mailadres in en we sturen je instructies om je wachtwoord te resetten.",
|
"description": "Voer je e-mailadres in en we sturen je instructies om je wachtwoord te resetten.",
|
||||||
"resetInstructions": "Reset instructies verzonden naar je e-mail",
|
"resetInstructions": "Reset instructies verzonden naar je e-mail",
|
||||||
"pageTitle": "Wachtwoord Vergeten"
|
"pageTitle": "Wachtwoord Vergeten",
|
||||||
|
"passwordAuthDisabled": "Wachtwoordauthenticatie is uitgeschakeld. Neem contact op met uw beheerder of gebruik een externe authenticatieprovider."
|
||||||
},
|
},
|
||||||
"generateShareLink": {
|
"generateShareLink": {
|
||||||
"generateTitle": "Deel-link genereren",
|
"generateTitle": "Deel-link genereren",
|
||||||
@@ -327,7 +329,12 @@
|
|||||||
"copyButton": "Link kopiëren",
|
"copyButton": "Link kopiëren",
|
||||||
"success": "Link succesvol gegenereerd",
|
"success": "Link succesvol gegenereerd",
|
||||||
"error": "Fout bij het genereren van link",
|
"error": "Fout bij het genereren van link",
|
||||||
"copied": "Link gekopieerd naar klembord"
|
"copied": "Link gekopieerd naar klembord",
|
||||||
|
"readyDescription": "Uw deel-link is klaar. U kunt de QR-code direct scannen, downloaden voor later gebruik, of de link hieronder kopiëren.",
|
||||||
|
"tabs": {
|
||||||
|
"link": "Link",
|
||||||
|
"qrcode": "QR Code"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"description": "Het open-source alternatief voor WeTransfer. Deel bestanden veilig, zonder tracking of beperkingen.",
|
"description": "Het open-source alternatief voor WeTransfer. Deel bestanden veilig, zonder tracking of beperkingen.",
|
||||||
@@ -355,6 +362,12 @@
|
|||||||
"stats": "{iconCount} pictogrammen van {libraryCount} bibliotheken",
|
"stats": "{iconCount} pictogrammen van {libraryCount} bibliotheken",
|
||||||
"categoryBadge": "{category} ({count} pictogrammen)"
|
"categoryBadge": "{category} ({count} pictogrammen)"
|
||||||
},
|
},
|
||||||
|
"imageEdit": {
|
||||||
|
"title": "Afbeelding bewerken",
|
||||||
|
"rotate": "Draai",
|
||||||
|
"zoom": "Vergroot",
|
||||||
|
"cropInstructions": "Sleep om te herpositioneren, verander de grootte van de hoeken om de uitsnijdgebied aan te passen"
|
||||||
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"welcome": "Welkom bij",
|
"welcome": "Welkom bij",
|
||||||
"signInToContinue": "Log in om door te gaan",
|
"signInToContinue": "Log in om door te gaan",
|
||||||
@@ -442,6 +455,11 @@
|
|||||||
},
|
},
|
||||||
"pageTitle": "Profiel"
|
"pageTitle": "Profiel"
|
||||||
},
|
},
|
||||||
|
"qrCodeModal": {
|
||||||
|
"title": "QR Code Delen",
|
||||||
|
"description": "Scan deze QR-code om toegang te krijgen tot de link.",
|
||||||
|
"download": "QR Code Downloaden"
|
||||||
|
},
|
||||||
"quickAccess": {
|
"quickAccess": {
|
||||||
"files": {
|
"files": {
|
||||||
"title": "Mijn Bestanden",
|
"title": "Mijn Bestanden",
|
||||||
@@ -613,7 +631,8 @@
|
|||||||
"createLink": "Link Aanmaken",
|
"createLink": "Link Aanmaken",
|
||||||
"delete": "Verwijderen",
|
"delete": "Verwijderen",
|
||||||
"copyLinkTitle": "Link kopiëren",
|
"copyLinkTitle": "Link kopiëren",
|
||||||
"createLinkCTA": "Ontvangstlink Aanmaken"
|
"createLinkCTA": "Ontvangstlink Aanmaken",
|
||||||
|
"viewQrCode": "QR Code Bekijken"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"active": "Actief",
|
"active": "Actief",
|
||||||
@@ -629,7 +648,8 @@
|
|||||||
"viewDetails": "Details Bekijken",
|
"viewDetails": "Details Bekijken",
|
||||||
"edit": "Bewerken",
|
"edit": "Bewerken",
|
||||||
"delete": "Verwijderen",
|
"delete": "Verwijderen",
|
||||||
"viewFiles": "Ontvangen Bestanden"
|
"viewFiles": "Ontvangen Bestanden",
|
||||||
|
"viewQrCode": "QR Code Bekijken"
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "Geen ontvangstlinks aangemaakt",
|
"title": "Geen ontvangstlinks aangemaakt",
|
||||||
@@ -1115,6 +1135,10 @@
|
|||||||
"smtpTrustSelfSigned": {
|
"smtpTrustSelfSigned": {
|
||||||
"title": "Vertrouw Zelf-Ondertekende Certificaten",
|
"title": "Vertrouw Zelf-Ondertekende Certificaten",
|
||||||
"description": "Schakel dit in om zelf-ondertekende SSL/TLS certificaten te vertrouwen (handig voor ontwikkelomgevingen)"
|
"description": "Schakel dit in om zelf-ondertekende SSL/TLS certificaten te vertrouwen (handig voor ontwikkelomgevingen)"
|
||||||
|
},
|
||||||
|
"passwordAuthEnabled": {
|
||||||
|
"title": "Wachtwoord Authenticatie",
|
||||||
|
"description": "Wachtwoord-gebaseerde authenticatie inschakelen of uitschakelen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
@@ -1124,7 +1148,8 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadFailed": "Fout bij het laden van instellingen",
|
"loadFailed": "Fout bij het laden van instellingen",
|
||||||
"updateFailed": "Fout bij het bijwerken van instellingen"
|
"updateFailed": "Fout bij het bijwerken van instellingen",
|
||||||
|
"passwordAuthRequiresProvider": "Wachtwoordauthenticatie kan niet worden uitgeschakeld zonder ten minste één actieve authenticatieprovider"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"noChanges": "Geen wijzigingen om op te slaan",
|
"noChanges": "Geen wijzigingen om op te slaan",
|
||||||
@@ -1230,7 +1255,10 @@
|
|||||||
"invalidDate": "Ongeldige datum",
|
"invalidDate": "Ongeldige datum",
|
||||||
"loadError": "Fout bij laden van delen details",
|
"loadError": "Fout bij laden van delen details",
|
||||||
"editSecurity": "Beveiliging Bewerken",
|
"editSecurity": "Beveiliging Bewerken",
|
||||||
"editExpiration": "Vervaldatum Bewerken"
|
"editExpiration": "Vervaldatum Bewerken",
|
||||||
|
"clickToEnlargeQrCode": "Klik om QR Code te vergroten",
|
||||||
|
"downloadQrCode": "QR Code Downloaden",
|
||||||
|
"qrCode": "QR Code"
|
||||||
},
|
},
|
||||||
"shareExpiration": {
|
"shareExpiration": {
|
||||||
"neverExpires": "Verloopt Nooit",
|
"neverExpires": "Verloopt Nooit",
|
||||||
@@ -1417,7 +1445,8 @@
|
|||||||
"copyLink": "Link Kopiëren",
|
"copyLink": "Link Kopiëren",
|
||||||
"notifyRecipients": "Ontvangers Informeren",
|
"notifyRecipients": "Ontvangers Informeren",
|
||||||
"delete": "Verwijderen",
|
"delete": "Verwijderen",
|
||||||
"downloadShareFiles": "Download alle bestanden"
|
"downloadShareFiles": "Download alle bestanden",
|
||||||
|
"viewQrCode": "QR Code Bekijken"
|
||||||
},
|
},
|
||||||
"bulkActions": {
|
"bulkActions": {
|
||||||
"delete": "Verwijderen",
|
"delete": "Verwijderen",
|
||||||
@@ -1634,7 +1663,8 @@
|
|||||||
"title": "Sleep bestanden om te uploaden",
|
"title": "Sleep bestanden om te uploaden",
|
||||||
"description": "Laat los om je bestanden te uploaden"
|
"description": "Laat los om je bestanden te uploaden"
|
||||||
},
|
},
|
||||||
"pasteSuccess": "{count, plural, =1 {Afbeelding geplakt en succesvol geüpload} other {# afbeeldingen geplakt en succesvol geüpload}}"
|
"pasteSuccess": "{count, plural, =1 {Afbeelding geplakt en succesvol geüpload} other {# afbeeldingen geplakt en succesvol geüpload}}",
|
||||||
|
"filesQueued": "{count, plural, one {# bestand in de wachtrij voor upload} other {# bestanden in de wachtrij voor upload}}"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"modes": {
|
"modes": {
|
||||||
@@ -1719,11 +1749,5 @@
|
|||||||
"passwordMinLength": "Wachtwoord moet minimaal 6 tekens bevatten",
|
"passwordMinLength": "Wachtwoord moet minimaal 6 tekens bevatten",
|
||||||
"nameRequired": "Naam is verplicht",
|
"nameRequired": "Naam is verplicht",
|
||||||
"required": "Dit veld is verplicht"
|
"required": "Dit veld is verplicht"
|
||||||
},
|
|
||||||
"imageEdit": {
|
|
||||||
"title": "Afbeelding bewerken",
|
|
||||||
"rotate": "Draai",
|
|
||||||
"zoom": "Vergroot",
|
|
||||||
"cropInstructions": "Sleep om te herpositioneren, verander de grootte van de hoeken om de uitsnijdgebied aan te passen"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,8 @@
|
|||||||
"passwordLabel": "Hasło",
|
"passwordLabel": "Hasło",
|
||||||
"create": "Utwórz Udostępnienie",
|
"create": "Utwórz Udostępnienie",
|
||||||
"success": "Udostępnienie utworzone pomyślnie",
|
"success": "Udostępnienie utworzone pomyślnie",
|
||||||
"error": "Nie udało się utworzyć udostępnienia"
|
"error": "Nie udało się utworzyć udostępnienia",
|
||||||
|
"namePlaceholder": "Wprowadź nazwę dla swojego udostępnienia"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"loadError": "Nie udało się załadować danych panelu głównego",
|
"loadError": "Nie udało się załadować danych panelu głównego",
|
||||||
@@ -313,7 +314,8 @@
|
|||||||
"title": "Zapomniałeś hasła?",
|
"title": "Zapomniałeś hasła?",
|
||||||
"description": "Wprowadź swój adres e-mail, a wyślemy Ci instrukcje resetowania hasła",
|
"description": "Wprowadź swój adres e-mail, a wyślemy Ci instrukcje resetowania hasła",
|
||||||
"resetInstructions": "Instrukcje resetowania wysłane na Twój adres e-mail",
|
"resetInstructions": "Instrukcje resetowania wysłane na Twój adres e-mail",
|
||||||
"pageTitle": "Zapomniałeś hasła?"
|
"pageTitle": "Zapomniałeś hasła?",
|
||||||
|
"passwordAuthDisabled": "Uwierzytelnianie hasłem jest wyłączone. Skontaktuj się z administratorem lub użyj zewnętrznego dostawcy uwierzytelniania."
|
||||||
},
|
},
|
||||||
"generateShareLink": {
|
"generateShareLink": {
|
||||||
"generateTitle": "Generuj link do udostępniania",
|
"generateTitle": "Generuj link do udostępniania",
|
||||||
@@ -327,7 +329,12 @@
|
|||||||
"copyButton": "Skopiuj link",
|
"copyButton": "Skopiuj link",
|
||||||
"success": "Link wygenerowany pomyślnie",
|
"success": "Link wygenerowany pomyślnie",
|
||||||
"error": "Nie udało się wygenerować linku",
|
"error": "Nie udało się wygenerować linku",
|
||||||
"copied": "Link skopiowany do schowka"
|
"copied": "Link skopiowany do schowka",
|
||||||
|
"readyDescription": "Twój link do udostępniania jest gotowy. Możesz skanować kod QR bezpośrednio, pobrać go do późniejszego użycia lub skopiować link poniżej.",
|
||||||
|
"tabs": {
|
||||||
|
"link": "Link",
|
||||||
|
"qrcode": "QR Code"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"description": "Otwartoźródłowa alternatywa dla WeTransfer. Udostępniaj pliki bezpiecznie, bez śledzenia i ograniczeń.",
|
"description": "Otwartoźródłowa alternatywa dla WeTransfer. Udostępniaj pliki bezpiecznie, bez śledzenia i ograniczeń.",
|
||||||
@@ -355,6 +362,12 @@
|
|||||||
"stats": "{iconCount} ikon z {libraryCount} bibliotek",
|
"stats": "{iconCount} ikon z {libraryCount} bibliotek",
|
||||||
"categoryBadge": "{category} ({count} ikon)"
|
"categoryBadge": "{category} ({count} ikon)"
|
||||||
},
|
},
|
||||||
|
"imageEdit": {
|
||||||
|
"title": "Edytuj obraz",
|
||||||
|
"rotate": "Obróć",
|
||||||
|
"zoom": "Powiększ",
|
||||||
|
"cropInstructions": "Przeciągnij, aby przesunąć, zmień rozmiar rogów, aby dostosować obszar przycięcia"
|
||||||
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"welcome": "Witaj w",
|
"welcome": "Witaj w",
|
||||||
"signInToContinue": "Zaloguj się, aby kontynuować",
|
"signInToContinue": "Zaloguj się, aby kontynuować",
|
||||||
@@ -442,6 +455,11 @@
|
|||||||
},
|
},
|
||||||
"pageTitle": "Profil"
|
"pageTitle": "Profil"
|
||||||
},
|
},
|
||||||
|
"qrCodeModal": {
|
||||||
|
"title": "Udostępnij kod QR",
|
||||||
|
"description": "Skanuj ten kod QR, aby uzyskać dostęp do linku.",
|
||||||
|
"download": "Pobierz kod QR"
|
||||||
|
},
|
||||||
"quickAccess": {
|
"quickAccess": {
|
||||||
"files": {
|
"files": {
|
||||||
"title": "Moje pliki",
|
"title": "Moje pliki",
|
||||||
@@ -613,7 +631,8 @@
|
|||||||
"createLink": "Utwórz link",
|
"createLink": "Utwórz link",
|
||||||
"delete": "Usuń",
|
"delete": "Usuń",
|
||||||
"copyLinkTitle": "Skopiuj link",
|
"copyLinkTitle": "Skopiuj link",
|
||||||
"createLinkCTA": "Utwórz link do odbierania"
|
"createLinkCTA": "Utwórz link do odbierania",
|
||||||
|
"viewQrCode": "Wyświetl kod QR"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"active": "Aktywny",
|
"active": "Aktywny",
|
||||||
@@ -629,7 +648,8 @@
|
|||||||
"viewDetails": "Wyświetl szczegóły",
|
"viewDetails": "Wyświetl szczegóły",
|
||||||
"edit": "Edytuj",
|
"edit": "Edytuj",
|
||||||
"delete": "Usuń",
|
"delete": "Usuń",
|
||||||
"viewFiles": "Odebrane pliki"
|
"viewFiles": "Odebrane pliki",
|
||||||
|
"viewQrCode": "Wyświetl kod QR"
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "Brak utworzonych linków do odbierania",
|
"title": "Brak utworzonych linków do odbierania",
|
||||||
@@ -1115,6 +1135,10 @@
|
|||||||
"smtpTrustSelfSigned": {
|
"smtpTrustSelfSigned": {
|
||||||
"title": "Zaufaj certyfikatom samopodpisanym",
|
"title": "Zaufaj certyfikatom samopodpisanym",
|
||||||
"description": "Włącz tę opcję, aby zaufać samopodpisanym certyfikatom SSL/TLS (przydatne w środowiskach deweloperskich)"
|
"description": "Włącz tę opcję, aby zaufać samopodpisanym certyfikatom SSL/TLS (przydatne w środowiskach deweloperskich)"
|
||||||
|
},
|
||||||
|
"passwordAuthEnabled": {
|
||||||
|
"title": "Uwierzytelnianie hasłem",
|
||||||
|
"description": "Włącz lub wyłącz uwierzytelnianie oparte na haśle"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
@@ -1124,7 +1148,8 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadFailed": "Nie udało się załadować ustawień",
|
"loadFailed": "Nie udało się załadować ustawień",
|
||||||
"updateFailed": "Nie udało się zaktualizować ustawień"
|
"updateFailed": "Nie udało się zaktualizować ustawień",
|
||||||
|
"passwordAuthRequiresProvider": "Uwierzytelnianie oparte na haśle nie może być wyłączone, jeśli nie ma co najmniej jednego aktywnego dostawcy uwierzytelniania"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"noChanges": "Brak zmian do zapisania",
|
"noChanges": "Brak zmian do zapisania",
|
||||||
@@ -1230,7 +1255,10 @@
|
|||||||
"recipients": "Odbiorcy",
|
"recipients": "Odbiorcy",
|
||||||
"notAvailable": "N/A",
|
"notAvailable": "N/A",
|
||||||
"invalidDate": "Nieprawidłowa data",
|
"invalidDate": "Nieprawidłowa data",
|
||||||
"loadError": "Nie udało się załadować szczegółów udostępnienia"
|
"loadError": "Nie udało się załadować szczegółów udostępnienia",
|
||||||
|
"clickToEnlargeQrCode": "Kliknij, aby powiększyć kod QR",
|
||||||
|
"downloadQrCode": "Pobierz kod QR",
|
||||||
|
"qrCode": "Kod QR"
|
||||||
},
|
},
|
||||||
"shareExpiration": {
|
"shareExpiration": {
|
||||||
"title": "Ustawienia wygaśnięcia udostępnienia",
|
"title": "Ustawienia wygaśnięcia udostępnienia",
|
||||||
@@ -1417,7 +1445,8 @@
|
|||||||
"copyLink": "Skopiuj link",
|
"copyLink": "Skopiuj link",
|
||||||
"notifyRecipients": "Powiadom odbiorców",
|
"notifyRecipients": "Powiadom odbiorców",
|
||||||
"delete": "Usuń",
|
"delete": "Usuń",
|
||||||
"downloadShareFiles": "Pobierz wszystkie pliki"
|
"downloadShareFiles": "Pobierz wszystkie pliki",
|
||||||
|
"viewQrCode": "Wyświetl kod QR"
|
||||||
},
|
},
|
||||||
"bulkActions": {
|
"bulkActions": {
|
||||||
"delete": "Usuń",
|
"delete": "Usuń",
|
||||||
@@ -1634,7 +1663,8 @@
|
|||||||
"title": "Upuść pliki, aby przesłać",
|
"title": "Upuść pliki, aby przesłać",
|
||||||
"description": "Zwolnij, aby przesłać pliki"
|
"description": "Zwolnij, aby przesłać pliki"
|
||||||
},
|
},
|
||||||
"pasteSuccess": "{count, plural, =1 {Obraz wklejony i przesłany pomyślnie} other {# obrazy wklejone i przesłane pomyślnie}}"
|
"pasteSuccess": "{count, plural, =1 {Obraz wklejony i przesłany pomyślnie} other {# obrazy wklejone i przesłane pomyślnie}}",
|
||||||
|
"filesQueued": "{count, plural, one {# plik w kolejce do przesyłania} other {# pliki w kolejce do przesyłania}}"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"modes": {
|
"modes": {
|
||||||
@@ -1719,11 +1749,5 @@
|
|||||||
"passwordMinLength": "Hasło musi mieć co najmniej 6 znaków",
|
"passwordMinLength": "Hasło musi mieć co najmniej 6 znaków",
|
||||||
"nameRequired": "Nazwa jest wymagana",
|
"nameRequired": "Nazwa jest wymagana",
|
||||||
"required": "To pole jest wymagane"
|
"required": "To pole jest wymagane"
|
||||||
},
|
|
||||||
"imageEdit": {
|
|
||||||
"title": "Edytuj obraz",
|
|
||||||
"rotate": "Obróć",
|
|
||||||
"zoom": "Powiększ",
|
|
||||||
"cropInstructions": "Przeciągnij, aby przesunąć, zmień rozmiar rogów, aby dostosować obszar przycięcia"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,8 @@
|
|||||||
"passwordLabel": "Senha",
|
"passwordLabel": "Senha",
|
||||||
"create": "Criar compartilhamento",
|
"create": "Criar compartilhamento",
|
||||||
"success": "Compartilhamento criado com sucesso",
|
"success": "Compartilhamento criado com sucesso",
|
||||||
"error": "Falha ao criar compartilhamento"
|
"error": "Falha ao criar compartilhamento",
|
||||||
|
"namePlaceholder": "Digite um nome para seu compartilhamento"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"loadError": "Falha ao carregar dados do painel",
|
"loadError": "Falha ao carregar dados do painel",
|
||||||
@@ -313,7 +314,8 @@
|
|||||||
"title": "Esqueceu a Senha",
|
"title": "Esqueceu a Senha",
|
||||||
"description": "Digite seu endereço de email e enviaremos instruções para redefinir sua senha.",
|
"description": "Digite seu endereço de email e enviaremos instruções para redefinir sua senha.",
|
||||||
"resetInstructions": "Instruções de redefinição enviadas para seu email",
|
"resetInstructions": "Instruções de redefinição enviadas para seu email",
|
||||||
"pageTitle": "Esqueceu a Senha"
|
"pageTitle": "Esqueceu a Senha",
|
||||||
|
"passwordAuthDisabled": "A autenticação por senha está desativada. Por favor, contate seu administrador ou use um provedor de autenticação externo."
|
||||||
},
|
},
|
||||||
"generateShareLink": {
|
"generateShareLink": {
|
||||||
"generateTitle": "Gerar link de compartilhamento",
|
"generateTitle": "Gerar link de compartilhamento",
|
||||||
@@ -327,7 +329,12 @@
|
|||||||
"copyButton": "Copiar link",
|
"copyButton": "Copiar link",
|
||||||
"success": "Link gerado com sucesso",
|
"success": "Link gerado com sucesso",
|
||||||
"error": "Erro ao gerar link",
|
"error": "Erro ao gerar link",
|
||||||
"copied": "Link copiado para a área de transferência"
|
"copied": "Link copiado para a área de transferência",
|
||||||
|
"readyDescription": "Seu link de compartilhamento está pronto. Você pode escanear o código QR diretamente, baixá-lo para uso posterior ou copiar o link abaixo.",
|
||||||
|
"tabs": {
|
||||||
|
"link": "Link",
|
||||||
|
"qrcode": "QR Code"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"description": "A alternativa open-source ao WeTransfer. Compartilhe arquivos com segurança, sem rastreamento ou limitações.",
|
"description": "A alternativa open-source ao WeTransfer. Compartilhe arquivos com segurança, sem rastreamento ou limitações.",
|
||||||
@@ -355,6 +362,12 @@
|
|||||||
"stats": "{iconCount} ícones de {libraryCount} bibliotecas",
|
"stats": "{iconCount} ícones de {libraryCount} bibliotecas",
|
||||||
"categoryBadge": "{category} ({count} ícones)"
|
"categoryBadge": "{category} ({count} ícones)"
|
||||||
},
|
},
|
||||||
|
"imageEdit": {
|
||||||
|
"title": "Editar imagem",
|
||||||
|
"rotate": "Girar",
|
||||||
|
"zoom": "Ampliar",
|
||||||
|
"cropInstructions": "Arraste para reposicionar, redimensione os cantos para ajustar a área de recorte"
|
||||||
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"welcome": "Bem-vindo ao",
|
"welcome": "Bem-vindo ao",
|
||||||
"signInToContinue": "Faça login para continuar",
|
"signInToContinue": "Faça login para continuar",
|
||||||
@@ -442,6 +455,11 @@
|
|||||||
},
|
},
|
||||||
"pageTitle": "Perfil"
|
"pageTitle": "Perfil"
|
||||||
},
|
},
|
||||||
|
"qrCodeModal": {
|
||||||
|
"title": "Compartilhar QR Code",
|
||||||
|
"description": "Escaneie este código QR para acessar o link.",
|
||||||
|
"download": "Baixar QR Code"
|
||||||
|
},
|
||||||
"quickAccess": {
|
"quickAccess": {
|
||||||
"files": {
|
"files": {
|
||||||
"title": "Meus Arquivos",
|
"title": "Meus Arquivos",
|
||||||
@@ -613,7 +631,8 @@
|
|||||||
"createLink": "Criar Link",
|
"createLink": "Criar Link",
|
||||||
"delete": "Excluir",
|
"delete": "Excluir",
|
||||||
"copyLinkTitle": "Copiar link",
|
"copyLinkTitle": "Copiar link",
|
||||||
"createLinkCTA": "Criar Link de Recebimento"
|
"createLinkCTA": "Criar Link de Recebimento",
|
||||||
|
"viewQrCode": "Visualizar QR Code"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"active": "Ativo",
|
"active": "Ativo",
|
||||||
@@ -629,7 +648,8 @@
|
|||||||
"viewDetails": "Ver Detalhes",
|
"viewDetails": "Ver Detalhes",
|
||||||
"edit": "Editar",
|
"edit": "Editar",
|
||||||
"delete": "Excluir",
|
"delete": "Excluir",
|
||||||
"viewFiles": "Arquivos Recebidos"
|
"viewFiles": "Arquivos Recebidos",
|
||||||
|
"viewQrCode": "Ver QR Code"
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "Nenhum link de recebimento criado",
|
"title": "Nenhum link de recebimento criado",
|
||||||
@@ -1123,6 +1143,10 @@
|
|||||||
"smtpTrustSelfSigned": {
|
"smtpTrustSelfSigned": {
|
||||||
"title": "Confiar em Certificados Auto-Assinados",
|
"title": "Confiar em Certificados Auto-Assinados",
|
||||||
"description": "Ative isso para confiar em certificados SSL/TLS auto-assinados (útil para ambientes de desenvolvimento)"
|
"description": "Ative isso para confiar em certificados SSL/TLS auto-assinados (útil para ambientes de desenvolvimento)"
|
||||||
|
},
|
||||||
|
"passwordAuthEnabled": {
|
||||||
|
"title": "Autenticação por Senha",
|
||||||
|
"description": "Ative ou desative a autenticação baseada em senha"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
@@ -1132,7 +1156,8 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadFailed": "Falha ao carregar configurações",
|
"loadFailed": "Falha ao carregar configurações",
|
||||||
"updateFailed": "Falha ao atualizar configurações"
|
"updateFailed": "Falha ao atualizar configurações",
|
||||||
|
"passwordAuthRequiresProvider": "Não é possível desabilitar a autenticação por senha sem ter pelo menos um provedor de autenticação ativo"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"noChanges": "Nenhuma alteração para salvar",
|
"noChanges": "Nenhuma alteração para salvar",
|
||||||
@@ -1230,7 +1255,10 @@
|
|||||||
"invalidDate": "Data inválida",
|
"invalidDate": "Data inválida",
|
||||||
"loadError": "Falha ao carregar detalhes do compartilhamento",
|
"loadError": "Falha ao carregar detalhes do compartilhamento",
|
||||||
"editSecurity": "Editar Segurança",
|
"editSecurity": "Editar Segurança",
|
||||||
"editExpiration": "Editar Expiração"
|
"editExpiration": "Editar Expiração",
|
||||||
|
"clickToEnlargeQrCode": "Clique para ampliar o QR Code",
|
||||||
|
"downloadQrCode": "Baixar QR Code",
|
||||||
|
"qrCode": "QR Code"
|
||||||
},
|
},
|
||||||
"shareExpiration": {
|
"shareExpiration": {
|
||||||
"neverExpires": "Nunca Expira",
|
"neverExpires": "Nunca Expira",
|
||||||
@@ -1425,7 +1453,8 @@
|
|||||||
"copyLink": "Copiar Link",
|
"copyLink": "Copiar Link",
|
||||||
"notifyRecipients": "Notificar Destinatários",
|
"notifyRecipients": "Notificar Destinatários",
|
||||||
"delete": "Excluir",
|
"delete": "Excluir",
|
||||||
"downloadShareFiles": "Baixar todos os arquivos"
|
"downloadShareFiles": "Baixar todos os arquivos",
|
||||||
|
"viewQrCode": "Visualizar QR Code"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storageUsage": {
|
"storageUsage": {
|
||||||
@@ -1634,7 +1663,8 @@
|
|||||||
"continue": "Continuar Uploads",
|
"continue": "Continuar Uploads",
|
||||||
"cancel": "Cancelar Uploads"
|
"cancel": "Cancelar Uploads"
|
||||||
},
|
},
|
||||||
"pasteSuccess": "{count, plural, =1 {Imagem colada e enviada com sucesso} other {# imagens coladas e enviadas com sucesso}}"
|
"pasteSuccess": "{count, plural, =1 {Imagem colada e enviada com sucesso} other {# imagens coladas e enviadas com sucesso}}",
|
||||||
|
"filesQueued": "{count, plural, one {# arquivo na fila para upload} other {# arquivos na fila para upload}}"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"modes": {
|
"modes": {
|
||||||
@@ -1719,11 +1749,5 @@
|
|||||||
"lastNameRequired": "O sobrenome é necessário",
|
"lastNameRequired": "O sobrenome é necessário",
|
||||||
"usernameLength": "O nome de usuário deve ter pelo menos 3 caracteres",
|
"usernameLength": "O nome de usuário deve ter pelo menos 3 caracteres",
|
||||||
"usernameSpaces": "O nome de usuário não pode conter espaços"
|
"usernameSpaces": "O nome de usuário não pode conter espaços"
|
||||||
},
|
|
||||||
"imageEdit": {
|
|
||||||
"title": "Editar imagem",
|
|
||||||
"rotate": "Girar",
|
|
||||||
"zoom": "Ampliar",
|
|
||||||
"cropInstructions": "Arraste para reposicionar, redimensione os cantos para ajustar a área de recorte"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,8 @@
|
|||||||
"success": "Общий доступ успешно создан",
|
"success": "Общий доступ успешно создан",
|
||||||
"error": "Не удалось создать общий доступ",
|
"error": "Не удалось создать общий доступ",
|
||||||
"descriptionLabel": "Описание",
|
"descriptionLabel": "Описание",
|
||||||
"descriptionPlaceholder": "Введите описание (опционально)"
|
"descriptionPlaceholder": "Введите описание (опционально)",
|
||||||
|
"namePlaceholder": "Введите имя для вашего общего доступа"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"loadError": "Ошибка загрузки данных панели управления",
|
"loadError": "Ошибка загрузки данных панели управления",
|
||||||
@@ -313,7 +314,8 @@
|
|||||||
"title": "Забыли пароль",
|
"title": "Забыли пароль",
|
||||||
"description": "Введите адрес электронной почты, и мы отправим вам инструкции по сбросу пароля.",
|
"description": "Введите адрес электронной почты, и мы отправим вам инструкции по сбросу пароля.",
|
||||||
"resetInstructions": "Инструкции по сбросу отправлены на вашу электронную почту",
|
"resetInstructions": "Инструкции по сбросу отправлены на вашу электронную почту",
|
||||||
"pageTitle": "Забыли пароль"
|
"pageTitle": "Забыли пароль",
|
||||||
|
"passwordAuthDisabled": "Парольная аутентификация отключена. Пожалуйста, свяжитесь с администратором или используйте внешний провайдер аутентификации."
|
||||||
},
|
},
|
||||||
"generateShareLink": {
|
"generateShareLink": {
|
||||||
"generateTitle": "Создать ссылку для обмена",
|
"generateTitle": "Создать ссылку для обмена",
|
||||||
@@ -327,7 +329,12 @@
|
|||||||
"copyButton": "Копировать ссылку",
|
"copyButton": "Копировать ссылку",
|
||||||
"success": "Ссылка успешно создана",
|
"success": "Ссылка успешно создана",
|
||||||
"error": "Ошибка при создании ссылки",
|
"error": "Ошибка при создании ссылки",
|
||||||
"copied": "Ссылка скопирована в буфер обмена"
|
"copied": "Ссылка скопирована в буфер обмена",
|
||||||
|
"readyDescription": "Ваша ссылка для обмена готова. Вы можете сканировать QR-код напрямую, скачать его для последующего использования или скопировать ссылку ниже.",
|
||||||
|
"tabs": {
|
||||||
|
"link": "Ссылка",
|
||||||
|
"qrcode": "QR-код"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"description": "Открытая альтернатива WeTransfer. Делитесь файлами безопасно, без отслеживания и ограничений.",
|
"description": "Открытая альтернатива WeTransfer. Делитесь файлами безопасно, без отслеживания и ограничений.",
|
||||||
@@ -355,6 +362,12 @@
|
|||||||
"stats": "{iconCount} иконок из {libraryCount} библиотек",
|
"stats": "{iconCount} иконок из {libraryCount} библиотек",
|
||||||
"categoryBadge": "{category} ({count} иконок)"
|
"categoryBadge": "{category} ({count} иконок)"
|
||||||
},
|
},
|
||||||
|
"imageEdit": {
|
||||||
|
"title": "Редактировать изображение",
|
||||||
|
"rotate": "Повернуть",
|
||||||
|
"zoom": "Увеличить",
|
||||||
|
"cropInstructions": "Перетащите, чтобы переместить, измените размер углов, чтобы отрегулировать область обрезки"
|
||||||
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"welcome": "Добро пожаловать в",
|
"welcome": "Добро пожаловать в",
|
||||||
"signInToContinue": "Войдите, чтобы продолжить",
|
"signInToContinue": "Войдите, чтобы продолжить",
|
||||||
@@ -442,6 +455,11 @@
|
|||||||
},
|
},
|
||||||
"pageTitle": "Профиль"
|
"pageTitle": "Профиль"
|
||||||
},
|
},
|
||||||
|
"qrCodeModal": {
|
||||||
|
"title": "Поделиться QR-кодом",
|
||||||
|
"description": "Отсканируйте этот QR-код, чтобы получить доступ к ссылке.",
|
||||||
|
"download": "Скачать QR-код"
|
||||||
|
},
|
||||||
"quickAccess": {
|
"quickAccess": {
|
||||||
"files": {
|
"files": {
|
||||||
"title": "Мои файлы",
|
"title": "Мои файлы",
|
||||||
@@ -613,7 +631,8 @@
|
|||||||
"createLink": "Создать ссылку",
|
"createLink": "Создать ссылку",
|
||||||
"delete": "Удалить",
|
"delete": "Удалить",
|
||||||
"copyLinkTitle": "Копировать ссылку",
|
"copyLinkTitle": "Копировать ссылку",
|
||||||
"createLinkCTA": "Создать ссылку для получения"
|
"createLinkCTA": "Создать ссылку для получения",
|
||||||
|
"viewQrCode": "Просмотр QR-кода"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"active": "Активно",
|
"active": "Активно",
|
||||||
@@ -629,7 +648,8 @@
|
|||||||
"viewDetails": "Просмотр деталей",
|
"viewDetails": "Просмотр деталей",
|
||||||
"edit": "Редактировать",
|
"edit": "Редактировать",
|
||||||
"delete": "Удалить",
|
"delete": "Удалить",
|
||||||
"viewFiles": "Полученные файлы"
|
"viewFiles": "Полученные файлы",
|
||||||
|
"viewQrCode": "Просмотр QR-кода"
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "Нет созданных ссылок для получения",
|
"title": "Нет созданных ссылок для получения",
|
||||||
@@ -1115,6 +1135,10 @@
|
|||||||
"smtpTrustSelfSigned": {
|
"smtpTrustSelfSigned": {
|
||||||
"title": "Доверять самоподписанным сертификатам",
|
"title": "Доверять самоподписанным сертификатам",
|
||||||
"description": "Включите это для доверия самоподписанным SSL/TLS сертификатам (полезно для сред разработки)"
|
"description": "Включите это для доверия самоподписанным SSL/TLS сертификатам (полезно для сред разработки)"
|
||||||
|
},
|
||||||
|
"passwordAuthEnabled": {
|
||||||
|
"title": "Парольная аутентификация",
|
||||||
|
"description": "Включить или отключить парольную аутентификацию"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
@@ -1124,7 +1148,8 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadFailed": "Ошибка загрузки настроек",
|
"loadFailed": "Ошибка загрузки настроек",
|
||||||
"updateFailed": "Ошибка обновления настроек"
|
"updateFailed": "Ошибка обновления настроек",
|
||||||
|
"passwordAuthRequiresProvider": "Парольную аутентификацию нельзя отключить, если нет хотя бы одного активного поставщика аутентификации"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"noChanges": "Изменений для сохранения нет",
|
"noChanges": "Изменений для сохранения нет",
|
||||||
@@ -1230,7 +1255,10 @@
|
|||||||
"invalidDate": "Неверная дата",
|
"invalidDate": "Неверная дата",
|
||||||
"loadError": "Ошибка загрузки деталей общего доступа",
|
"loadError": "Ошибка загрузки деталей общего доступа",
|
||||||
"editSecurity": "Изменить безопасность",
|
"editSecurity": "Изменить безопасность",
|
||||||
"editExpiration": "Изменить срок действия"
|
"editExpiration": "Изменить срок действия",
|
||||||
|
"clickToEnlargeQrCode": "Нажмите, чтобы увеличить QR-код",
|
||||||
|
"downloadQrCode": "Скачать QR-код",
|
||||||
|
"qrCode": "QR-код"
|
||||||
},
|
},
|
||||||
"shareExpiration": {
|
"shareExpiration": {
|
||||||
"neverExpires": "Никогда не истекает",
|
"neverExpires": "Никогда не истекает",
|
||||||
@@ -1417,7 +1445,8 @@
|
|||||||
"copyLink": "Скопировать Ссылку",
|
"copyLink": "Скопировать Ссылку",
|
||||||
"notifyRecipients": "Уведомить Получателей",
|
"notifyRecipients": "Уведомить Получателей",
|
||||||
"delete": "Удалить",
|
"delete": "Удалить",
|
||||||
"downloadShareFiles": "Загрузите все файлы"
|
"downloadShareFiles": "Загрузите все файлы",
|
||||||
|
"viewQrCode": "Просмотр QR-кода"
|
||||||
},
|
},
|
||||||
"bulkActions": {
|
"bulkActions": {
|
||||||
"delete": "Удалить",
|
"delete": "Удалить",
|
||||||
@@ -1634,7 +1663,8 @@
|
|||||||
"title": "Перетащите файлы для загрузки",
|
"title": "Перетащите файлы для загрузки",
|
||||||
"description": "Отпустите, чтобы загрузить файлы"
|
"description": "Отпустите, чтобы загрузить файлы"
|
||||||
},
|
},
|
||||||
"pasteSuccess": "{count, plural, =1 {Изображение вставлено и успешно загружено} other {# изображений вставлено и успешно загружено}}"
|
"pasteSuccess": "{count, plural, =1 {Изображение вставлено и успешно загружено} other {# изображений вставлено и успешно загружено}}",
|
||||||
|
"filesQueued": "{count, plural, one {# файл в очереди для загрузки} other {# файлов в очереди для загрузки}}"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"modes": {
|
"modes": {
|
||||||
@@ -1719,11 +1749,5 @@
|
|||||||
"passwordRequired": "Требуется пароль",
|
"passwordRequired": "Требуется пароль",
|
||||||
"nameRequired": "Требуется имя",
|
"nameRequired": "Требуется имя",
|
||||||
"required": "Это поле обязательно"
|
"required": "Это поле обязательно"
|
||||||
},
|
|
||||||
"imageEdit": {
|
|
||||||
"title": "Редактировать изображение",
|
|
||||||
"rotate": "Повернуть",
|
|
||||||
"zoom": "Увеличить",
|
|
||||||
"cropInstructions": "Перетащите, чтобы переместить, измените размер углов, чтобы отрегулировать область обрезки"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,8 @@
|
|||||||
"success": "Paylaşım başarıyla oluşturuldu",
|
"success": "Paylaşım başarıyla oluşturuldu",
|
||||||
"error": "Paylaşım oluşturulamadı",
|
"error": "Paylaşım oluşturulamadı",
|
||||||
"descriptionLabel": "Açıklama",
|
"descriptionLabel": "Açıklama",
|
||||||
"descriptionPlaceholder": "Açıklama girin (isteğe bağlı)"
|
"descriptionPlaceholder": "Açıklama girin (isteğe bağlı)",
|
||||||
|
"namePlaceholder": "Paylaşımınız için bir ad girin"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"loadError": "Gösterge paneli verileri yüklenemedi",
|
"loadError": "Gösterge paneli verileri yüklenemedi",
|
||||||
@@ -313,7 +314,8 @@
|
|||||||
"title": "Şifrenizi mi Unuttunuz?",
|
"title": "Şifrenizi mi Unuttunuz?",
|
||||||
"description": "E-posta adresinizi girin, şifre sıfırlama talimatlarını göndereceğiz.",
|
"description": "E-posta adresinizi girin, şifre sıfırlama talimatlarını göndereceğiz.",
|
||||||
"resetInstructions": "Şifre sıfırlama talimatları e-posta adresinize gönderildi",
|
"resetInstructions": "Şifre sıfırlama talimatları e-posta adresinize gönderildi",
|
||||||
"pageTitle": "Şifrenizi mi Unuttunuz?"
|
"pageTitle": "Şifrenizi mi Unuttunuz?",
|
||||||
|
"passwordAuthDisabled": "Şifre doğrulama devre dışı. Lütfen yöneticinize başvurun veya dış doğrulama sağlayıcısı kullanın."
|
||||||
},
|
},
|
||||||
"generateShareLink": {
|
"generateShareLink": {
|
||||||
"generateTitle": "Paylaşım Bağlantısı Oluştur",
|
"generateTitle": "Paylaşım Bağlantısı Oluştur",
|
||||||
@@ -327,7 +329,12 @@
|
|||||||
"copyButton": "Bağlantıyı Kopyala",
|
"copyButton": "Bağlantıyı Kopyala",
|
||||||
"success": "Bağlantı başarıyla oluşturuldu",
|
"success": "Bağlantı başarıyla oluşturuldu",
|
||||||
"error": "Bağlantı oluşturulamadı",
|
"error": "Bağlantı oluşturulamadı",
|
||||||
"copied": "Bağlantı panoya kopyalandı"
|
"copied": "Bağlantı panoya kopyalandı",
|
||||||
|
"readyDescription": "Paylaşım bağlantınız hazır. QR kodu doğrudan tarayabilir, daha sonra kullanmak için indirebilir veya aşağıdaki bağlantıyı kopyalayabilirsiniz.",
|
||||||
|
"tabs": {
|
||||||
|
"link": "Bağlantı",
|
||||||
|
"qrcode": "QR Kodu"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"description": "WeTransfer'e açık kaynaklı alternatif. Takip veya kısıtlama olmadan dosyalarınızı güvenle paylaşın.",
|
"description": "WeTransfer'e açık kaynaklı alternatif. Takip veya kısıtlama olmadan dosyalarınızı güvenle paylaşın.",
|
||||||
@@ -355,6 +362,12 @@
|
|||||||
"stats": "{libraryCount} kütüphaneden {iconCount} simge",
|
"stats": "{libraryCount} kütüphaneden {iconCount} simge",
|
||||||
"categoryBadge": "{category} ({count} simge)"
|
"categoryBadge": "{category} ({count} simge)"
|
||||||
},
|
},
|
||||||
|
"imageEdit": {
|
||||||
|
"title": "Resmi Düzenle",
|
||||||
|
"rotate": "Döndür",
|
||||||
|
"zoom": "Yakınlaştır",
|
||||||
|
"cropInstructions": "Yerleştirmek için sürükleyin, kırpma alanını ayarlamak için köşeleri yeniden boyutlandırın"
|
||||||
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"welcome": "Hoş geldiniz'e",
|
"welcome": "Hoş geldiniz'e",
|
||||||
"signInToContinue": "Devam etmek için oturum açın",
|
"signInToContinue": "Devam etmek için oturum açın",
|
||||||
@@ -442,6 +455,11 @@
|
|||||||
},
|
},
|
||||||
"pageTitle": "Profil"
|
"pageTitle": "Profil"
|
||||||
},
|
},
|
||||||
|
"qrCodeModal": {
|
||||||
|
"title": "QR Kodu Paylaş",
|
||||||
|
"description": "Bu QR kodu tarayarak bağlantıya erişebilirsiniz.",
|
||||||
|
"download": "QR Kodu İndir"
|
||||||
|
},
|
||||||
"quickAccess": {
|
"quickAccess": {
|
||||||
"files": {
|
"files": {
|
||||||
"title": "Benim Dosyalarım",
|
"title": "Benim Dosyalarım",
|
||||||
@@ -613,7 +631,8 @@
|
|||||||
"createLink": "Bağlantı Oluştur",
|
"createLink": "Bağlantı Oluştur",
|
||||||
"delete": "Sil",
|
"delete": "Sil",
|
||||||
"copyLinkTitle": "Bağlantıyı kopyala",
|
"copyLinkTitle": "Bağlantıyı kopyala",
|
||||||
"createLinkCTA": "Alma Bağlantısı Oluştur"
|
"createLinkCTA": "Alma Bağlantısı Oluştur",
|
||||||
|
"viewQrCode": "QR Kodu Görüntüle"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"active": "Aktif",
|
"active": "Aktif",
|
||||||
@@ -629,7 +648,8 @@
|
|||||||
"viewDetails": "Detayları Görüntüle",
|
"viewDetails": "Detayları Görüntüle",
|
||||||
"edit": "Düzenle",
|
"edit": "Düzenle",
|
||||||
"delete": "Sil",
|
"delete": "Sil",
|
||||||
"viewFiles": "Alınan Dosyalar"
|
"viewFiles": "Alınan Dosyalar",
|
||||||
|
"viewQrCode": "QR Kodu Görüntüle"
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "Alma bağlantısı oluşturulmadı",
|
"title": "Alma bağlantısı oluşturulmadı",
|
||||||
@@ -1115,6 +1135,10 @@
|
|||||||
"smtpTrustSelfSigned": {
|
"smtpTrustSelfSigned": {
|
||||||
"title": "Kendinden İmzalı Sertifikalara Güven",
|
"title": "Kendinden İmzalı Sertifikalara Güven",
|
||||||
"description": "Kendinden imzalı SSL/TLS sertifikalarına güvenmek için bunu etkinleştirin (geliştirme ortamları için kullanışlıdır)"
|
"description": "Kendinden imzalı SSL/TLS sertifikalarına güvenmek için bunu etkinleştirin (geliştirme ortamları için kullanışlıdır)"
|
||||||
|
},
|
||||||
|
"passwordAuthEnabled": {
|
||||||
|
"title": "Şifre Doğrulama",
|
||||||
|
"description": "Şifre tabanlı doğrulamayı etkinleştirme veya devre dışı bırakma"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
@@ -1124,7 +1148,8 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadFailed": "Ayarlar yüklenemedi",
|
"loadFailed": "Ayarlar yüklenemedi",
|
||||||
"updateFailed": "Ayarlar güncellenemedi"
|
"updateFailed": "Ayarlar güncellenemedi",
|
||||||
|
"passwordAuthRequiresProvider": "En az bir aktif kimlik doğrulama sağlayıcısı olmadan şifre doğrulaması devre dışı bırakılamaz"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"noChanges": "Kaydedilecek değişiklik yok",
|
"noChanges": "Kaydedilecek değişiklik yok",
|
||||||
@@ -1230,7 +1255,10 @@
|
|||||||
"invalidDate": "Geçersiz tarih",
|
"invalidDate": "Geçersiz tarih",
|
||||||
"loadError": "Paylaşım detaylarını yükleme başarısız",
|
"loadError": "Paylaşım detaylarını yükleme başarısız",
|
||||||
"editSecurity": "Güvenlik Düzenle",
|
"editSecurity": "Güvenlik Düzenle",
|
||||||
"editExpiration": "Son Kullanma Düzenle"
|
"editExpiration": "Son Kullanma Düzenle",
|
||||||
|
"clickToEnlargeQrCode": "QR Kodu Büyüt",
|
||||||
|
"downloadQrCode": "QR Kodu İndir",
|
||||||
|
"qrCode": "QR Kodu"
|
||||||
},
|
},
|
||||||
"shareExpiration": {
|
"shareExpiration": {
|
||||||
"neverExpires": "Asla Sona Ermez",
|
"neverExpires": "Asla Sona Ermez",
|
||||||
@@ -1417,7 +1445,8 @@
|
|||||||
"copyLink": "Bağlantıyı Kopyala",
|
"copyLink": "Bağlantıyı Kopyala",
|
||||||
"notifyRecipients": "Alıcıları Bilgilendir",
|
"notifyRecipients": "Alıcıları Bilgilendir",
|
||||||
"delete": "Sil",
|
"delete": "Sil",
|
||||||
"downloadShareFiles": "Tüm dosyaları indirin"
|
"downloadShareFiles": "Tüm dosyaları indirin",
|
||||||
|
"viewQrCode": "QR Kodu Görüntüle"
|
||||||
},
|
},
|
||||||
"bulkActions": {
|
"bulkActions": {
|
||||||
"delete": "Sil",
|
"delete": "Sil",
|
||||||
@@ -1634,7 +1663,8 @@
|
|||||||
"title": "Yüklemek için dosyaları bırakın",
|
"title": "Yüklemek için dosyaları bırakın",
|
||||||
"description": "Dosyalarınızı yüklemek için bırakın"
|
"description": "Dosyalarınızı yüklemek için bırakın"
|
||||||
},
|
},
|
||||||
"pasteSuccess": "{count, plural, =1 {Görüntü yapıştırıldı ve başarıyla yüklendi} other {# görüntü yapıştırıldı ve başarıyla yüklendi}}"
|
"pasteSuccess": "{count, plural, =1 {Görüntü yapıştırıldı ve başarıyla yüklendi} other {# görüntü yapıştırıldı ve başarıyla yüklendi}}",
|
||||||
|
"filesQueued": "{count, plural, one {# dosya yükleme için bekliyor} other {# dosya yükleme için bekliyor}}"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"modes": {
|
"modes": {
|
||||||
@@ -1719,11 +1749,5 @@
|
|||||||
"passwordRequired": "Şifre gerekli",
|
"passwordRequired": "Şifre gerekli",
|
||||||
"nameRequired": "İsim gereklidir",
|
"nameRequired": "İsim gereklidir",
|
||||||
"required": "Bu alan zorunludur"
|
"required": "Bu alan zorunludur"
|
||||||
},
|
|
||||||
"imageEdit": {
|
|
||||||
"title": "Resmi Düzenle",
|
|
||||||
"rotate": "Döndür",
|
|
||||||
"zoom": "Yakınlaştır",
|
|
||||||
"cropInstructions": "Yerleştirmek için sürükleyin, kırpma alanını ayarlamak için köşeleri yeniden boyutlandırın"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,8 @@
|
|||||||
"passwordLabel": "密码",
|
"passwordLabel": "密码",
|
||||||
"create": "创建分享",
|
"create": "创建分享",
|
||||||
"success": "分享创建成功",
|
"success": "分享创建成功",
|
||||||
"error": "创建分享失败"
|
"error": "创建分享失败",
|
||||||
|
"namePlaceholder": "输入分享名称"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"loadError": "加载仪表盘数据失败",
|
"loadError": "加载仪表盘数据失败",
|
||||||
@@ -313,7 +314,8 @@
|
|||||||
"title": "忘记密码?",
|
"title": "忘记密码?",
|
||||||
"description": "请输入您的电子邮件,我们将发送密码重置指令给您。",
|
"description": "请输入您的电子邮件,我们将发送密码重置指令给您。",
|
||||||
"resetInstructions": "密码重置指令已发送到您的电子邮件",
|
"resetInstructions": "密码重置指令已发送到您的电子邮件",
|
||||||
"pageTitle": "忘记密码?"
|
"pageTitle": "忘记密码?",
|
||||||
|
"passwordAuthDisabled": "密码认证已禁用。请联系您的管理员或使用外部认证提供商。"
|
||||||
},
|
},
|
||||||
"generateShareLink": {
|
"generateShareLink": {
|
||||||
"generateTitle": "生成分享链接",
|
"generateTitle": "生成分享链接",
|
||||||
@@ -327,7 +329,12 @@
|
|||||||
"copyButton": "复制链接",
|
"copyButton": "复制链接",
|
||||||
"success": "链接生成成功",
|
"success": "链接生成成功",
|
||||||
"error": "链接生成失败",
|
"error": "链接生成失败",
|
||||||
"copied": "链接已复制到剪贴板"
|
"copied": "链接已复制到剪贴板",
|
||||||
|
"readyDescription": "您的分享链接已准备就绪:",
|
||||||
|
"tabs": {
|
||||||
|
"link": "链接",
|
||||||
|
"qrcode": "QR Code"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"description": "WeTransfer的开源替代方案。安全分享文件,无需跟踪或限制。",
|
"description": "WeTransfer的开源替代方案。安全分享文件,无需跟踪或限制。",
|
||||||
@@ -355,6 +362,12 @@
|
|||||||
"stats": "来自 {libraryCount} 个库的 {iconCount} 个图标",
|
"stats": "来自 {libraryCount} 个库的 {iconCount} 个图标",
|
||||||
"categoryBadge": "{category}({count} 个图标)"
|
"categoryBadge": "{category}({count} 个图标)"
|
||||||
},
|
},
|
||||||
|
"imageEdit": {
|
||||||
|
"title": "编辑图片",
|
||||||
|
"rotate": "旋转",
|
||||||
|
"zoom": "缩放",
|
||||||
|
"cropInstructions": "拖动以重新定位,调整角落大小以调整裁剪区域"
|
||||||
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"welcome": "欢迎您",
|
"welcome": "欢迎您",
|
||||||
"signInToContinue": "请登录以继续",
|
"signInToContinue": "请登录以继续",
|
||||||
@@ -442,6 +455,11 @@
|
|||||||
},
|
},
|
||||||
"pageTitle": "个人资料"
|
"pageTitle": "个人资料"
|
||||||
},
|
},
|
||||||
|
"qrCodeModal": {
|
||||||
|
"title": "分享QR Code",
|
||||||
|
"description": "扫描此QR Code以访问链接。",
|
||||||
|
"download": "下载QR Code"
|
||||||
|
},
|
||||||
"quickAccess": {
|
"quickAccess": {
|
||||||
"files": {
|
"files": {
|
||||||
"title": "我的文件",
|
"title": "我的文件",
|
||||||
@@ -613,7 +631,8 @@
|
|||||||
"createLink": "创建链接",
|
"createLink": "创建链接",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"copyLinkTitle": "复制链接",
|
"copyLinkTitle": "复制链接",
|
||||||
"createLinkCTA": "创建接收链接"
|
"createLinkCTA": "创建接收链接",
|
||||||
|
"viewQrCode": "查看QR Code"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"active": "活动",
|
"active": "活动",
|
||||||
@@ -629,7 +648,8 @@
|
|||||||
"viewDetails": "查看详情",
|
"viewDetails": "查看详情",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"viewFiles": "已接收文件"
|
"viewFiles": "已接收文件",
|
||||||
|
"viewQrCode": "查看QR Code"
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "未创建接收链接",
|
"title": "未创建接收链接",
|
||||||
@@ -1115,6 +1135,10 @@
|
|||||||
"smtpTrustSelfSigned": {
|
"smtpTrustSelfSigned": {
|
||||||
"title": "信任自签名证书",
|
"title": "信任自签名证书",
|
||||||
"description": "启用此选项以信任自签名SSL/TLS证书(对开发环境有用)"
|
"description": "启用此选项以信任自签名SSL/TLS证书(对开发环境有用)"
|
||||||
|
},
|
||||||
|
"passwordAuthEnabled": {
|
||||||
|
"title": "密码认证",
|
||||||
|
"description": "启用或禁用基于密码的认证"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
@@ -1124,7 +1148,8 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadFailed": "加载设置失败",
|
"loadFailed": "加载设置失败",
|
||||||
"updateFailed": "更新设置失败"
|
"updateFailed": "更新设置失败",
|
||||||
|
"passwordAuthRequiresProvider": "没有至少一个活动认证提供者时,无法禁用密码认证"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"noChanges": "没有需要保存的更改",
|
"noChanges": "没有需要保存的更改",
|
||||||
@@ -1230,7 +1255,10 @@
|
|||||||
"description": "描述",
|
"description": "描述",
|
||||||
"linkCopied": "链接已复制到剪贴板",
|
"linkCopied": "链接已复制到剪贴板",
|
||||||
"editSecurity": "编辑安全",
|
"editSecurity": "编辑安全",
|
||||||
"editExpiration": "编辑过期"
|
"editExpiration": "编辑过期",
|
||||||
|
"clickToEnlargeQrCode": "点击放大QR Code",
|
||||||
|
"downloadQrCode": "下载QR Code",
|
||||||
|
"qrCode": "QR Code"
|
||||||
},
|
},
|
||||||
"shareExpiration": {
|
"shareExpiration": {
|
||||||
"neverExpires": "永不过期",
|
"neverExpires": "永不过期",
|
||||||
@@ -1417,7 +1445,8 @@
|
|||||||
"copyLink": "复制链接",
|
"copyLink": "复制链接",
|
||||||
"notifyRecipients": "通知收件人",
|
"notifyRecipients": "通知收件人",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"downloadShareFiles": "下载所有文件"
|
"downloadShareFiles": "下载所有文件",
|
||||||
|
"viewQrCode": "查看QR Code"
|
||||||
},
|
},
|
||||||
"bulkActions": {
|
"bulkActions": {
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
@@ -1483,11 +1512,7 @@
|
|||||||
"copyToClipboard": "复制到剪贴板",
|
"copyToClipboard": "复制到剪贴板",
|
||||||
"savedMessage": "我已保存备用码",
|
"savedMessage": "我已保存备用码",
|
||||||
"available": "可用备用码:{count}个",
|
"available": "可用备用码:{count}个",
|
||||||
"instructions": [
|
"instructions": ["• 将这些代码保存在安全的位置", "• 每个备用码只能使用一次", "• 您可以随时生成新的备用码"]
|
||||||
"• 将这些代码保存在安全的位置",
|
|
||||||
"• 每个备用码只能使用一次",
|
|
||||||
"• 您可以随时生成新的备用码"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"verification": {
|
"verification": {
|
||||||
"title": "双重认证",
|
"title": "双重认证",
|
||||||
@@ -1634,7 +1659,8 @@
|
|||||||
"title": "拖放文件以上传",
|
"title": "拖放文件以上传",
|
||||||
"description": "松开以上传您的文件"
|
"description": "松开以上传您的文件"
|
||||||
},
|
},
|
||||||
"pasteSuccess": "{count, plural, =1 {图片已成功粘贴并上传} other {# 张图片已成功粘贴并上传}}"
|
"pasteSuccess": "{count, plural, =1 {图片已成功粘贴并上传} other {# 张图片已成功粘贴并上传}}",
|
||||||
|
"filesQueued": "{count, plural, one {# 文件正在等待上传} other {# 文件正在等待上传}}"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"modes": {
|
"modes": {
|
||||||
@@ -1719,11 +1745,5 @@
|
|||||||
"passwordRequired": "密码为必填项",
|
"passwordRequired": "密码为必填项",
|
||||||
"nameRequired": "名称为必填项",
|
"nameRequired": "名称为必填项",
|
||||||
"required": "此字段为必填项"
|
"required": "此字段为必填项"
|
||||||
},
|
|
||||||
"imageEdit": {
|
|
||||||
"title": "编辑图片",
|
|
||||||
"rotate": "旋转",
|
|
||||||
"zoom": "缩放",
|
|
||||||
"cropInstructions": "拖动以重新定位,调整角落大小以调整裁剪区域"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "palmr-web",
|
"name": "palmr-web",
|
||||||
"version": "3.1.2-beta",
|
"version": "3.1.8-beta",
|
||||||
"description": "Frontend for Palmr",
|
"description": "Frontend for Palmr",
|
||||||
"private": true,
|
"private": true,
|
||||||
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
|
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
|
||||||
@@ -70,6 +70,7 @@
|
|||||||
"react-hook-form": "^7.59.0",
|
"react-hook-form": "^7.59.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-image-crop": "^11.0.10",
|
"react-image-crop": "^11.0.10",
|
||||||
|
"react-qr-code": "^2.0.18",
|
||||||
"react-qr-reader": "3.0.0-beta-1",
|
"react-qr-reader": "3.0.0-beta-1",
|
||||||
"sonner": "^2.0.5",
|
"sonner": "^2.0.5",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
|||||||
19
apps/web/pnpm-lock.yaml
generated
19
apps/web/pnpm-lock.yaml
generated
@@ -128,6 +128,9 @@ importers:
|
|||||||
react-image-crop:
|
react-image-crop:
|
||||||
specifier: ^11.0.10
|
specifier: ^11.0.10
|
||||||
version: 11.0.10(react@19.1.0)
|
version: 11.0.10(react@19.1.0)
|
||||||
|
react-qr-code:
|
||||||
|
specifier: ^2.0.18
|
||||||
|
version: 2.0.18(react@19.1.0)
|
||||||
react-qr-reader:
|
react-qr-reader:
|
||||||
specifier: 3.0.0-beta-1
|
specifier: 3.0.0-beta-1
|
||||||
version: 3.0.0-beta-1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
version: 3.0.0-beta-1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
@@ -2544,6 +2547,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
qr.js@0.0.0:
|
||||||
|
resolution: {integrity: sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==}
|
||||||
|
|
||||||
qrcode@1.5.4:
|
qrcode@1.5.4:
|
||||||
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
@@ -2591,6 +2597,11 @@ packages:
|
|||||||
react-is@16.13.1:
|
react-is@16.13.1:
|
||||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||||
|
|
||||||
|
react-qr-code@2.0.18:
|
||||||
|
resolution: {integrity: sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '*'
|
||||||
|
|
||||||
react-qr-reader@3.0.0-beta-1:
|
react-qr-reader@3.0.0-beta-1:
|
||||||
resolution: {integrity: sha512-5HeFH9x/BlziRYQYGK2AeWS9WiKYZtGGMs9DXy3bcySTX3C9UJL9EwcPnWw8vlf7JP4FcrAlr1SnZ5nsWLQGyw==}
|
resolution: {integrity: sha512-5HeFH9x/BlziRYQYGK2AeWS9WiKYZtGGMs9DXy3bcySTX3C9UJL9EwcPnWw8vlf7JP4FcrAlr1SnZ5nsWLQGyw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -5451,6 +5462,8 @@ snapshots:
|
|||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
|
qr.js@0.0.0: {}
|
||||||
|
|
||||||
qrcode@1.5.4:
|
qrcode@1.5.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
dijkstrajs: 1.0.3
|
dijkstrajs: 1.0.3
|
||||||
@@ -5491,6 +5504,12 @@ snapshots:
|
|||||||
|
|
||||||
react-is@16.13.1: {}
|
react-is@16.13.1: {}
|
||||||
|
|
||||||
|
react-qr-code@2.0.18(react@19.1.0):
|
||||||
|
dependencies:
|
||||||
|
prop-types: 15.8.1
|
||||||
|
qr.js: 0.0.0
|
||||||
|
react: 19.1.0
|
||||||
|
|
||||||
react-qr-reader@3.0.0-beta-1(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
react-qr-reader@3.0.0-beta-1(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@zxing/browser': 0.0.7(@zxing/library@0.18.6)
|
'@zxing/browser': 0.0.7(@zxing/library@0.18.6)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function Navbar() {
|
|||||||
{appLogo && <img alt="App Logo" className="h-8 w-8 object-contain rounded" src={appLogo} />}
|
{appLogo && <img alt="App Logo" className="h-8 w-8 object-contain rounded" src={appLogo} />}
|
||||||
<p className="font-bold text-2xl">{appName}</p>
|
<p className="font-bold text-2xl">{appName}</p>
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="hidden lg:flex ml-2 gap-4">
|
<nav className="hidden md:flex ml-2 gap-4">
|
||||||
{siteConfig.navItems.map((item) => (
|
{siteConfig.navItems.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
@@ -44,7 +44,7 @@ export function Navbar() {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden md:flex items-center gap-2">
|
<div className="hidden lg:flex items-center gap-2">
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ export function Navbar() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 sm:hidden">
|
<div className="flex items-center gap-2 md:hidden">
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
<Sheet open={isMenuOpen} onOpenChange={setIsMenuOpen}>
|
<Sheet open={isMenuOpen} onOpenChange={setIsMenuOpen}>
|
||||||
@@ -79,6 +79,16 @@ export function Navbar() {
|
|||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
<Link
|
||||||
|
href={siteConfig.links.sponsor}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-foreground text-lg font-medium flex items-center gap-2"
|
||||||
|
onClick={() => setIsMenuOpen(false)}
|
||||||
|
>
|
||||||
|
<IconHeart className="h-4 w-4 text-destructive" />
|
||||||
|
Sponsor
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
|
|||||||
@@ -4,34 +4,51 @@ import { useEffect } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
import { useSecureConfigValue } from "@/hooks/use-secure-configs";
|
import { useSecureConfigValue } from "@/hooks/use-secure-configs";
|
||||||
|
|
||||||
interface HomeStore {
|
interface HomeStore {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
shouldShowHomePage: boolean;
|
||||||
setIsLoading: (loading: boolean) => void;
|
setIsLoading: (loading: boolean) => void;
|
||||||
|
setShouldShowHomePage: (show: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useHomeStore = create<HomeStore>((set) => ({
|
const useHomeStore = create<HomeStore>((set) => ({
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
|
shouldShowHomePage: false,
|
||||||
setIsLoading: (loading: boolean) => set({ isLoading: loading }),
|
setIsLoading: (loading: boolean) => set({ isLoading: loading }),
|
||||||
|
setShouldShowHomePage: (show: boolean) => set({ shouldShowHomePage: show }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export function useHome() {
|
export function useHome() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { isLoading, setIsLoading } = useHomeStore();
|
const { isLoading, shouldShowHomePage, setIsLoading, setShouldShowHomePage } = useHomeStore();
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
const { value: showHomePage, isLoading: configLoading } = useSecureConfigValue("showHomePage");
|
const { value: showHomePage, isLoading: configLoading } = useSecureConfigValue("showHomePage");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!configLoading) {
|
if (isAuthenticated === true) {
|
||||||
|
router.replace("/dashboard");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!configLoading && isAuthenticated !== null) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
||||||
if (showHomePage !== "true") {
|
if (showHomePage !== "true") {
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
|
setShouldShowHomePage(false);
|
||||||
|
} else if (isAuthenticated === false) {
|
||||||
|
setShouldShowHomePage(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [router, showHomePage, configLoading, setIsLoading]);
|
}, [router, showHomePage, configLoading, isAuthenticated, setIsLoading, setShouldShowHomePage]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isLoading,
|
isLoading,
|
||||||
|
shouldShowHomePage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import { Navbar } from "./components/navbar";
|
|||||||
import { useHome } from "./hooks/use-home";
|
import { useHome } from "./hooks/use-home";
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const { isLoading } = useHome();
|
const { isLoading, shouldShowHomePage } = useHome();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading || !shouldShowHomePage) {
|
||||||
return <LoadingScreen />;
|
return <LoadingScreen />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { getPresignedUrlForUploadByAlias, registerFileUploadByAlias } from "@/http/endpoints";
|
import { getPresignedUrlForUploadByAlias, registerFileUploadByAlias } from "@/http/endpoints";
|
||||||
|
import { getSystemInfo } from "@/http/endpoints/app";
|
||||||
import { ChunkedUploader } from "@/utils/chunked-upload";
|
import { ChunkedUploader } from "@/utils/chunked-upload";
|
||||||
import { formatFileSize } from "@/utils/format-file-size";
|
import { formatFileSize } from "@/utils/format-file-size";
|
||||||
import { FILE_STATUS, UPLOAD_CONFIG, UPLOAD_PROGRESS } from "../constants";
|
import { FILE_STATUS, UPLOAD_CONFIG, UPLOAD_PROGRESS } from "../constants";
|
||||||
@@ -25,9 +26,24 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
|
|||||||
const [uploaderEmail, setUploaderEmail] = useState("");
|
const [uploaderEmail, setUploaderEmail] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [isS3Enabled, setIsS3Enabled] = useState<boolean | null>(null);
|
||||||
|
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSystemInfo = async () => {
|
||||||
|
try {
|
||||||
|
const response = await getSystemInfo();
|
||||||
|
setIsS3Enabled(response.data.s3Enabled);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to fetch system info, defaulting to filesystem mode:", error);
|
||||||
|
setIsS3Enabled(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchSystemInfo();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const validateFileSize = useCallback(
|
const validateFileSize = useCallback(
|
||||||
(file: File): string | null => {
|
(file: File): string | null => {
|
||||||
if (!reverseShare.maxFileSize) return null;
|
if (!reverseShare.maxFileSize) return null;
|
||||||
@@ -139,7 +155,7 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
|
|||||||
presignedUrl: string,
|
presignedUrl: string,
|
||||||
onProgress?: (progress: number) => void
|
onProgress?: (progress: number) => void
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size);
|
const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size, isS3Enabled ?? undefined);
|
||||||
|
|
||||||
if (shouldUseChunked) {
|
if (shouldUseChunked) {
|
||||||
const chunkSize = ChunkedUploader.calculateOptimalChunkSize(file.size);
|
const chunkSize = ChunkedUploader.calculateOptimalChunkSize(file.size);
|
||||||
@@ -148,6 +164,7 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
|
|||||||
file,
|
file,
|
||||||
url: presignedUrl,
|
url: presignedUrl,
|
||||||
chunkSize,
|
chunkSize,
|
||||||
|
isS3Enabled: isS3Enabled ?? undefined,
|
||||||
onProgress,
|
onProgress,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { version } from "../../../../../../package.json";
|
import packageJson from "../../../../../../package.json";
|
||||||
|
|
||||||
|
const { version } = packageJson;
|
||||||
|
|
||||||
export function TransparentFooter() {
|
export function TransparentFooter() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
IconLink,
|
IconLink,
|
||||||
IconLock,
|
IconLock,
|
||||||
IconLockOpen,
|
IconLockOpen,
|
||||||
|
IconQrcode,
|
||||||
IconToggleLeft,
|
IconToggleLeft,
|
||||||
IconToggleRight,
|
IconToggleRight,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
@@ -38,6 +39,7 @@ interface ReverseShareCardProps {
|
|||||||
onGenerateLink: (reverseShare: ReverseShare) => void;
|
onGenerateLink: (reverseShare: ReverseShare) => void;
|
||||||
onViewDetails: (reverseShare: ReverseShare) => void;
|
onViewDetails: (reverseShare: ReverseShare) => void;
|
||||||
onViewFiles: (reverseShare: ReverseShare) => void;
|
onViewFiles: (reverseShare: ReverseShare) => void;
|
||||||
|
onViewQrCode?: (reverseShare: ReverseShare) => void;
|
||||||
onUpdateReverseShare?: (id: string, data: any) => Promise<any>;
|
onUpdateReverseShare?: (id: string, data: any) => Promise<any>;
|
||||||
onToggleActive?: (id: string, isActive: boolean) => Promise<any>;
|
onToggleActive?: (id: string, isActive: boolean) => Promise<any>;
|
||||||
onUpdatePassword?: (id: string, data: { hasPassword: boolean; password?: string }) => Promise<any>;
|
onUpdatePassword?: (id: string, data: { hasPassword: boolean; password?: string }) => Promise<any>;
|
||||||
@@ -51,6 +53,7 @@ export function ReverseShareCard({
|
|||||||
onGenerateLink,
|
onGenerateLink,
|
||||||
onViewDetails,
|
onViewDetails,
|
||||||
onViewFiles,
|
onViewFiles,
|
||||||
|
onViewQrCode,
|
||||||
onUpdateReverseShare,
|
onUpdateReverseShare,
|
||||||
onToggleActive,
|
onToggleActive,
|
||||||
onUpdatePassword,
|
onUpdatePassword,
|
||||||
@@ -230,6 +233,18 @@ export function ReverseShareCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
{hasAlias && onViewQrCode && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 hover:bg-background/80 rounded-sm"
|
||||||
|
onClick={() => onViewQrCode(reverseShare)}
|
||||||
|
title={t("reverseShares.card.viewQrCode")}
|
||||||
|
>
|
||||||
|
<IconQrcode className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -239,7 +254,6 @@ export function ReverseShareCard({
|
|||||||
>
|
>
|
||||||
<IconEye className="h-3 w-3" />
|
<IconEye className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -257,6 +271,11 @@ export function ReverseShareCard({
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => onViewDetails(reverseShare)}>
|
||||||
|
<IconEye className="h-4 w-4" />
|
||||||
|
{t("reverseShares.card.viewDetails")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem onClick={() => onCopyLink(reverseShare)}>
|
<DropdownMenuItem onClick={() => onCopyLink(reverseShare)}>
|
||||||
<IconCopy className="h-4 w-4" />
|
<IconCopy className="h-4 w-4" />
|
||||||
{t("reverseShares.card.copyLink")}
|
{t("reverseShares.card.copyLink")}
|
||||||
@@ -286,6 +305,13 @@ export function ReverseShareCard({
|
|||||||
{t("reverseShares.actions.viewFiles")}
|
{t("reverseShares.actions.viewFiles")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
{hasAlias && onViewQrCode && (
|
||||||
|
<DropdownMenuItem onClick={() => onViewQrCode(reverseShare)}>
|
||||||
|
<IconQrcode className="h-4 w-4" />
|
||||||
|
{t("reverseShares.actions.viewQrCode")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
<DropdownMenuItem className="text-destructive" onClick={() => onDelete(reverseShare)}>
|
<DropdownMenuItem className="text-destructive" onClick={() => onDelete(reverseShare)}>
|
||||||
<IconTrash className="h-4 w-4" />
|
<IconTrash className="h-4 w-4" />
|
||||||
{t("reverseShares.card.delete")}
|
{t("reverseShares.card.delete")}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
IconCopy,
|
IconCopy,
|
||||||
|
IconDownload,
|
||||||
IconEdit,
|
IconEdit,
|
||||||
IconLink,
|
IconLink,
|
||||||
IconLock,
|
IconLock,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
IconToggleRight,
|
IconToggleRight,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import QRCode from "react-qr-code";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -42,6 +44,7 @@ interface ReverseShareDetailsModalProps {
|
|||||||
onCopyLink?: (reverseShare: ReverseShare) => void;
|
onCopyLink?: (reverseShare: ReverseShare) => void;
|
||||||
onToggleActive?: (id: string, isActive: boolean) => Promise<void>;
|
onToggleActive?: (id: string, isActive: boolean) => Promise<void>;
|
||||||
onUpdatePassword?: (id: string, data: { hasPassword: boolean; password?: string }) => Promise<void>;
|
onUpdatePassword?: (id: string, data: { hasPassword: boolean; password?: string }) => Promise<void>;
|
||||||
|
onViewQrCode?: (reverseShare: ReverseShare) => void;
|
||||||
refreshTrigger?: number;
|
refreshTrigger?: number;
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
}
|
}
|
||||||
@@ -55,10 +58,12 @@ export function ReverseShareDetailsModal({
|
|||||||
onCopyLink,
|
onCopyLink,
|
||||||
onToggleActive,
|
onToggleActive,
|
||||||
onUpdatePassword,
|
onUpdatePassword,
|
||||||
|
onViewQrCode,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}: ReverseShareDetailsModalProps) {
|
}: ReverseShareDetailsModalProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [pendingChanges, setPendingChanges] = useState<Record<string, any>>({});
|
const [pendingChanges, setPendingChanges] = useState<Record<string, any>>({});
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
showAliasModal,
|
showAliasModal,
|
||||||
@@ -140,6 +145,7 @@ export function ReverseShareDetailsModal({
|
|||||||
isActive={reverseShare.isActive}
|
isActive={reverseShare.isActive}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{/* Informações Básicas */}
|
{/* Informações Básicas */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-base font-medium text-foreground border-b pb-2">
|
<h3 className="text-base font-medium text-foreground border-b pb-2">
|
||||||
@@ -182,6 +188,78 @@ export function ReverseShareDetailsModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* QR Code */}
|
||||||
|
{reverseShareLink && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 border-b pb-2">
|
||||||
|
<h3
|
||||||
|
className="text-base font-medium text-foreground cursor-pointer"
|
||||||
|
onClick={() => onViewQrCode && onViewQrCode(reverseShare)}
|
||||||
|
>
|
||||||
|
{t("qrCodeModal.title")}
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-5 w-5 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => {
|
||||||
|
const svg = document.getElementById("reverse-share-details-qr-code");
|
||||||
|
if (!svg) return;
|
||||||
|
|
||||||
|
setIsDownloading(true);
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
const padding = 20;
|
||||||
|
canvas.width = 200 + padding * 2;
|
||||||
|
canvas.height = 200 + padding * 2;
|
||||||
|
|
||||||
|
if (ctx) {
|
||||||
|
ctx.fillStyle = "#FFFFFF";
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
const svgData = new XMLSerializer().serializeToString(svg);
|
||||||
|
const img = new Image();
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
ctx.drawImage(img, padding, padding, 200, 200);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.download = `${reverseShare?.name?.replace(/[^a-z0-9]/gi, "-").toLowerCase() || "reverse-share"}-qr-code.png`;
|
||||||
|
link.href = canvas.toDataURL("image/png");
|
||||||
|
link.click();
|
||||||
|
setIsDownloading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = `data:image/svg+xml;base64,${btoa(svgData)}`;
|
||||||
|
} else {
|
||||||
|
setIsDownloading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isDownloading}
|
||||||
|
title={t("qrCodeModal.download")}
|
||||||
|
>
|
||||||
|
<IconDownload className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-start justify-start">
|
||||||
|
<div
|
||||||
|
className="p-2 bg-white rounded-lg cursor-pointer hover:opacity-80 transition-opacity duration-300"
|
||||||
|
onClick={() => onViewQrCode && onViewQrCode(reverseShare)}
|
||||||
|
title={t("reverseShares.actions.viewQrCode")}
|
||||||
|
>
|
||||||
|
<QRCode
|
||||||
|
id="reverse-share-details-qr-code"
|
||||||
|
value={reverseShareLink}
|
||||||
|
size={100}
|
||||||
|
level="H"
|
||||||
|
fgColor="#000000"
|
||||||
|
bgColor="#FFFFFF"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Link de Compartilhamento */}
|
{/* Link de Compartilhamento */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2 border-b pb-2">
|
<div className="flex items-center gap-2 border-b pb-2">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface ReverseSharesCardsContainerProps {
|
|||||||
onGenerateLink: (reverseShare: ReverseShare) => void;
|
onGenerateLink: (reverseShare: ReverseShare) => void;
|
||||||
onViewDetails: (reverseShare: ReverseShare) => void;
|
onViewDetails: (reverseShare: ReverseShare) => void;
|
||||||
onViewFiles: (reverseShare: ReverseShare) => void;
|
onViewFiles: (reverseShare: ReverseShare) => void;
|
||||||
|
onViewQrCode?: (reverseShare: ReverseShare) => void;
|
||||||
onCreateReverseShare: () => void;
|
onCreateReverseShare: () => void;
|
||||||
onUpdateReverseShare?: (id: string, data: any) => Promise<any>;
|
onUpdateReverseShare?: (id: string, data: any) => Promise<any>;
|
||||||
onToggleActive?: (id: string, isActive: boolean) => Promise<any>;
|
onToggleActive?: (id: string, isActive: boolean) => Promise<any>;
|
||||||
@@ -24,6 +25,7 @@ export function ReverseSharesCardsContainer({
|
|||||||
onGenerateLink,
|
onGenerateLink,
|
||||||
onViewDetails,
|
onViewDetails,
|
||||||
onViewFiles,
|
onViewFiles,
|
||||||
|
onViewQrCode,
|
||||||
onCreateReverseShare,
|
onCreateReverseShare,
|
||||||
onUpdateReverseShare,
|
onUpdateReverseShare,
|
||||||
onToggleActive,
|
onToggleActive,
|
||||||
@@ -45,6 +47,7 @@ export function ReverseSharesCardsContainer({
|
|||||||
onGenerateLink={onGenerateLink}
|
onGenerateLink={onGenerateLink}
|
||||||
onViewDetails={onViewDetails}
|
onViewDetails={onViewDetails}
|
||||||
onViewFiles={onViewFiles}
|
onViewFiles={onViewFiles}
|
||||||
|
onViewQrCode={onViewQrCode}
|
||||||
onUpdateReverseShare={onUpdateReverseShare}
|
onUpdateReverseShare={onUpdateReverseShare}
|
||||||
onToggleActive={onToggleActive}
|
onToggleActive={onToggleActive}
|
||||||
onUpdatePassword={onUpdatePassword}
|
onUpdatePassword={onUpdatePassword}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { QrCodeModal } from "@/components/modals/qr-code-modal";
|
||||||
import type { CreateReverseShareBody, UpdateReverseShareBody } from "@/http/endpoints/reverse-shares/types";
|
import type { CreateReverseShareBody, UpdateReverseShareBody } from "@/http/endpoints/reverse-shares/types";
|
||||||
import { ReverseShare } from "../hooks/use-reverse-shares";
|
import { ReverseShare } from "../hooks/use-reverse-shares";
|
||||||
import { CreateReverseShareModal } from "./create-reverse-share-modal";
|
import { CreateReverseShareModal } from "./create-reverse-share-modal";
|
||||||
@@ -20,14 +21,17 @@ interface ReverseSharesModalsProps {
|
|||||||
reverseShareToGenerateLink: ReverseShare | null;
|
reverseShareToGenerateLink: ReverseShare | null;
|
||||||
reverseShareToDelete: ReverseShare | null;
|
reverseShareToDelete: ReverseShare | null;
|
||||||
reverseShareToViewFiles: ReverseShare | null;
|
reverseShareToViewFiles: ReverseShare | null;
|
||||||
|
reverseShareToViewQrCode: ReverseShare | null;
|
||||||
isDeleting: boolean;
|
isDeleting: boolean;
|
||||||
onCloseViewDetails: () => void;
|
onCloseViewDetails: () => void;
|
||||||
onCloseGenerateLink: () => void;
|
onCloseGenerateLink: () => void;
|
||||||
onCloseDeleteModal: () => void;
|
onCloseDeleteModal: () => void;
|
||||||
onCloseViewFiles: () => void;
|
onCloseViewFiles: () => void;
|
||||||
|
onCloseViewQrCode: () => void;
|
||||||
onConfirmDelete: (reverseShare: ReverseShare) => Promise<void>;
|
onConfirmDelete: (reverseShare: ReverseShare) => Promise<void>;
|
||||||
onCreateAlias: (reverseShareId: string, alias: string) => Promise<void>;
|
onCreateAlias: (reverseShareId: string, alias: string) => Promise<void>;
|
||||||
onCopyLink: (reverseShare: ReverseShare) => void;
|
onCopyLink: (reverseShare: ReverseShare) => void;
|
||||||
|
onViewQrCode: (reverseShare: ReverseShare) => void;
|
||||||
onUpdateReverseShareData?: (id: string, data: any) => Promise<any>;
|
onUpdateReverseShareData?: (id: string, data: any) => Promise<any>;
|
||||||
onUpdatePassword?: (id: string, data: { hasPassword: boolean; password?: string }) => Promise<any>;
|
onUpdatePassword?: (id: string, data: { hasPassword: boolean; password?: string }) => Promise<any>;
|
||||||
onToggleActive?: (id: string, isActive: boolean) => Promise<any>;
|
onToggleActive?: (id: string, isActive: boolean) => Promise<any>;
|
||||||
@@ -48,14 +52,17 @@ export function ReverseSharesModals({
|
|||||||
reverseShareToGenerateLink,
|
reverseShareToGenerateLink,
|
||||||
reverseShareToDelete,
|
reverseShareToDelete,
|
||||||
reverseShareToViewFiles,
|
reverseShareToViewFiles,
|
||||||
|
reverseShareToViewQrCode,
|
||||||
isDeleting,
|
isDeleting,
|
||||||
onCloseViewDetails,
|
onCloseViewDetails,
|
||||||
onCloseGenerateLink,
|
onCloseGenerateLink,
|
||||||
onCloseDeleteModal,
|
onCloseDeleteModal,
|
||||||
onCloseViewFiles,
|
onCloseViewFiles,
|
||||||
|
onCloseViewQrCode,
|
||||||
onConfirmDelete,
|
onConfirmDelete,
|
||||||
onCreateAlias,
|
onCreateAlias,
|
||||||
onCopyLink,
|
onCopyLink,
|
||||||
|
onViewQrCode,
|
||||||
onUpdateReverseShareData,
|
onUpdateReverseShareData,
|
||||||
onUpdatePassword,
|
onUpdatePassword,
|
||||||
onToggleActive,
|
onToggleActive,
|
||||||
@@ -103,6 +110,7 @@ export function ReverseSharesModals({
|
|||||||
onCopyLink={onCopyLink}
|
onCopyLink={onCopyLink}
|
||||||
onUpdatePassword={onUpdatePassword}
|
onUpdatePassword={onUpdatePassword}
|
||||||
onToggleActive={onToggleActive}
|
onToggleActive={onToggleActive}
|
||||||
|
onViewQrCode={onViewQrCode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ReceivedFilesModal
|
<ReceivedFilesModal
|
||||||
@@ -112,6 +120,17 @@ export function ReverseSharesModals({
|
|||||||
onRefresh={onRefreshData}
|
onRefresh={onRefreshData}
|
||||||
refreshReverseShare={refreshReverseShare}
|
refreshReverseShare={refreshReverseShare}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<QrCodeModal
|
||||||
|
isOpen={!!reverseShareToViewQrCode}
|
||||||
|
onClose={onCloseViewQrCode}
|
||||||
|
shareLink={
|
||||||
|
reverseShareToViewQrCode?.alias?.alias
|
||||||
|
? `${typeof window !== "undefined" ? window.location.origin : ""}/r/${reverseShareToViewQrCode.alias.alias}`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
shareName={reverseShareToViewQrCode?.name || "Reverse Share"}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export function useReverseShares() {
|
|||||||
const [reverseShareToDelete, setReverseShareToDelete] = useState<ReverseShare | null>(null);
|
const [reverseShareToDelete, setReverseShareToDelete] = useState<ReverseShare | null>(null);
|
||||||
const [reverseShareToEdit, setReverseShareToEdit] = useState<ReverseShare | null>(null);
|
const [reverseShareToEdit, setReverseShareToEdit] = useState<ReverseShare | null>(null);
|
||||||
const [reverseShareToViewFiles, setReverseShareToViewFiles] = useState<ReverseShare | null>(null);
|
const [reverseShareToViewFiles, setReverseShareToViewFiles] = useState<ReverseShare | null>(null);
|
||||||
|
const [reverseShareToViewQrCode, setReverseShareToViewQrCode] = useState<ReverseShare | null>(null);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
@@ -277,6 +278,7 @@ export function useReverseShares() {
|
|||||||
reverseShareToDelete,
|
reverseShareToDelete,
|
||||||
reverseShareToEdit,
|
reverseShareToEdit,
|
||||||
reverseShareToViewFiles,
|
reverseShareToViewFiles,
|
||||||
|
reverseShareToViewQrCode,
|
||||||
isDeleting,
|
isDeleting,
|
||||||
isCreateModalOpen,
|
isCreateModalOpen,
|
||||||
isCreating,
|
isCreating,
|
||||||
@@ -288,6 +290,7 @@ export function useReverseShares() {
|
|||||||
setReverseShareToDelete,
|
setReverseShareToDelete,
|
||||||
setReverseShareToEdit,
|
setReverseShareToEdit,
|
||||||
setReverseShareToViewFiles,
|
setReverseShareToViewFiles,
|
||||||
|
setReverseShareToViewQrCode,
|
||||||
setIsCreateModalOpen,
|
setIsCreateModalOpen,
|
||||||
handleCopyLink,
|
handleCopyLink,
|
||||||
handleDeleteReverseShare,
|
handleDeleteReverseShare,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export default function ReverseSharesPage() {
|
|||||||
reverseShareToDelete,
|
reverseShareToDelete,
|
||||||
reverseShareToEdit,
|
reverseShareToEdit,
|
||||||
reverseShareToViewFiles,
|
reverseShareToViewFiles,
|
||||||
|
reverseShareToViewQrCode,
|
||||||
isDeleting,
|
isDeleting,
|
||||||
isCreateModalOpen,
|
isCreateModalOpen,
|
||||||
isCreating,
|
isCreating,
|
||||||
@@ -37,6 +38,7 @@ export default function ReverseSharesPage() {
|
|||||||
setReverseShareToDelete,
|
setReverseShareToDelete,
|
||||||
setReverseShareToEdit,
|
setReverseShareToEdit,
|
||||||
setReverseShareToViewFiles,
|
setReverseShareToViewFiles,
|
||||||
|
setReverseShareToViewQrCode,
|
||||||
handleCreateAlias,
|
handleCreateAlias,
|
||||||
handleUpdatePassword,
|
handleUpdatePassword,
|
||||||
handleUpdateReverseShareData,
|
handleUpdateReverseShareData,
|
||||||
@@ -77,6 +79,7 @@ export default function ReverseSharesPage() {
|
|||||||
onGenerateLink={setReverseShareToGenerateLink}
|
onGenerateLink={setReverseShareToGenerateLink}
|
||||||
onViewDetails={setReverseShareToViewDetails}
|
onViewDetails={setReverseShareToViewDetails}
|
||||||
onViewFiles={setReverseShareToViewFiles}
|
onViewFiles={setReverseShareToViewFiles}
|
||||||
|
onViewQrCode={setReverseShareToViewQrCode}
|
||||||
onCreateReverseShare={() => setIsCreateModalOpen(true)}
|
onCreateReverseShare={() => setIsCreateModalOpen(true)}
|
||||||
onUpdateReverseShare={handleUpdateReverseShareData}
|
onUpdateReverseShare={handleUpdateReverseShareData}
|
||||||
onToggleActive={handleToggleActive}
|
onToggleActive={handleToggleActive}
|
||||||
@@ -99,14 +102,17 @@ export default function ReverseSharesPage() {
|
|||||||
reverseShareToViewDetails={reverseShareToViewDetails}
|
reverseShareToViewDetails={reverseShareToViewDetails}
|
||||||
reverseShareToDelete={reverseShareToDelete}
|
reverseShareToDelete={reverseShareToDelete}
|
||||||
reverseShareToViewFiles={reverseShareToViewFiles}
|
reverseShareToViewFiles={reverseShareToViewFiles}
|
||||||
|
reverseShareToViewQrCode={reverseShareToViewQrCode}
|
||||||
isDeleting={isDeleting}
|
isDeleting={isDeleting}
|
||||||
onCloseGenerateLink={() => setReverseShareToGenerateLink(null)}
|
onCloseGenerateLink={() => setReverseShareToGenerateLink(null)}
|
||||||
onCloseViewDetails={() => setReverseShareToViewDetails(null)}
|
onCloseViewDetails={() => setReverseShareToViewDetails(null)}
|
||||||
onCloseDeleteModal={() => setReverseShareToDelete(null)}
|
onCloseDeleteModal={() => setReverseShareToDelete(null)}
|
||||||
onCloseViewFiles={() => setReverseShareToViewFiles(null)}
|
onCloseViewFiles={() => setReverseShareToViewFiles(null)}
|
||||||
|
onCloseViewQrCode={() => setReverseShareToViewQrCode(null)}
|
||||||
onConfirmDelete={handleDeleteReverseShare}
|
onConfirmDelete={handleDeleteReverseShare}
|
||||||
onCreateAlias={handleCreateAlias}
|
onCreateAlias={handleCreateAlias}
|
||||||
onCopyLink={handleCopyLink}
|
onCopyLink={handleCopyLink}
|
||||||
|
onViewQrCode={setReverseShareToViewQrCode}
|
||||||
onUpdateReverseShareData={handleUpdateReverseShareData}
|
onUpdateReverseShareData={handleUpdateReverseShareData}
|
||||||
onUpdatePassword={handleUpdatePassword}
|
onUpdatePassword={handleUpdatePassword}
|
||||||
onToggleActive={handleToggleActive}
|
onToggleActive={handleToggleActive}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useTranslations } from "next-intl";
|
|||||||
import { CreateShareModal } from "@/components/modals/create-share-modal";
|
import { CreateShareModal } from "@/components/modals/create-share-modal";
|
||||||
import { DeleteConfirmationModal } from "@/components/modals/delete-confirmation-modal";
|
import { DeleteConfirmationModal } from "@/components/modals/delete-confirmation-modal";
|
||||||
import { GenerateShareLinkModal } from "@/components/modals/generate-share-link-modal";
|
import { GenerateShareLinkModal } from "@/components/modals/generate-share-link-modal";
|
||||||
|
import { QrCodeModal } from "@/components/modals/qr-code-modal";
|
||||||
import { ShareActionsModals } from "@/components/modals/share-actions-modals";
|
import { ShareActionsModals } from "@/components/modals/share-actions-modals";
|
||||||
import { ShareDetailsModal } from "@/components/modals/share-details-modal";
|
import { ShareDetailsModal } from "@/components/modals/share-details-modal";
|
||||||
import { ShareExpirationModal } from "@/components/modals/share-expiration-modal";
|
import { ShareExpirationModal } from "@/components/modals/share-expiration-modal";
|
||||||
@@ -30,6 +31,11 @@ export function SharesModals({
|
|||||||
onSuccess();
|
onSuccess();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getShareLink = (share: any) => {
|
||||||
|
if (!share?.alias?.alias) return "";
|
||||||
|
return `${window.location.origin}/s/${share.alias.alias}`;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CreateShareModal isOpen={isCreateModalOpen} onClose={onCloseCreateModal} onSuccess={handleShareSuccess} />
|
<CreateShareModal isOpen={isCreateModalOpen} onClose={onCloseCreateModal} onSuccess={handleShareSuccess} />
|
||||||
@@ -51,6 +57,13 @@ export function SharesModals({
|
|||||||
onEditFile={fileManager.handleRename}
|
onEditFile={fileManager.handleRename}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<QrCodeModal
|
||||||
|
isOpen={!!shareManager.shareToViewQrCode}
|
||||||
|
onClose={() => shareManager.setShareToViewQrCode(null)}
|
||||||
|
shareLink={getShareLink(shareManager.shareToViewQrCode)}
|
||||||
|
shareName={shareManager.shareToViewQrCode?.name || "Share"}
|
||||||
|
/>
|
||||||
|
|
||||||
<DeleteConfirmationModal
|
<DeleteConfirmationModal
|
||||||
isOpen={!!shareManager.sharesToDelete}
|
isOpen={!!shareManager.sharesToDelete}
|
||||||
onClose={() => shareManager.setSharesToDelete(null)}
|
onClose={() => shareManager.setSharesToDelete(null)}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export function SharesTableContainer({ shares, onCopyLink, onCreateShare, shareM
|
|||||||
onManageFiles={shareManager.setShareToManageFiles}
|
onManageFiles={shareManager.setShareToManageFiles}
|
||||||
onManageRecipients={shareManager.setShareToManageRecipients}
|
onManageRecipients={shareManager.setShareToManageRecipients}
|
||||||
onNotifyRecipients={shareManager.handleNotifyRecipients}
|
onNotifyRecipients={shareManager.handleNotifyRecipients}
|
||||||
|
onViewQrCode={shareManager.setShareToViewQrCode}
|
||||||
onViewDetails={shareManager.setShareToViewDetails}
|
onViewDetails={shareManager.setShareToViewDetails}
|
||||||
setClearSelectionCallback={shareManager.setClearSelectionCallback}
|
setClearSelectionCallback={shareManager.setClearSelectionCallback}
|
||||||
/>
|
/>
|
||||||
|
|||||||
31
apps/web/src/app/api/(proxy)/app/system-info/route.ts
Normal file
31
apps/web/src/app/api/(proxy)/app/system-info/route.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const cookieHeader = req.headers.get("cookie");
|
||||||
|
const url = `${API_BASE_URL}/app/system-info`;
|
||||||
|
|
||||||
|
const apiRes = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
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;
|
||||||
|
}
|
||||||
32
apps/web/src/app/api/(proxy)/auth/config/route.ts
Normal file
32
apps/web/src/app/api/(proxy)/auth/config/route.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const url = `${API_BASE_URL}/auth/config`;
|
||||||
|
|
||||||
|
const apiRes = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
redirect: "manual",
|
||||||
|
});
|
||||||
|
|
||||||
|
const resBody = await apiRes.text();
|
||||||
|
const res = new NextResponse(resBody, {
|
||||||
|
status: apiRes.status,
|
||||||
|
statusText: apiRes.statusText,
|
||||||
|
});
|
||||||
|
|
||||||
|
apiRes.headers.forEach((value, key) => {
|
||||||
|
res.headers.set(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
return res;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error proxying auth config request:", error);
|
||||||
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,7 @@ export function RecentShares({ shares, shareManager, onOpenCreateModal, onCopyLi
|
|||||||
onManageFiles={shareManager.setShareToManageFiles}
|
onManageFiles={shareManager.setShareToManageFiles}
|
||||||
onManageRecipients={shareManager.setShareToManageRecipients}
|
onManageRecipients={shareManager.setShareToManageRecipients}
|
||||||
onNotifyRecipients={shareManager.handleNotifyRecipients}
|
onNotifyRecipients={shareManager.handleNotifyRecipients}
|
||||||
|
onViewQrCode={shareManager.setShareToViewQrCode}
|
||||||
onViewDetails={shareManager.setShareToViewDetails}
|
onViewDetails={shareManager.setShareToViewDetails}
|
||||||
setClearSelectionCallback={shareManager.setClearSelectionCallback}
|
setClearSelectionCallback={shareManager.setClearSelectionCallback}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { DeleteConfirmationModal } from "@/components/modals/delete-confirmation
|
|||||||
import { FileActionsModals } from "@/components/modals/file-actions-modals";
|
import { FileActionsModals } from "@/components/modals/file-actions-modals";
|
||||||
import { FilePreviewModal } from "@/components/modals/file-preview-modal";
|
import { FilePreviewModal } from "@/components/modals/file-preview-modal";
|
||||||
import { GenerateShareLinkModal } from "@/components/modals/generate-share-link-modal";
|
import { GenerateShareLinkModal } from "@/components/modals/generate-share-link-modal";
|
||||||
|
import { QrCodeModal } from "@/components/modals/qr-code-modal";
|
||||||
import { ShareActionsModals } from "@/components/modals/share-actions-modals";
|
import { ShareActionsModals } from "@/components/modals/share-actions-modals";
|
||||||
import { ShareDetailsModal } from "@/components/modals/share-details-modal";
|
import { ShareDetailsModal } from "@/components/modals/share-details-modal";
|
||||||
import { ShareExpirationModal } from "@/components/modals/share-expiration-modal";
|
import { ShareExpirationModal } from "@/components/modals/share-expiration-modal";
|
||||||
@@ -25,6 +26,11 @@ export function DashboardModals({ modals, fileManager, shareManager, onSuccess }
|
|||||||
onSuccess();
|
onSuccess();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getShareLink = (share: any) => {
|
||||||
|
if (!share?.alias?.alias) return "";
|
||||||
|
return `${window.location.origin}/s/${share.alias.alias}`;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<UploadFileModal isOpen={modals.isUploadModalOpen} onClose={modals.onCloseUploadModal} onSuccess={onSuccess} />
|
<UploadFileModal isOpen={modals.isUploadModalOpen} onClose={modals.onCloseUploadModal} onSuccess={onSuccess} />
|
||||||
@@ -144,6 +150,13 @@ export function DashboardModals({ modals, fileManager, shareManager, onSuccess }
|
|||||||
onGenerate={shareManager.handleGenerateLink}
|
onGenerate={shareManager.handleGenerateLink}
|
||||||
onSuccess={onSuccess}
|
onSuccess={onSuccess}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<QrCodeModal
|
||||||
|
isOpen={!!shareManager.shareToViewQrCode}
|
||||||
|
onClose={() => shareManager.setShareToViewQrCode(null)}
|
||||||
|
shareLink={getShareLink(shareManager.shareToViewQrCode)}
|
||||||
|
shareName={shareManager.shareToViewQrCode?.name || "Share"}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@@ -8,7 +9,7 @@ import { useForm } from "react-hook-form";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { requestPasswordReset } from "@/http/endpoints";
|
import { getAuthConfig, requestPasswordReset } from "@/http/endpoints";
|
||||||
|
|
||||||
export type ForgotPasswordFormData = {
|
export type ForgotPasswordFormData = {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -17,16 +18,39 @@ export type ForgotPasswordFormData = {
|
|||||||
export function useForgotPassword() {
|
export function useForgotPassword() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [passwordAuthEnabled, setPasswordAuthEnabled] = useState(true);
|
||||||
|
const [authConfigLoading, setAuthConfigLoading] = useState(true);
|
||||||
|
|
||||||
const forgotPasswordSchema = z.object({
|
const forgotPasswordSchema = z.object({
|
||||||
email: z.string().email(t("validation.invalidEmail")),
|
email: z.string().email(t("validation.invalidEmail")),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchAuthConfig = async () => {
|
||||||
|
try {
|
||||||
|
const response = await getAuthConfig();
|
||||||
|
setPasswordAuthEnabled((response as any).data.passwordAuthEnabled);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch auth config:", error);
|
||||||
|
setPasswordAuthEnabled(true);
|
||||||
|
} finally {
|
||||||
|
setAuthConfigLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchAuthConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const form = useForm<ForgotPasswordFormData>({
|
const form = useForm<ForgotPasswordFormData>({
|
||||||
resolver: zodResolver(forgotPasswordSchema),
|
resolver: zodResolver(forgotPasswordSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = async (data: ForgotPasswordFormData) => {
|
const onSubmit = async (data: ForgotPasswordFormData) => {
|
||||||
|
if (!passwordAuthEnabled) {
|
||||||
|
toast.error(t("errors.passwordAuthDisabled"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await requestPasswordReset({
|
await requestPasswordReset({
|
||||||
email: data.email,
|
email: data.email,
|
||||||
@@ -46,5 +70,7 @@ export function useForgotPassword() {
|
|||||||
return {
|
return {
|
||||||
form,
|
form,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
passwordAuthEnabled,
|
||||||
|
authConfigLoading,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { DefaultFooter } from "@/components/ui/default-footer";
|
import { DefaultFooter } from "@/components/ui/default-footer";
|
||||||
import { StaticBackgroundLights } from "../login/components/static-background-lights";
|
import { StaticBackgroundLights } from "../login/components/static-background-lights";
|
||||||
@@ -10,6 +12,7 @@ import { useForgotPassword } from "./hooks/use-forgot-password";
|
|||||||
|
|
||||||
export default function ForgotPasswordPage() {
|
export default function ForgotPasswordPage() {
|
||||||
const forgotPassword = useForgotPassword();
|
const forgotPassword = useForgotPassword();
|
||||||
|
const t = useTranslations("ForgotPassword");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-screen flex-col">
|
<div className="relative flex min-h-screen flex-col">
|
||||||
@@ -22,7 +25,24 @@ export default function ForgotPasswordPage() {
|
|||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
>
|
>
|
||||||
<ForgotPasswordHeader />
|
<ForgotPasswordHeader />
|
||||||
|
{forgotPassword.authConfigLoading ? (
|
||||||
|
<div className="flex justify-center items-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
) : !forgotPassword.passwordAuthEnabled ? (
|
||||||
|
<div className="mt-8 space-y-4">
|
||||||
|
<div className="text-center p-4 bg-muted/50 rounded-lg">
|
||||||
|
<p className="text-muted-foreground">{t("forgotPassword.passwordAuthDisabled")}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<Link className="text-muted-foreground hover:text-primary text-sm" href="/login">
|
||||||
|
{t("forgotPassword.backToLogin")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<ForgotPasswordForm form={forgotPassword.form} onSubmit={forgotPassword.onSubmit} />
|
<ForgotPasswordForm form={forgotPassword.form} onSubmit={forgotPassword.onSubmit} />
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getLocale } from "next-intl/server";
|
|||||||
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
|
import { RedirectHandler } from "@/components/auth/redirect-handler";
|
||||||
import { Favicon } from "@/components/layout/favicon";
|
import { Favicon } from "@/components/layout/favicon";
|
||||||
import { DynamicToaster } from "@/components/ui/dynamic-toaster";
|
import { DynamicToaster } from "@/components/ui/dynamic-toaster";
|
||||||
import { useAppInfo } from "@/contexts/app-info-context";
|
import { useAppInfo } from "@/contexts/app-info-context";
|
||||||
@@ -39,7 +40,9 @@ export default async function RootLayout({
|
|||||||
<NextIntlClientProvider>
|
<NextIntlClientProvider>
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<RedirectHandler>
|
||||||
<ShareProvider>{children}</ShareProvider>
|
<ShareProvider>{children}</ShareProvider>
|
||||||
|
</RedirectHandler>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
<DynamicToaster />
|
<DynamicToaster />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
@@ -6,6 +7,7 @@ import { useForm } from "react-hook-form";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { getEnabledProviders } from "@/http/endpoints";
|
||||||
import { createLoginSchema, type LoginFormValues } from "../schemas/schema";
|
import { createLoginSchema, type LoginFormValues } from "../schemas/schema";
|
||||||
import { MultiProviderButtons } from "./multi-provider-buttons";
|
import { MultiProviderButtons } from "./multi-provider-buttons";
|
||||||
import { PasswordVisibilityToggle } from "./password-visibility-toggle";
|
import { PasswordVisibilityToggle } from "./password-visibility-toggle";
|
||||||
@@ -15,21 +17,50 @@ interface LoginFormProps {
|
|||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
onToggleVisibility: () => void;
|
onToggleVisibility: () => void;
|
||||||
onSubmit: (data: LoginFormValues) => Promise<void>;
|
onSubmit: (data: LoginFormValues) => Promise<void>;
|
||||||
|
passwordAuthEnabled: boolean;
|
||||||
|
authConfigLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LoginForm({ error, isVisible, onToggleVisibility, onSubmit }: LoginFormProps) {
|
export function LoginForm({
|
||||||
|
error,
|
||||||
|
isVisible,
|
||||||
|
onToggleVisibility,
|
||||||
|
onSubmit,
|
||||||
|
passwordAuthEnabled,
|
||||||
|
authConfigLoading,
|
||||||
|
}: LoginFormProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const loginSchema = createLoginSchema(t);
|
const [hasEnabledProviders, setHasEnabledProviders] = useState(false);
|
||||||
|
const [providersLoading, setProvidersLoading] = useState(true);
|
||||||
|
|
||||||
|
const loginSchema = createLoginSchema(t, passwordAuthEnabled);
|
||||||
const form = useForm<LoginFormValues>({
|
const form = useForm<LoginFormValues>({
|
||||||
resolver: zodResolver(loginSchema),
|
resolver: zodResolver(loginSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
emailOrUsername: "",
|
emailOrUsername: "",
|
||||||
password: "",
|
password: passwordAuthEnabled ? "" : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const isSubmitting = form.formState.isSubmitting;
|
const isSubmitting = form.formState.isSubmitting;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkProviders = async () => {
|
||||||
|
try {
|
||||||
|
const response = await getEnabledProviders();
|
||||||
|
const data = response.data as any;
|
||||||
|
setHasEnabledProviders(data.success && data.data && data.data.length > 0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error checking providers:", error);
|
||||||
|
setHasEnabledProviders(false);
|
||||||
|
} finally {
|
||||||
|
setProvidersLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkProviders();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const renderErrorMessage = () =>
|
const renderErrorMessage = () =>
|
||||||
error && (
|
error && (
|
||||||
<p className="text-destructive text-sm text-center bg-destructive/10 p-2 rounded-md">
|
<p className="text-destructive text-sm text-center bg-destructive/10 p-2 rounded-md">
|
||||||
@@ -84,13 +115,41 @@ export function LoginForm({ error, isVisible, onToggleVisibility, onSubmit }: Lo
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (authConfigLoading || providersLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!passwordAuthEnabled && hasEnabledProviders) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{renderErrorMessage()}
|
||||||
|
<MultiProviderButtons showSeparator={false} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!passwordAuthEnabled && !hasEnabledProviders) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{renderErrorMessage()}
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-destructive text-sm">{t("login.noAuthMethodsAvailable")}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{renderErrorMessage()}
|
{renderErrorMessage()}
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
|
||||||
{renderEmailOrUsernameField()}
|
{renderEmailOrUsernameField()}
|
||||||
{renderPasswordField()}
|
{passwordAuthEnabled && renderPasswordField()}
|
||||||
<Button className="w-full mt-4 cursor-pointer" variant="default" size="lg" type="submit">
|
<Button className="w-full mt-4 cursor-pointer" variant="default" size="lg" type="submit">
|
||||||
{isSubmitting ? t("login.signingIn") : t("login.signIn")}
|
{isSubmitting ? t("login.signingIn") : t("login.signIn")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -99,11 +158,13 @@ export function LoginForm({ error, isVisible, onToggleVisibility, onSubmit }: Lo
|
|||||||
|
|
||||||
<MultiProviderButtons />
|
<MultiProviderButtons />
|
||||||
|
|
||||||
|
{passwordAuthEnabled && (
|
||||||
<div className="flex w-full items-center justify-center px-1 mt-2">
|
<div className="flex w-full items-center justify-center px-1 mt-2">
|
||||||
<Link className="text-muted-foreground hover:text-primary text-sm" href="/forgot-password">
|
<Link className="text-muted-foreground hover:text-primary text-sm" href="/forgot-password">
|
||||||
{t("login.forgotPassword")}
|
{t("login.forgotPassword")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import { useAppInfo } from "@/contexts/app-info-context";
|
|||||||
import { getEnabledProviders } from "@/http/endpoints";
|
import { getEnabledProviders } from "@/http/endpoints";
|
||||||
import type { EnabledAuthProvider } from "@/http/endpoints/auth/types";
|
import type { EnabledAuthProvider } from "@/http/endpoints/auth/types";
|
||||||
|
|
||||||
export function MultiProviderButtons() {
|
interface MultiProviderButtonsProps {
|
||||||
|
showSeparator?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MultiProviderButtons({ showSeparator = true }: MultiProviderButtonsProps) {
|
||||||
const [providers, setProviders] = useState<EnabledAuthProvider[]>([]);
|
const [providers, setProviders] = useState<EnabledAuthProvider[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const { firstAccess } = useAppInfo();
|
const { firstAccess } = useAppInfo();
|
||||||
@@ -67,6 +71,7 @@ export function MultiProviderButtons() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
{showSeparator && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute inset-0 flex items-center">
|
<div className="absolute inset-0 flex items-center">
|
||||||
<span className="w-full border-t" />
|
<span className="w-full border-t" />
|
||||||
@@ -75,6 +80,7 @@ export function MultiProviderButtons() {
|
|||||||
<span className="bg-background px-2 text-muted-foreground">Or continue with</span>
|
<span className="bg-background px-2 text-muted-foreground">Or continue with</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{providers.map((provider) => (
|
{providers.map((provider) => (
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { toast } from "sonner";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
import { getCurrentUser, login } from "@/http/endpoints";
|
import { getAuthConfig, getCurrentUser, login } from "@/http/endpoints";
|
||||||
import { completeTwoFactorLogin } from "@/http/endpoints/auth/two-factor";
|
import { completeTwoFactorLogin } from "@/http/endpoints/auth/two-factor";
|
||||||
import type { LoginResponse } from "@/http/endpoints/auth/two-factor/types";
|
import type { LoginResponse } from "@/http/endpoints/auth/two-factor/types";
|
||||||
import { LoginFormValues } from "../schemas/schema";
|
import { LoginFormValues } from "../schemas/schema";
|
||||||
@@ -31,6 +31,14 @@ export function useLogin() {
|
|||||||
const [twoFactorUserId, setTwoFactorUserId] = useState<string | null>(null);
|
const [twoFactorUserId, setTwoFactorUserId] = useState<string | null>(null);
|
||||||
const [twoFactorCode, setTwoFactorCode] = useState("");
|
const [twoFactorCode, setTwoFactorCode] = useState("");
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [passwordAuthEnabled, setPasswordAuthEnabled] = useState(true);
|
||||||
|
const [authConfigLoading, setAuthConfigLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated === true) {
|
||||||
|
router.replace("/dashboard");
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const errorParam = searchParams.get("error");
|
const errorParam = searchParams.get("error");
|
||||||
@@ -60,6 +68,22 @@ export function useLogin() {
|
|||||||
}
|
}
|
||||||
}, [searchParams, t]);
|
}, [searchParams, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchAuthConfig = async () => {
|
||||||
|
try {
|
||||||
|
const response = await getAuthConfig();
|
||||||
|
setPasswordAuthEnabled((response as any).data.passwordAuthEnabled);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch auth config:", error);
|
||||||
|
setPasswordAuthEnabled(true);
|
||||||
|
} finally {
|
||||||
|
setAuthConfigLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchAuthConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const toggleVisibility = () => setIsVisible(!isVisible);
|
const toggleVisibility = () => setIsVisible(!isVisible);
|
||||||
|
|
||||||
const onSubmit = async (data: LoginFormValues) => {
|
const onSubmit = async (data: LoginFormValues) => {
|
||||||
@@ -67,7 +91,12 @@ export function useLogin() {
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await login(data);
|
if (!passwordAuthEnabled) {
|
||||||
|
setError(t("errors.passwordAuthDisabled"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await login(data as any);
|
||||||
const loginData = response.data as LoginResponse;
|
const loginData = response.data as LoginResponse;
|
||||||
|
|
||||||
if (loginData.requiresTwoFactor && loginData.userId) {
|
if (loginData.requiresTwoFactor && loginData.userId) {
|
||||||
@@ -77,7 +106,6 @@ export function useLogin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (loginData.user) {
|
if (loginData.user) {
|
||||||
// Após login bem-sucedido, buscar dados completos do usuário incluindo a imagem
|
|
||||||
try {
|
try {
|
||||||
const userResponse = await getCurrentUser();
|
const userResponse = await getCurrentUser();
|
||||||
if (userResponse?.data?.user) {
|
if (userResponse?.data?.user) {
|
||||||
@@ -92,7 +120,6 @@ export function useLogin() {
|
|||||||
console.warn("Failed to fetch complete user data, using login data:", userErr);
|
console.warn("Failed to fetch complete user data, using login data:", userErr);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback para dados do login se falhar ao buscar dados completos
|
|
||||||
const { isAdmin, ...userData } = loginData.user;
|
const { isAdmin, ...userData } = loginData.user;
|
||||||
setUser({ ...userData, image: null });
|
setUser({ ...userData, image: null });
|
||||||
setIsAdmin(isAdmin);
|
setIsAdmin(isAdmin);
|
||||||
@@ -129,7 +156,6 @@ export function useLogin() {
|
|||||||
rememberDevice: rememberDevice,
|
rememberDevice: rememberDevice,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Após two-factor login bem-sucedido, buscar dados completos do usuário incluindo a imagem
|
|
||||||
try {
|
try {
|
||||||
const userResponse = await getCurrentUser();
|
const userResponse = await getCurrentUser();
|
||||||
if (userResponse?.data?.user) {
|
if (userResponse?.data?.user) {
|
||||||
@@ -144,7 +170,6 @@ export function useLogin() {
|
|||||||
console.warn("Failed to fetch complete user data after 2FA, using response data:", userErr);
|
console.warn("Failed to fetch complete user data after 2FA, using response data:", userErr);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback para dados da resposta se falhar ao buscar dados completos
|
|
||||||
const { isAdmin, ...userData } = response.data.user;
|
const { isAdmin, ...userData } = response.data.user;
|
||||||
setUser({ ...userData, image: userData.image ?? null });
|
setUser({ ...userData, image: userData.image ?? null });
|
||||||
setIsAdmin(isAdmin);
|
setIsAdmin(isAdmin);
|
||||||
@@ -172,5 +197,7 @@ export function useLogin() {
|
|||||||
setTwoFactorCode,
|
setTwoFactorCode,
|
||||||
onTwoFactorSubmit,
|
onTwoFactorSubmit,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
|
passwordAuthEnabled,
|
||||||
|
authConfigLoading,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default function LoginPage() {
|
|||||||
const login = useLogin();
|
const login = useLogin();
|
||||||
const { firstAccess } = useAppInfo();
|
const { firstAccess } = useAppInfo();
|
||||||
|
|
||||||
if (login.isAuthenticated === null) {
|
if (login.isAuthenticated === null || login.isAuthenticated === true) {
|
||||||
return <LoadingScreen />;
|
return <LoadingScreen />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +53,8 @@ export default function LoginPage() {
|
|||||||
isVisible={login.isVisible}
|
isVisible={login.isVisible}
|
||||||
onSubmit={login.onSubmit}
|
onSubmit={login.onSubmit}
|
||||||
onToggleVisibility={login.toggleVisibility}
|
onToggleVisibility={login.toggleVisibility}
|
||||||
|
passwordAuthEnabled={login.passwordAuthEnabled}
|
||||||
|
authConfigLoading={login.authConfigLoading}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import * as z from "zod";
|
|||||||
|
|
||||||
type TFunction = ReturnType<typeof useTranslations>;
|
type TFunction = ReturnType<typeof useTranslations>;
|
||||||
|
|
||||||
export const createLoginSchema = (t: TFunction) =>
|
export const createLoginSchema = (t: TFunction, passwordAuthEnabled: boolean = true) =>
|
||||||
z.object({
|
z.object({
|
||||||
emailOrUsername: z.string().min(1, t("validation.emailOrUsernameRequired")),
|
emailOrUsername: z.string().min(1, t("validation.emailOrUsernameRequired")),
|
||||||
password: z.string().min(1, t("validation.passwordRequired")),
|
password: passwordAuthEnabled ? z.string().min(1, t("validation.passwordRequired")) : z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type LoginFormValues = z.infer<ReturnType<typeof createLoginSchema>>;
|
export type LoginFormValues = z.infer<ReturnType<typeof createLoginSchema>>;
|
||||||
|
|||||||
@@ -172,9 +172,20 @@ export function useSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await refreshAppInfo();
|
await refreshAppInfo();
|
||||||
} catch {
|
} catch (error: any) {
|
||||||
|
const errorMessage = error?.response?.data?.error || error?.message || "";
|
||||||
|
|
||||||
|
if (
|
||||||
|
errorMessage.includes("autenticação por senha") ||
|
||||||
|
errorMessage.includes("provedor de autenticação ativo") ||
|
||||||
|
errorMessage.includes("password authentication") ||
|
||||||
|
errorMessage.includes("authentication provider")
|
||||||
|
) {
|
||||||
|
toast.error(t("settings.errors.passwordAuthRequiresProvider"));
|
||||||
|
} else {
|
||||||
toast.error(t("settings.errors.updateFailed"));
|
toast.error(t("settings.errors.updateFailed"));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleCollapse = (group: string) => {
|
const toggleCollapse = (group: string) => {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export function UsersHeader({ onCreateUser }: UsersHeaderProps) {
|
|||||||
<h1 className="text-2xl font-bold">{t("users.header.title")}</h1>
|
<h1 className="text-2xl font-bold">{t("users.header.title")}</h1>
|
||||||
</div>
|
</div>
|
||||||
<Button className="font-semibold" onClick={onCreateUser}>
|
<Button className="font-semibold" onClick={onCreateUser}>
|
||||||
<IconUserPlus size={18} className="mr-2" />
|
<IconUserPlus size={18} />
|
||||||
{t("users.header.addUser")}
|
{t("users.header.addUser")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, type ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
@@ -14,16 +14,21 @@ type ProtectedRouteProps = {
|
|||||||
export function ProtectedRoute({ children, requireAdmin = false }: ProtectedRouteProps) {
|
export function ProtectedRoute({ children, requireAdmin = false }: ProtectedRouteProps) {
|
||||||
const { isAuthenticated, isAdmin } = useAuth();
|
const { isAuthenticated, isAdmin } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [hasCheckedAuth, setHasCheckedAuth] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isAuthenticated !== null) {
|
||||||
|
setHasCheckedAuth(true);
|
||||||
|
|
||||||
if (isAuthenticated === false) {
|
if (isAuthenticated === false) {
|
||||||
router.replace("/login");
|
router.replace("/login");
|
||||||
} else if (requireAdmin && isAdmin === false) {
|
} else if (requireAdmin && isAdmin === false) {
|
||||||
router.replace("/dashboard");
|
router.replace("/dashboard");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, [isAuthenticated, isAdmin, requireAdmin, router]);
|
}, [isAuthenticated, isAdmin, requireAdmin, router]);
|
||||||
|
|
||||||
if (isAuthenticated === null || (requireAdmin && isAdmin === null)) {
|
if (!hasCheckedAuth || isAuthenticated === null || (requireAdmin && isAdmin === null)) {
|
||||||
return <LoadingScreen />;
|
return <LoadingScreen />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
63
apps/web/src/components/auth/redirect-handler.tsx
Normal file
63
apps/web/src/components/auth/redirect-handler.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import { LoadingScreen } from "@/components/layout/loading-screen";
|
||||||
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
|
||||||
|
interface RedirectHandlerProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicPaths = [
|
||||||
|
"/login",
|
||||||
|
"/forgot-password",
|
||||||
|
"/reset-password",
|
||||||
|
"/auth/callback",
|
||||||
|
"/auth/oidc/callback",
|
||||||
|
"/s/",
|
||||||
|
"/r/",
|
||||||
|
];
|
||||||
|
const homePaths = ["/"];
|
||||||
|
|
||||||
|
export function RedirectHandler({ children }: RedirectHandlerProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated === true) {
|
||||||
|
if (publicPaths.some((path) => pathname.startsWith(path)) || homePaths.includes(pathname)) {
|
||||||
|
router.replace("/dashboard");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (isAuthenticated === false) {
|
||||||
|
if (!publicPaths.some((path) => pathname.startsWith(path)) && !homePaths.includes(pathname)) {
|
||||||
|
router.replace("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, pathname, router]);
|
||||||
|
|
||||||
|
if (isAuthenticated === null) {
|
||||||
|
return <LoadingScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isAuthenticated === true &&
|
||||||
|
(publicPaths.some((path) => pathname.startsWith(path)) || homePaths.includes(pathname))
|
||||||
|
) {
|
||||||
|
return <LoadingScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isAuthenticated === false &&
|
||||||
|
!publicPaths.some((path) => pathname.startsWith(path)) &&
|
||||||
|
!homePaths.includes(pathname)
|
||||||
|
) {
|
||||||
|
return <LoadingScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { toast } from "sonner";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { checkFile, getPresignedUrl, registerFile } from "@/http/endpoints";
|
import { checkFile, getPresignedUrl, registerFile } from "@/http/endpoints";
|
||||||
|
import { getSystemInfo } from "@/http/endpoints/app";
|
||||||
import { ChunkedUploader } from "@/utils/chunked-upload";
|
import { ChunkedUploader } from "@/utils/chunked-upload";
|
||||||
import { getFileIcon } from "@/utils/file-icons";
|
import { getFileIcon } from "@/utils/file-icons";
|
||||||
import { generateSafeFileName } from "@/utils/file-utils";
|
import { generateSafeFileName } from "@/utils/file-utils";
|
||||||
@@ -43,6 +44,7 @@ export function GlobalDropZone({ onSuccess, children }: GlobalDropZoneProps) {
|
|||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const [fileUploads, setFileUploads] = useState<FileUpload[]>([]);
|
const [fileUploads, setFileUploads] = useState<FileUpload[]>([]);
|
||||||
const [hasShownSuccessToast, setHasShownSuccessToast] = useState(false);
|
const [hasShownSuccessToast, setHasShownSuccessToast] = useState(false);
|
||||||
|
const [isS3Enabled, setIsS3Enabled] = useState<boolean | null>(null);
|
||||||
|
|
||||||
const generateFileId = useCallback(() => {
|
const generateFileId = useCallback(() => {
|
||||||
return Date.now().toString() + Math.random().toString(36).substr(2, 9);
|
return Date.now().toString() + Math.random().toString(36).substr(2, 9);
|
||||||
@@ -124,7 +126,7 @@ export function GlobalDropZone({ onSuccess, children }: GlobalDropZoneProps) {
|
|||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, abortController } : u)));
|
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, abortController } : u)));
|
||||||
|
|
||||||
const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size);
|
const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size, isS3Enabled ?? undefined);
|
||||||
|
|
||||||
if (shouldUseChunked) {
|
if (shouldUseChunked) {
|
||||||
const chunkSize = ChunkedUploader.calculateOptimalChunkSize(file.size);
|
const chunkSize = ChunkedUploader.calculateOptimalChunkSize(file.size);
|
||||||
@@ -134,6 +136,7 @@ export function GlobalDropZone({ onSuccess, children }: GlobalDropZoneProps) {
|
|||||||
url,
|
url,
|
||||||
chunkSize,
|
chunkSize,
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
|
isS3Enabled: isS3Enabled ?? undefined,
|
||||||
onProgress: (progress) => {
|
onProgress: (progress) => {
|
||||||
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, progress } : u)));
|
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, progress } : u)));
|
||||||
},
|
},
|
||||||
@@ -196,7 +199,7 @@ export function GlobalDropZone({ onSuccess, children }: GlobalDropZoneProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[t]
|
[t, isS3Enabled]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDrop = useCallback(
|
const handleDrop = useCallback(
|
||||||
@@ -256,6 +259,20 @@ export function GlobalDropZone({ onSuccess, children }: GlobalDropZoneProps) {
|
|||||||
[uploadFile, t, createFileUpload]
|
[uploadFile, t, createFileUpload]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSystemInfo = async () => {
|
||||||
|
try {
|
||||||
|
const response = await getSystemInfo();
|
||||||
|
setIsS3Enabled(response.data.s3Enabled);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to fetch system info, defaulting to filesystem mode:", error);
|
||||||
|
setIsS3Enabled(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchSystemInfo();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.addEventListener("dragover", handleDragOver);
|
document.addEventListener("dragover", handleDragOver);
|
||||||
document.addEventListener("dragleave", handleDragLeave);
|
document.addEventListener("dragleave", handleDragLeave);
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export function LanguageSwitcher() {
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
{Object.entries(languages).map(([code, name]) => {
|
{Object.entries(languages).map(([code, name]) => {
|
||||||
const isCurrentLocale = locale === code.split("-")[0];
|
const isCurrentLocale = locale === code;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { IconLogout, IconSettings, IconUser, IconUsers } from "@tabler/icons-react";
|
import { IconLogout, IconSettings, IconUser, IconUsers } from "@tabler/icons-react";
|
||||||
@@ -21,8 +22,22 @@ import { logout as logoutAPI } from "@/http/endpoints";
|
|||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user, isAdmin, logout } = useAuth();
|
const { user, isAdmin, logout, isAuthenticated } = useAuth();
|
||||||
const { appName, appLogo } = useAppInfo();
|
const { appName, appLogo } = useAppInfo();
|
||||||
|
const [isNavigating, setIsNavigating] = useState(false);
|
||||||
|
|
||||||
|
const handleLogoClick = async () => {
|
||||||
|
if (isNavigating || !isAuthenticated) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsNavigating(true);
|
||||||
|
router.replace("/dashboard");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error navigating to dashboard:", err);
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => setIsNavigating(false), 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -39,10 +54,15 @@ export function Navbar() {
|
|||||||
<div className="container flex h-16 max-w-screen-xl items-center mx-auto lg:px-6">
|
<div className="container flex h-16 max-w-screen-xl items-center mx-auto lg:px-6">
|
||||||
<div className="flex flex-1 items-center justify-between">
|
<div className="flex flex-1 items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link href="/dashboard" className="flex items-center gap-2 cursor-pointer">
|
<div
|
||||||
|
onClick={handleLogoClick}
|
||||||
|
className={`flex items-center gap-2 cursor-pointer transition-opacity ${
|
||||||
|
isNavigating ? "opacity-50" : "opacity-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{appLogo && <img alt={t("navbar.logoAlt")} className="h-8 w-8 object-contain rounded" src={appLogo} />}
|
{appLogo && <img alt={t("navbar.logoAlt")} className="h-8 w-8 object-contain rounded" src={appLogo} />}
|
||||||
<p className="font-bold text-2xl">{appName}</p>
|
<p className="font-bold text-2xl">{appName}</p>
|
||||||
</Link>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 cursor-pointer">
|
<div className="flex items-center gap-2 cursor-pointer">
|
||||||
|
|||||||
@@ -37,7 +37,15 @@ export function CreateShareModal({ isOpen, onClose, onSuccess }: CreateShareModa
|
|||||||
name: formData.name,
|
name: formData.name,
|
||||||
description: formData.description || undefined,
|
description: formData.description || undefined,
|
||||||
password: formData.isPasswordProtected ? formData.password : undefined,
|
password: formData.isPasswordProtected ? formData.password : undefined,
|
||||||
expiration: formData.expiresAt ? new Date(formData.expiresAt).toISOString() : undefined,
|
expiration: formData.expiresAt
|
||||||
|
? (() => {
|
||||||
|
const dateValue = formData.expiresAt;
|
||||||
|
if (dateValue.length === 10) {
|
||||||
|
return new Date(dateValue + "T23:59:59").toISOString();
|
||||||
|
}
|
||||||
|
return new Date(dateValue).toISOString();
|
||||||
|
})()
|
||||||
|
: undefined,
|
||||||
maxViews: formData.maxViews ? parseInt(formData.maxViews) : undefined,
|
maxViews: formData.maxViews ? parseInt(formData.maxViews) : undefined,
|
||||||
files: [],
|
files: [],
|
||||||
});
|
});
|
||||||
@@ -71,7 +79,16 @@ export function CreateShareModal({ isOpen, onClose, onSuccess }: CreateShareModa
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t("createShare.nameLabel")}</Label>
|
<Label>{t("createShare.nameLabel")}</Label>
|
||||||
<Input value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} />
|
<Input
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
onPaste={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const pastedText = e.clipboardData.getData("text");
|
||||||
|
setFormData({ ...formData, name: pastedText });
|
||||||
|
}}
|
||||||
|
placeholder={t("createShare.namePlaceholder")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -93,6 +110,12 @@ export function CreateShareModal({ isOpen, onClose, onSuccess }: CreateShareModa
|
|||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={formData.expiresAt}
|
value={formData.expiresAt}
|
||||||
onChange={(e) => setFormData({ ...formData, expiresAt: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, expiresAt: e.target.value })}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
if (value && value.length === 10) {
|
||||||
|
setFormData({ ...formData, expiresAt: value + "T23:59" });
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { IconCopy } from "@tabler/icons-react";
|
import { IconCopy, IconDownload } from "@tabler/icons-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import QRCode from "react-qr-code";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -33,6 +34,7 @@ export function GenerateShareLinkModal({
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [generatedLink, setGeneratedLink] = useState("");
|
const [generatedLink, setGeneratedLink] = useState("");
|
||||||
const [isEdit, setIsEdit] = useState(false);
|
const [isEdit, setIsEdit] = useState(false);
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (shareId && share?.alias?.alias) {
|
if (shareId && share?.alias?.alias) {
|
||||||
@@ -68,9 +70,56 @@ export function GenerateShareLinkModal({
|
|||||||
toast.success(t("generateShareLink.copied"));
|
toast.success(t("generateShareLink.copied"));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const downloadQRCode = () => {
|
||||||
|
setIsDownloading(true);
|
||||||
|
|
||||||
|
// Get the SVG element
|
||||||
|
const svg = document.getElementById("share-link-qr-code");
|
||||||
|
if (!svg) {
|
||||||
|
setIsDownloading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a canvas
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
|
||||||
|
// Set dimensions (with some padding)
|
||||||
|
const padding = 20;
|
||||||
|
canvas.width = 256 + padding * 2;
|
||||||
|
canvas.height = 256 + padding * 2;
|
||||||
|
|
||||||
|
// Fill white background
|
||||||
|
if (ctx) {
|
||||||
|
ctx.fillStyle = "#FFFFFF";
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
// Convert SVG to data URL
|
||||||
|
const svgData = new XMLSerializer().serializeToString(svg);
|
||||||
|
const img = new Image();
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
// Draw the image in the center of the canvas with padding
|
||||||
|
ctx.drawImage(img, padding, padding, 256, 256);
|
||||||
|
|
||||||
|
// Create a download link
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.download = `${share?.name?.replace(/[^a-z0-9]/gi, "-").toLowerCase() || "share"}-qr-code.png`;
|
||||||
|
link.href = canvas.toDataURL("image/png");
|
||||||
|
link.click();
|
||||||
|
|
||||||
|
setIsDownloading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = `data:image/svg+xml;base64,${btoa(svgData)}`;
|
||||||
|
} else {
|
||||||
|
setIsDownloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={!!shareId} onOpenChange={() => onClose()}>
|
<Dialog open={!!shareId} onOpenChange={() => onClose()}>
|
||||||
<DialogContent>
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{isEdit ? t("generateShareLink.updateTitle") : t("generateShareLink.generateTitle")}
|
{isEdit ? t("generateShareLink.updateTitle") : t("generateShareLink.generateTitle")}
|
||||||
@@ -88,23 +137,55 @@ export function GenerateShareLinkModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
<p className="text-sm text-muted-foreground">{t("generateShareLink.linkReady")}</p>
|
<p className="text-sm text-muted-foreground text-left">
|
||||||
<Input readOnly value={generatedLink} />
|
{t("generateShareLink.readyDescription", {
|
||||||
|
defaultValue:
|
||||||
|
"Your share link is ready. You can scan the QR code directly, download it for later use, or copy the link below.",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col items-center justify-center">
|
||||||
|
<div className="p-4 bg-white rounded-lg">
|
||||||
|
<QRCode
|
||||||
|
id="share-link-qr-code"
|
||||||
|
value={generatedLink}
|
||||||
|
size={200}
|
||||||
|
level="H"
|
||||||
|
fgColor="#000000"
|
||||||
|
bgColor="#FFFFFF"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Input readOnly value={generatedLink} className="flex-1" />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleCopyLink}
|
||||||
|
title={t("generateShareLink.copyButton")}
|
||||||
|
>
|
||||||
|
<IconCopy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={downloadQRCode} disabled={isDownloading}>
|
||||||
|
<IconDownload className="h-4 w-4" />
|
||||||
|
{t("qrCodeModal.download")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!generatedLink && (
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
{!generatedLink ? (
|
|
||||||
<Button disabled={!alias || isLoading} onClick={handleGenerate}>
|
<Button disabled={!alias || isLoading} onClick={handleGenerate}>
|
||||||
{isEdit ? t("generateShareLink.updateButton") : t("generateShareLink.generateButton")}
|
{isEdit ? t("generateShareLink.updateButton") : t("generateShareLink.generateButton")}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
|
||||||
<Button onClick={handleCopyLink}>
|
|
||||||
<IconCopy className="h-4 w-4" />
|
|
||||||
{t("generateShareLink.copyButton")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
13
apps/web/src/components/modals/index.ts
Normal file
13
apps/web/src/components/modals/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export { QrCodeModal } from "./qr-code-modal";
|
||||||
|
export { UploadFileModal } from "./upload-file-modal";
|
||||||
|
export { CreateShareModal } from "./create-share-modal";
|
||||||
|
export { ShareSecurityModal } from "./share-security-modal";
|
||||||
|
export { ShareFileModal } from "./share-file-modal";
|
||||||
|
export { ShareMultipleFilesModal } from "./share-multiple-files-modal";
|
||||||
|
export { ShareDetailsModal } from "./share-details-modal";
|
||||||
|
export { FilePreviewModal } from "./file-preview-modal";
|
||||||
|
export { GenerateShareLinkModal } from "./generate-share-link-modal";
|
||||||
|
export { ImageEditModal } from "./image-edit-modal";
|
||||||
|
export { DeleteConfirmationModal } from "./delete-confirmation-modal";
|
||||||
|
export { BulkDownloadModal } from "./bulk-download-modal";
|
||||||
|
export { ShareExpirationModal } from "./share-expiration-modal";
|
||||||
103
apps/web/src/components/modals/qr-code-modal.tsx
Normal file
103
apps/web/src/components/modals/qr-code-modal.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { IconDownload } from "@tabler/icons-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import QRCode from "react-qr-code";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
interface QrCodeModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
shareLink: string;
|
||||||
|
shareName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QrCodeModal({ isOpen, onClose, shareLink, shareName }: QrCodeModalProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
|
||||||
|
const downloadQRCode = () => {
|
||||||
|
setIsDownloading(true);
|
||||||
|
|
||||||
|
// Get the SVG element
|
||||||
|
const svg = document.getElementById("share-qr-code");
|
||||||
|
if (!svg) {
|
||||||
|
setIsDownloading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a canvas
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
|
||||||
|
// Set dimensions (with some padding)
|
||||||
|
const padding = 20;
|
||||||
|
canvas.width = 256 + padding * 2;
|
||||||
|
canvas.height = 256 + padding * 2;
|
||||||
|
|
||||||
|
// Fill white background
|
||||||
|
if (ctx) {
|
||||||
|
ctx.fillStyle = "#FFFFFF";
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
// Convert SVG to data URL
|
||||||
|
const svgData = new XMLSerializer().serializeToString(svg);
|
||||||
|
const img = new Image();
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
// Draw the image in the center of the canvas with padding
|
||||||
|
ctx.drawImage(img, padding, padding, 256, 256);
|
||||||
|
|
||||||
|
// Create a download link
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.download = `${shareName.replace(/[^a-z0-9]/gi, "-").toLowerCase()}-qr-code.png`;
|
||||||
|
link.href = canvas.toDataURL("image/png");
|
||||||
|
link.click();
|
||||||
|
|
||||||
|
setIsDownloading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = `data:image/svg+xml;base64,${btoa(svgData)}`;
|
||||||
|
} else {
|
||||||
|
setIsDownloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("qrCodeModal.title", { defaultValue: "Share QR Code" })}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("qrCodeModal.description", { defaultValue: "Scan this QR code to access the shared files." })}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center justify-center">
|
||||||
|
<div className="p-4 bg-white rounded-lg">
|
||||||
|
<QRCode id="share-qr-code" value={shareLink} size={256} level="H" fgColor="#000000" bgColor="#FFFFFF" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-sm text-muted-foreground text-center max-w-full break-all">{shareLink}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="sm:justify-between flex-row">
|
||||||
|
<Button variant="outline" onClick={onClose} className="mt-2 sm:mt-0">
|
||||||
|
{t("common.close")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={downloadQRCode} className="mt-2 sm:mt-0" disabled={isDownloading}>
|
||||||
|
<IconDownload className="h-4 w-4" />
|
||||||
|
{t("qrCodeModal.download", { defaultValue: "Download QR Code" })}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
IconCheck,
|
IconCheck,
|
||||||
IconCopy,
|
IconCopy,
|
||||||
|
IconDownload,
|
||||||
IconEdit,
|
IconEdit,
|
||||||
IconExternalLink,
|
IconExternalLink,
|
||||||
IconLock,
|
IconLock,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import QRCode from "react-qr-code";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -30,6 +32,7 @@ import { Loader } from "@/components/ui/loader";
|
|||||||
import { getShare } from "@/http/endpoints";
|
import { getShare } from "@/http/endpoints";
|
||||||
import { getFileIcon } from "@/utils/file-icons";
|
import { getFileIcon } from "@/utils/file-icons";
|
||||||
import { GenerateShareLinkModal } from "./generate-share-link-modal";
|
import { GenerateShareLinkModal } from "./generate-share-link-modal";
|
||||||
|
import { QrCodeModal } from "./qr-code-modal";
|
||||||
import { ShareExpirationModal } from "./share-expiration-modal";
|
import { ShareExpirationModal } from "./share-expiration-modal";
|
||||||
import { ShareSecurityModal } from "./share-security-modal";
|
import { ShareSecurityModal } from "./share-security-modal";
|
||||||
|
|
||||||
@@ -86,6 +89,8 @@ export function ShareDetailsModal({
|
|||||||
const [showLinkModal, setShowLinkModal] = useState(false);
|
const [showLinkModal, setShowLinkModal] = useState(false);
|
||||||
const [showSecurityModal, setShowSecurityModal] = useState(false);
|
const [showSecurityModal, setShowSecurityModal] = useState(false);
|
||||||
const [showExpirationModal, setShowExpirationModal] = useState(false);
|
const [showExpirationModal, setShowExpirationModal] = useState(false);
|
||||||
|
const [showQrCodeModal, setShowQrCodeModal] = useState(false);
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const loadShareDetails = useCallback(async () => {
|
const loadShareDetails = useCallback(async () => {
|
||||||
@@ -209,6 +214,53 @@ export function ShareDetailsModal({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const downloadQRCode = () => {
|
||||||
|
setIsDownloading(true);
|
||||||
|
|
||||||
|
// Get the SVG element
|
||||||
|
const svg = document.getElementById("share-details-qr-code");
|
||||||
|
if (!svg) {
|
||||||
|
setIsDownloading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a canvas
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
|
||||||
|
// Set dimensions (with some padding)
|
||||||
|
const padding = 20;
|
||||||
|
canvas.width = 200 + padding * 2;
|
||||||
|
canvas.height = 200 + padding * 2;
|
||||||
|
|
||||||
|
// Fill white background
|
||||||
|
if (ctx) {
|
||||||
|
ctx.fillStyle = "#FFFFFF";
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
// Convert SVG to data URL
|
||||||
|
const svgData = new XMLSerializer().serializeToString(svg);
|
||||||
|
const img = new Image();
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
// Draw the image in the center of the canvas with padding
|
||||||
|
ctx.drawImage(img, padding, padding, 200, 200);
|
||||||
|
|
||||||
|
// Create a download link
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.download = `${share?.name?.replace(/[^a-z0-9]/gi, "-").toLowerCase() || "share"}-qr-code.png`;
|
||||||
|
link.href = canvas.toDataURL("image/png");
|
||||||
|
link.click();
|
||||||
|
|
||||||
|
setIsDownloading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = `data:image/svg+xml;base64,${btoa(svgData)}`;
|
||||||
|
} else {
|
||||||
|
setIsDownloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleLinkGenerated = async () => {
|
const handleLinkGenerated = async () => {
|
||||||
setShowLinkModal(false);
|
setShowLinkModal(false);
|
||||||
await loadShareDetails();
|
await loadShareDetails();
|
||||||
@@ -258,7 +310,7 @@ export function ShareDetailsModal({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div className="text-center p-2 bg-muted/30 rounded-lg">
|
<div className="text-center p-2 bg-muted/30 rounded-lg">
|
||||||
<p className="text-lg font-semibold text-green-600">{share.viewCount || 0}</p>
|
<p className="text-lg font-semibold text-green-600">{share.views || 0}</p>
|
||||||
<p className="text-xs text-muted-foreground">{t("shareDetails.views")}</p>
|
<p className="text-xs text-muted-foreground">{t("shareDetails.views")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center p-2 bg-muted/30 rounded-lg">
|
<div className="text-center p-2 bg-muted/30 rounded-lg">
|
||||||
@@ -271,6 +323,8 @@ export function ShareDetailsModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* Basic Information */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2 border-b pb-2">
|
<div className="flex items-center gap-2 border-b pb-2">
|
||||||
<h3 className="text-base font-medium text-foreground">{t("shareDetails.basicInfo")}</h3>
|
<h3 className="text-base font-medium text-foreground">{t("shareDetails.basicInfo")}</h3>
|
||||||
@@ -372,6 +426,47 @@ export function ShareDetailsModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* QR Code */}
|
||||||
|
{shareLink && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 border-b pb-2">
|
||||||
|
<h3
|
||||||
|
className="text-base font-medium text-foreground cursor-pointer"
|
||||||
|
onClick={() => setShowQrCodeModal(true)}
|
||||||
|
>
|
||||||
|
{t("shareDetails.qrCode", { defaultValue: "QR Code" })}
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-5 w-5 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={downloadQRCode}
|
||||||
|
disabled={isDownloading}
|
||||||
|
title={t("shareDetails.downloadQrCode")}
|
||||||
|
>
|
||||||
|
<IconDownload className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-start justify-start ">
|
||||||
|
<div
|
||||||
|
className="p-2 bg-white rounded-lg cursor-pointer hover:opacity-80 transition-opacity duration-300"
|
||||||
|
onClick={() => setShowQrCodeModal(true)}
|
||||||
|
title={t("shareDetails.clickToEnlargeQrCode", { defaultValue: "Click to enlarge QR Code" })}
|
||||||
|
>
|
||||||
|
<QRCode
|
||||||
|
id="share-details-qr-code"
|
||||||
|
value={shareLink}
|
||||||
|
size={100}
|
||||||
|
level="H"
|
||||||
|
fgColor="#000000"
|
||||||
|
bgColor="#FFFFFF"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2 border-b pb-2">
|
<div className="flex items-center gap-2 border-b pb-2">
|
||||||
<h3 className="text-base font-medium text-foreground">{t("shareDetails.shareLink")}</h3>
|
<h3 className="text-base font-medium text-foreground">{t("shareDetails.shareLink")}</h3>
|
||||||
@@ -556,16 +651,16 @@ export function ShareDetailsModal({
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
{showLinkModal && onGenerateLink && (
|
{showLinkModal && shareId && (
|
||||||
<GenerateShareLinkModal
|
<GenerateShareLinkModal
|
||||||
shareId={shareId}
|
shareId={shareId}
|
||||||
share={share}
|
share={share}
|
||||||
onClose={() => setShowLinkModal(false)}
|
onClose={() => setShowLinkModal(false)}
|
||||||
onGenerate={onGenerateLink}
|
|
||||||
onSuccess={handleLinkGenerated}
|
onSuccess={handleLinkGenerated}
|
||||||
|
onGenerate={onGenerateLink || (() => Promise.resolve())}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showSecurityModal && (
|
{showSecurityModal && shareId && onUpdateSecurity && (
|
||||||
<ShareSecurityModal
|
<ShareSecurityModal
|
||||||
shareId={shareId}
|
shareId={shareId}
|
||||||
share={share}
|
share={share}
|
||||||
@@ -573,7 +668,7 @@ export function ShareDetailsModal({
|
|||||||
onSuccess={handleSecurityUpdated}
|
onSuccess={handleSecurityUpdated}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showExpirationModal && (
|
{showExpirationModal && shareId && onUpdateExpiration && (
|
||||||
<ShareExpirationModal
|
<ShareExpirationModal
|
||||||
shareId={shareId}
|
shareId={shareId}
|
||||||
share={share}
|
share={share}
|
||||||
@@ -581,6 +676,14 @@ export function ShareDetailsModal({
|
|||||||
onSuccess={handleExpirationUpdated}
|
onSuccess={handleExpirationUpdated}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{showQrCodeModal && shareLink && (
|
||||||
|
<QrCodeModal
|
||||||
|
isOpen={showQrCodeModal}
|
||||||
|
onClose={() => setShowQrCodeModal(false)}
|
||||||
|
shareLink={shareLink}
|
||||||
|
shareName={share?.name || "Share"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { IconCalendar, IconCopy, IconEye, IconLink, IconLock, IconShare } from "@tabler/icons-react";
|
import { IconCalendar, IconCopy, IconDownload, IconEye, IconLink, IconLock, IconShare } from "@tabler/icons-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import QRCode from "react-qr-code";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -123,6 +124,18 @@ export function ShareFileModal({ isOpen, file, onClose, onSuccess }: ShareFileMo
|
|||||||
toast.success(t("generateShareLink.copied"));
|
toast.success(t("generateShareLink.copied"));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const downloadQRCode = () => {
|
||||||
|
const canvas = document.getElementById("share-file-qr-code") as HTMLCanvasElement;
|
||||||
|
if (canvas) {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.download = "share-file-qr-code.png";
|
||||||
|
link.href = canvas.toDataURL("image/png");
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
onClose();
|
onClose();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -259,8 +272,26 @@ export function ShareFileModal({ isOpen, file, onClose, onSuccess }: ShareFileMo
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<div className="flex flex-col items-center justify-center">
|
||||||
|
<div className="p-4 bg-white rounded-lg">
|
||||||
|
<svg style={{ display: "none" }} /> {/* For SSR safety */}
|
||||||
|
<QRCode
|
||||||
|
id="share-file-qr-code"
|
||||||
|
value={generatedLink}
|
||||||
|
size={250}
|
||||||
|
level="H"
|
||||||
|
fgColor="#000000"
|
||||||
|
bgColor="#FFFFFF"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{t("shareFile.linkReady")}</p>
|
<p className="text-sm text-muted-foreground">{t("shareFile.linkReady")}</p>
|
||||||
<Input readOnly value={generatedLink} />
|
<div className="flex gap-2">
|
||||||
|
<Input readOnly value={generatedLink} className="flex-1" />
|
||||||
|
<Button size="icon" variant="outline" onClick={handleCopyLink} title={t("shareFile.copyLink")}>
|
||||||
|
<IconCopy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -294,9 +325,9 @@ export function ShareFileModal({ isOpen, file, onClose, onSuccess }: ShareFileMo
|
|||||||
<Button variant="outline" onClick={handleSuccess}>
|
<Button variant="outline" onClick={handleSuccess}>
|
||||||
{t("common.close")}
|
{t("common.close")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleCopyLink}>
|
<Button onClick={downloadQRCode}>
|
||||||
<IconCopy className="h-4 w-4" />
|
<IconDownload className="h-4 w-4" />
|
||||||
{t("shareFile.copyLink")}
|
{t("qrCodeModal.download")}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { IconCalendar, IconCopy, IconEye, IconLink, IconLock, IconShare } from "@tabler/icons-react";
|
import { IconCalendar, IconCopy, IconDownload, IconEye, IconLink, IconLock, IconShare } from "@tabler/icons-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import QRCode from "react-qr-code";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -115,6 +116,19 @@ export function ShareMultipleFilesModal({ files, isOpen, onClose, onSuccess }: S
|
|||||||
toast.success(t("generateShareLink.copied"));
|
toast.success(t("generateShareLink.copied"));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const downloadQRCode = () => {
|
||||||
|
const qrCodeElement = document.getElementById("share-multiple-files-qr-code");
|
||||||
|
if (qrCodeElement) {
|
||||||
|
const canvas = qrCodeElement.querySelector("canvas");
|
||||||
|
if (canvas) {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.download = "share-multiple-files-qr-code.png";
|
||||||
|
link.href = canvas.toDataURL("image/png");
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
onClose();
|
onClose();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -282,8 +296,26 @@ export function ShareMultipleFilesModal({ files, isOpen, onClose, onSuccess }: S
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<div className="flex flex-col items-center justify-center">
|
||||||
|
<div className="p-4 bg-white rounded-lg">
|
||||||
|
<svg style={{ display: "none" }} /> {/* For SSR safety */}
|
||||||
|
<QRCode
|
||||||
|
id="share-multiple-files-qr-code"
|
||||||
|
value={generatedLink}
|
||||||
|
size={250}
|
||||||
|
level="H"
|
||||||
|
fgColor="#000000"
|
||||||
|
bgColor="#FFFFFF"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{t("shareFile.linkReady")}</p>
|
<p className="text-sm text-muted-foreground">{t("shareFile.linkReady")}</p>
|
||||||
<Input readOnly value={generatedLink} />
|
<div className="flex gap-2">
|
||||||
|
<Input readOnly value={generatedLink} className="flex-1" />
|
||||||
|
<Button variant="outline" size="icon" onClick={handleCopyLink} title={t("shareFile.copyLink")}>
|
||||||
|
<IconCopy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -323,9 +355,9 @@ export function ShareMultipleFilesModal({ files, isOpen, onClose, onSuccess }: S
|
|||||||
<Button variant="outline" onClick={handleSuccess}>
|
<Button variant="outline" onClick={handleSuccess}>
|
||||||
{t("common.close")}
|
{t("common.close")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleCopyLink}>
|
<Button onClick={downloadQRCode}>
|
||||||
<IconCopy className="h-4 w-4" />
|
<IconDownload className="h-4 w-4" />
|
||||||
{t("shareFile.copyLink")}
|
{t("qrCodeModal.download")}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { checkFile, getPresignedUrl, registerFile } from "@/http/endpoints";
|
import { checkFile, getPresignedUrl, registerFile } from "@/http/endpoints";
|
||||||
|
import { getSystemInfo } from "@/http/endpoints/app";
|
||||||
import { ChunkedUploader } from "@/utils/chunked-upload";
|
import { ChunkedUploader } from "@/utils/chunked-upload";
|
||||||
import { getFileIcon } from "@/utils/file-icons";
|
import { getFileIcon } from "@/utils/file-icons";
|
||||||
import { generateSafeFileName } from "@/utils/file-utils";
|
import { generateSafeFileName } from "@/utils/file-utils";
|
||||||
@@ -87,8 +88,23 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
|
|||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||||
const [hasShownSuccessToast, setHasShownSuccessToast] = useState(false);
|
const [hasShownSuccessToast, setHasShownSuccessToast] = useState(false);
|
||||||
|
const [isS3Enabled, setIsS3Enabled] = useState<boolean | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSystemInfo = async () => {
|
||||||
|
try {
|
||||||
|
const response = await getSystemInfo();
|
||||||
|
setIsS3Enabled(response.data.s3Enabled);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to fetch system info, defaulting to filesystem mode:", error);
|
||||||
|
setIsS3Enabled(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchSystemInfo();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
fileUploads.forEach((upload) => {
|
fileUploads.forEach((upload) => {
|
||||||
@@ -108,7 +124,11 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
|
|||||||
let previewUrl: string | undefined;
|
let previewUrl: string | undefined;
|
||||||
|
|
||||||
if (file.type.startsWith("image/")) {
|
if (file.type.startsWith("image/")) {
|
||||||
|
try {
|
||||||
previewUrl = URL.createObjectURL(file);
|
previewUrl = URL.createObjectURL(file);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to create preview URL:", error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -126,6 +146,10 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
|
|||||||
const newUploads = Array.from(files).map(createFileUpload);
|
const newUploads = Array.from(files).map(createFileUpload);
|
||||||
setFileUploads((prev) => [...prev, ...newUploads]);
|
setFileUploads((prev) => [...prev, ...newUploads]);
|
||||||
setHasShownSuccessToast(false);
|
setHasShownSuccessToast(false);
|
||||||
|
|
||||||
|
if (newUploads.length > 0) {
|
||||||
|
toast.info(t("uploadFile.filesQueued", { count: newUploads.length }));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -148,7 +172,12 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
|
|||||||
const handleDrop = (event: React.DragEvent) => {
|
const handleDrop = (event: React.DragEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setIsDragOver(false);
|
setIsDragOver(false);
|
||||||
handleFilesSelect(event.dataTransfer.files);
|
event.stopPropagation();
|
||||||
|
|
||||||
|
const files = event.dataTransfer.files;
|
||||||
|
if (files.length > 0) {
|
||||||
|
handleFilesSelect(files);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderFileIcon = (fileName: string) => {
|
const renderFileIcon = (fileName: string) => {
|
||||||
@@ -252,7 +281,7 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
|
|||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, abortController } : u)));
|
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, abortController } : u)));
|
||||||
|
|
||||||
const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size);
|
const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size, isS3Enabled ?? undefined);
|
||||||
|
|
||||||
if (shouldUseChunked) {
|
if (shouldUseChunked) {
|
||||||
const chunkSize = ChunkedUploader.calculateOptimalChunkSize(file.size);
|
const chunkSize = ChunkedUploader.calculateOptimalChunkSize(file.size);
|
||||||
@@ -262,6 +291,7 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
|
|||||||
url,
|
url,
|
||||||
chunkSize,
|
chunkSize,
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
|
isS3Enabled: isS3Enabled ?? undefined,
|
||||||
onProgress: (progress) => {
|
onProgress: (progress) => {
|
||||||
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, progress } : u)));
|
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, progress } : u)));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
IconLock,
|
IconLock,
|
||||||
IconLockOpen,
|
IconLockOpen,
|
||||||
IconMail,
|
IconMail,
|
||||||
|
IconQrcode,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
IconUsers,
|
IconUsers,
|
||||||
IconX,
|
IconX,
|
||||||
@@ -46,6 +47,7 @@ export interface SharesTableProps {
|
|||||||
onGenerateLink: (share: any) => void;
|
onGenerateLink: (share: any) => void;
|
||||||
onCopyLink: (share: any) => void;
|
onCopyLink: (share: any) => void;
|
||||||
onNotifyRecipients: (share: any) => void;
|
onNotifyRecipients: (share: any) => void;
|
||||||
|
onViewQrCode?: (share: any) => void;
|
||||||
onDownloadShareFiles?: (share: any) => void;
|
onDownloadShareFiles?: (share: any) => void;
|
||||||
onBulkDelete?: (shares: any[]) => void;
|
onBulkDelete?: (shares: any[]) => void;
|
||||||
onBulkDownload?: (shares: any[]) => void;
|
onBulkDownload?: (shares: any[]) => void;
|
||||||
@@ -66,6 +68,7 @@ export function SharesTable({
|
|||||||
onGenerateLink,
|
onGenerateLink,
|
||||||
onCopyLink,
|
onCopyLink,
|
||||||
onNotifyRecipients,
|
onNotifyRecipients,
|
||||||
|
onViewQrCode,
|
||||||
onDownloadShareFiles,
|
onDownloadShareFiles,
|
||||||
onBulkDelete,
|
onBulkDelete,
|
||||||
onBulkDownload,
|
onBulkDownload,
|
||||||
@@ -604,6 +607,12 @@ export function SharesTable({
|
|||||||
{t("sharesTable.actions.copyLink")}
|
{t("sharesTable.actions.copyLink")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{share.alias && onViewQrCode && (
|
||||||
|
<DropdownMenuItem className="cursor-pointer py-2" onClick={() => onViewQrCode(share)}>
|
||||||
|
<IconQrcode className="h-4 w-4" />
|
||||||
|
{t("sharesTable.actions.viewQrCode", { defaultValue: "View QR Code" })}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{share.recipients?.length > 0 && share.alias && smtpEnabled === "true" && (
|
{share.recipients?.length > 0 && share.alias && smtpEnabled === "true" && (
|
||||||
<DropdownMenuItem className="cursor-pointer py-2" onClick={() => onNotifyRecipients(share)}>
|
<DropdownMenuItem className="cursor-pointer py-2" onClick={() => onNotifyRecipients(share)}>
|
||||||
<IconMail className="h-4 w-4" />
|
<IconMail className="h-4 w-4" />
|
||||||
|
|||||||
@@ -39,11 +39,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
const checkAuth = async () => {
|
const checkAuth = async () => {
|
||||||
try {
|
try {
|
||||||
const appInfoResponse = await getAppInfo();
|
const appInfoResponse = await getAppInfo();
|
||||||
const appInfo = appInfoResponse.data;
|
const appInfo = appInfoResponse.data;
|
||||||
|
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
if (appInfo.firstUserAccess) {
|
if (appInfo.firstUserAccess) {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setIsAdmin(false);
|
setIsAdmin(false);
|
||||||
@@ -52,8 +56,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await getCurrentUser();
|
const response = await getCurrentUser();
|
||||||
|
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
if (!response?.data?.user) {
|
if (!response?.data?.user) {
|
||||||
throw new Error("No user data");
|
setUser(null);
|
||||||
|
setIsAdmin(false);
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isAdmin, ...userData } = response.data.user;
|
const { isAdmin, ...userData } = response.data.user;
|
||||||
@@ -62,6 +72,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setIsAdmin(isAdmin);
|
setIsAdmin(isAdmin);
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setIsAdmin(false);
|
setIsAdmin(false);
|
||||||
@@ -70,6 +82,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
checkAuth();
|
checkAuth();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface ShareManagerHook {
|
|||||||
shareToManageExpiration: Share | null;
|
shareToManageExpiration: Share | null;
|
||||||
shareToViewDetails: Share | null;
|
shareToViewDetails: Share | null;
|
||||||
shareToGenerateLink: Share | null;
|
shareToGenerateLink: Share | null;
|
||||||
|
shareToViewQrCode: Share | null;
|
||||||
sharesToDelete: Share[] | null;
|
sharesToDelete: Share[] | null;
|
||||||
setShareToDelete: (share: Share | null) => void;
|
setShareToDelete: (share: Share | null) => void;
|
||||||
setShareToEdit: (share: Share | null) => void;
|
setShareToEdit: (share: Share | null) => void;
|
||||||
@@ -33,6 +34,7 @@ export interface ShareManagerHook {
|
|||||||
setShareToManageExpiration: (share: Share | null) => void;
|
setShareToManageExpiration: (share: Share | null) => void;
|
||||||
setShareToViewDetails: (share: Share | null) => void;
|
setShareToViewDetails: (share: Share | null) => void;
|
||||||
setShareToGenerateLink: (share: Share | null) => void;
|
setShareToGenerateLink: (share: Share | null) => void;
|
||||||
|
setShareToViewQrCode: (share: Share | null) => void;
|
||||||
setSharesToDelete: (shares: Share[] | null) => void;
|
setSharesToDelete: (shares: Share[] | null) => void;
|
||||||
handleDelete: (shareId: string) => Promise<void>;
|
handleDelete: (shareId: string) => Promise<void>;
|
||||||
handleBulkDelete: (shares: Share[]) => void;
|
handleBulkDelete: (shares: Share[]) => void;
|
||||||
@@ -62,6 +64,7 @@ export function useShareManager(onSuccess: () => void) {
|
|||||||
const [shareToManageExpiration, setShareToManageExpiration] = useState<Share | null>(null);
|
const [shareToManageExpiration, setShareToManageExpiration] = useState<Share | null>(null);
|
||||||
const [shareToViewDetails, setShareToViewDetails] = useState<Share | null>(null);
|
const [shareToViewDetails, setShareToViewDetails] = useState<Share | null>(null);
|
||||||
const [shareToGenerateLink, setShareToGenerateLink] = useState<Share | null>(null);
|
const [shareToGenerateLink, setShareToGenerateLink] = useState<Share | null>(null);
|
||||||
|
const [shareToViewQrCode, setShareToViewQrCode] = useState<Share | null>(null);
|
||||||
const [sharesToDelete, setSharesToDelete] = useState<Share[] | null>(null);
|
const [sharesToDelete, setSharesToDelete] = useState<Share[] | null>(null);
|
||||||
const [clearSelectionCallback, setClearSelectionCallbackState] = useState<(() => void) | null>(null);
|
const [clearSelectionCallback, setClearSelectionCallbackState] = useState<(() => void) | null>(null);
|
||||||
|
|
||||||
@@ -308,6 +311,7 @@ export function useShareManager(onSuccess: () => void) {
|
|||||||
shareToManageExpiration,
|
shareToManageExpiration,
|
||||||
shareToViewDetails,
|
shareToViewDetails,
|
||||||
shareToGenerateLink,
|
shareToGenerateLink,
|
||||||
|
shareToViewQrCode,
|
||||||
sharesToDelete,
|
sharesToDelete,
|
||||||
setShareToDelete,
|
setShareToDelete,
|
||||||
setShareToEdit,
|
setShareToEdit,
|
||||||
@@ -317,6 +321,7 @@ export function useShareManager(onSuccess: () => void) {
|
|||||||
setShareToManageExpiration,
|
setShareToManageExpiration,
|
||||||
setShareToViewDetails,
|
setShareToViewDetails,
|
||||||
setShareToGenerateLink,
|
setShareToGenerateLink,
|
||||||
|
setShareToViewQrCode,
|
||||||
setSharesToDelete,
|
setSharesToDelete,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
handleBulkDelete,
|
handleBulkDelete,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
CheckUploadAllowedResult,
|
CheckUploadAllowedResult,
|
||||||
GetAppInfoResult,
|
GetAppInfoResult,
|
||||||
GetDiskSpaceResult,
|
GetDiskSpaceResult,
|
||||||
|
GetSystemInfoResult,
|
||||||
RemoveLogoResult,
|
RemoveLogoResult,
|
||||||
UploadLogoBody,
|
UploadLogoBody,
|
||||||
UploadLogoResult,
|
UploadLogoResult,
|
||||||
@@ -20,6 +21,14 @@ export const getAppInfo = <TData = GetAppInfoResult>(options?: AxiosRequestConfi
|
|||||||
return apiInstance.get(`/api/app/info`, options);
|
return apiInstance.get(`/api/app/info`, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get system information including storage provider
|
||||||
|
* @summary Get system information
|
||||||
|
*/
|
||||||
|
export const getSystemInfo = <TData = GetSystemInfoResult>(options?: AxiosRequestConfig): Promise<TData> => {
|
||||||
|
return apiInstance.get(`/api/app/system-info`, options);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload a new app logo (admin only)
|
* Upload a new app logo (admin only)
|
||||||
* @summary Upload app logo
|
* @summary Upload app logo
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ export interface GetAppInfo200 {
|
|||||||
firstUserAccess: boolean;
|
firstUserAccess: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GetSystemInfo200 {
|
||||||
|
storageProvider: "s3" | "filesystem";
|
||||||
|
s3Enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RemoveLogo200 {
|
export interface RemoveLogo200 {
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
@@ -49,6 +54,7 @@ export interface UploadLogoBody {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type GetAppInfoResult = AxiosResponse<GetAppInfo200>;
|
export type GetAppInfoResult = AxiosResponse<GetAppInfo200>;
|
||||||
|
export type GetSystemInfoResult = AxiosResponse<GetSystemInfo200>;
|
||||||
export type UploadLogoResult = AxiosResponse<UploadLogo200>;
|
export type UploadLogoResult = AxiosResponse<UploadLogo200>;
|
||||||
export type RemoveLogoResult = AxiosResponse<RemoveLogo200>;
|
export type RemoveLogoResult = AxiosResponse<RemoveLogo200>;
|
||||||
export type CheckHealthResult = AxiosResponse<CheckHealth200>;
|
export type CheckHealthResult = AxiosResponse<CheckHealth200>;
|
||||||
|
|||||||
@@ -99,3 +99,9 @@ export const updateProvidersOrder = <TData = UpdateProvidersOrderResult>(
|
|||||||
): Promise<TData> => {
|
): Promise<TData> => {
|
||||||
return apiInstance.put(`/api/auth/providers/order`, updateProvidersOrderBody, options);
|
return apiInstance.put(`/api/auth/providers/order`, updateProvidersOrderBody, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getAuthConfig = <TData = { passwordAuthEnabled: boolean }>(
|
||||||
|
options?: AxiosRequestConfig
|
||||||
|
): Promise<TData> => {
|
||||||
|
return apiInstance.get(`/api/auth/config`, options);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,7 +1,26 @@
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { getRequestConfig } from "next-intl/server";
|
import { getRequestConfig } from "next-intl/server";
|
||||||
|
|
||||||
const DEFAULT_LOCALE = "en-US";
|
const supportedLocales = [
|
||||||
|
"en-US",
|
||||||
|
"pt-BR",
|
||||||
|
"fr-FR",
|
||||||
|
"es-ES",
|
||||||
|
"de-DE",
|
||||||
|
"it-IT",
|
||||||
|
"nl-NL",
|
||||||
|
"pl-PL",
|
||||||
|
"tr-TR",
|
||||||
|
"ru-RU",
|
||||||
|
"hi-IN",
|
||||||
|
"ar-SA",
|
||||||
|
"zh-CN",
|
||||||
|
"ja-JP",
|
||||||
|
"ko-KR",
|
||||||
|
];
|
||||||
|
|
||||||
|
const envDefault = process.env.NEXT_PUBLIC_DEFAULT_LANGUAGE || "en-US";
|
||||||
|
const DEFAULT_LOCALE = supportedLocales.includes(envDefault) ? envDefault : "en-US";
|
||||||
|
|
||||||
export default getRequestConfig(async ({ locale }) => {
|
export default getRequestConfig(async ({ locale }) => {
|
||||||
const cookieStore = cookies();
|
const cookieStore = cookies();
|
||||||
@@ -9,11 +28,12 @@ export default getRequestConfig(async ({ locale }) => {
|
|||||||
const localeCookie = cookiesList.get("NEXT_LOCALE");
|
const localeCookie = cookiesList.get("NEXT_LOCALE");
|
||||||
|
|
||||||
const resolvedLocale = localeCookie?.value || locale || DEFAULT_LOCALE;
|
const resolvedLocale = localeCookie?.value || locale || DEFAULT_LOCALE;
|
||||||
|
const finalLocale = supportedLocales.includes(resolvedLocale) ? resolvedLocale : DEFAULT_LOCALE;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
locale: resolvedLocale,
|
locale: finalLocale,
|
||||||
messages: (await import(`../../messages/${resolvedLocale}.json`)).default,
|
messages: (await import(`../../messages/${finalLocale}.json`)).default,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface ChunkedUploadOptions {
|
|||||||
onProgress?: (progress: number) => void;
|
onProgress?: (progress: number) => void;
|
||||||
onChunkComplete?: (chunkIndex: number, totalChunks: number) => void;
|
onChunkComplete?: (chunkIndex: number, totalChunks: number) => void;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
isS3Enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChunkedUploadResult {
|
export interface ChunkedUploadResult {
|
||||||
@@ -23,7 +24,7 @@ export class ChunkedUploader {
|
|||||||
static async uploadFile(options: ChunkedUploadOptions): Promise<ChunkedUploadResult> {
|
static async uploadFile(options: ChunkedUploadOptions): Promise<ChunkedUploadResult> {
|
||||||
const { file, url, chunkSize, onProgress, onChunkComplete, signal } = options;
|
const { file, url, chunkSize, onProgress, onChunkComplete, signal } = options;
|
||||||
|
|
||||||
if (!this.shouldUseChunkedUpload(file.size)) {
|
if (!this.shouldUseChunkedUpload(file.size, options.isS3Enabled)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`File ${file.name} (${(file.size / (1024 * 1024)).toFixed(2)}MB) should not use chunked upload. Use regular upload instead.`
|
`File ${file.name} (${(file.size / (1024 * 1024)).toFixed(2)}MB) should not use chunked upload. Use regular upload instead.`
|
||||||
);
|
);
|
||||||
@@ -238,8 +239,13 @@ export class ChunkedUploader {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if file should use chunked upload
|
* Check if file should use chunked upload
|
||||||
|
* Only use chunked upload for filesystem storage, not for S3
|
||||||
*/
|
*/
|
||||||
static shouldUseChunkedUpload(fileSize: number): boolean {
|
static shouldUseChunkedUpload(fileSize: number, isS3Enabled?: boolean): boolean {
|
||||||
|
if (isS3Enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const threshold = 100 * 1024 * 1024; // 100MB
|
const threshold = 100 * 1024 * 1024; // 100MB
|
||||||
const shouldUse = fileSize > threshold;
|
const shouldUse = fileSize > threshold;
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user