Compare commits

..

14 Commits

Author SHA1 Message Date
Daniel Luiz Alves
3dbd5b81ae feat(i18n): update translations and add new languages
- Removed unused translation keys from zh-CN.json.
- Added a new script to prune extra translation keys based on the reference file.
- Updated package.json to include the new prune script.
- Enhanced language switcher to support additional languages: Thai, Vietnamese, Ukrainian, Persian, Swedish, Indonesian, Greek, and Hebrew.
- Updated request.ts to include new languages in the supported locales.
- Adjusted RTL languages to include Hebrew and Persian.
2025-10-29 14:37:31 -03:00
Daniel Luiz Alves
1806fbee39 refactor: update video preview component to use AspectRatio for better responsiveness 2025-10-27 18:50:42 -03:00
Daniel Luiz Alves
5d8c80812b refactor: reduce multipart upload threshold from 100MB to 50MB 2025-10-27 18:39:31 -03:00
Daniel Luiz Alves
7118d87e47 Add internationalization support for authentication and file management messages
- Updated translation files for multiple languages (ja-JP, ko-KR, nl-NL, pl-PL, pt-BR, ru-RU, tr-TR, zh-CN) to include new keys for authentication success and failure messages, item selection prompts, and error messages related to file operations.
- Enhanced user feedback in the UI by integrating translation functions in various components, ensuring that users receive localized messages for actions like moving items, creating shares, and managing files.
- Improved accessibility by providing translated titles and descriptions for buttons and dialogs.
2025-10-27 18:37:44 -03:00
Daniel Luiz Alves
6742ca314e feat: implement download URL caching for improved performance and reliability 2025-10-27 16:41:57 -03:00
Daniel Luiz Alves
c0a7970330 refactor: clean up file upload logic and remove unused variables 2025-10-25 00:23:44 -03:00
Daniel Luiz Alves
d0d5d012f0 refactor: remove console logs from file upload and notification handling 2025-10-24 18:02:43 -03:00
Daniel Luiz Alves
965ef244f3 Refactor file upload handling with Uppy integration
- Replaced custom file upload logic with Uppy for improved handling of uploads.
- Introduced a new hook `useUppyUpload` to manage file uploads, including multipart support for large files.
- Created a centralized upload configuration file to manage upload settings.
- Updated `UploadFileModal` component to utilize the new Uppy hook and configuration.
- Removed obsolete S3 upload utility as functionality is now handled by Uppy.
- Added new API endpoints for multipart upload operations: create, complete, and abort.
- Enhanced error handling and user feedback during the upload process.
2025-10-24 16:03:10 -03:00
Daniel Luiz Alves
18700d7e72 feat: enhance file management with context menu and skeleton loading states
- Added a context menu for file and folder actions, including options to create new folders and upload files.
- Implemented skeleton loading components for files grid and table views to improve user experience during data loading.
- Updated file and folder components to support new context menu interactions.
- Refactored drag-and-drop functionality to integrate with the new context menu features.

These changes improve the usability and responsiveness of the file management interface.
2025-10-23 23:44:38 -03:00
Daniel Luiz Alves
25b1a62d5f refactor: enhance file management with drag-and-drop functionality and UI improvements
- Introduced a new `useDragDrop` hook to manage drag-and-drop operations for files and folders.
- Updated `FilesGrid` and `FilesViewManager` components to support drag-and-drop interactions.
- Added immediate update handling for file and folder movements.
- Improved user experience with visual feedback during drag-and-drop actions.
- Refactored file access logging to enhance security by omitting sensitive information.
- Adjusted S3 client configuration and timeout settings for better performance.

These changes streamline file operations and enhance the overall user interface.
2025-10-23 18:03:46 -03:00
Daniel Luiz Alves
7617a14f1b feat: migrate storage system from filesystem to S3-compatible storage
- Implemented S3 storage integration, replacing the previous filesystem storage.
- Added automatic migration script for existing files from filesystem to S3.
- Updated Docker configuration to include MinIO for local S3 emulation.
- Removed obsolete filesystem-related code and endpoints.
- Enhanced upload and download functionalities to utilize presigned URLs for S3.
- Updated environment configuration to support S3 settings.

This change significantly simplifies file management and enhances scalability.
2025-10-23 14:49:12 -03:00
Daniel Luiz Alves
cb4ed3f581 version: update package versions from 3.2.4-beta to 3.2.5-beta across all packages 2025-10-21 11:24:11 -03:00
Copilot
148676513d fix: issue with OIDC Google auto-registration for users (#314)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: danielalves96 <62755605+danielalves96@users.noreply.github.com>
2025-10-21 11:15:48 -03:00
Copilot
42a5b7a796 feat: add functionality to embed uploaded images with BBCode or HTML (#296) 2025-10-21 11:14:46 -03:00
123 changed files with 24292 additions and 9344 deletions

View File

@@ -5,11 +5,21 @@ RUN apk add --no-cache \
gcompat \
supervisor \
curl \
wget \
openssl \
su-exec
# Enable pnpm
RUN corepack enable pnpm
# Install storage system for S3-compatible storage
COPY infra/install-minio.sh /tmp/install-minio.sh
RUN chmod +x /tmp/install-minio.sh && /tmp/install-minio.sh
# Install storage client (mc)
RUN wget https://dl.min.io/client/mc/release/linux-amd64/mc -O /usr/local/bin/mc && \
chmod +x /usr/local/bin/mc
# Set working directory
WORKDIR /app
@@ -119,11 +129,14 @@ RUN mkdir -p /etc/supervisor/conf.d
# Copy server start script and configuration files
COPY infra/server-start.sh /app/server-start.sh
COPY infra/start-minio.sh /app/start-minio.sh
COPY infra/minio-setup.sh /app/minio-setup.sh
COPY infra/load-minio-credentials.sh /app/load-minio-credentials.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 chown -R palmr:nodejs /app/server-start.sh /app/infra
RUN chmod +x /app/server-start.sh /app/start-minio.sh /app/minio-setup.sh /app/load-minio-credentials.sh
RUN chown -R palmr:nodejs /app/server-start.sh /app/start-minio.sh /app/minio-setup.sh /app/load-minio-credentials.sh /app/infra
# Copy supervisor configuration
COPY infra/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
@@ -144,9 +157,42 @@ 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
mkdir -p /app/server/uploads /app/server/temp-uploads /app/server/prisma
mkdir -p /app/server/uploads /app/server/temp-uploads /app/server/prisma /app/server/minio-data
echo "Data directories ready for first run..."
# CRITICAL: Fix permissions BEFORE starting any services
# This runs on EVERY startup to handle updates and corrupted metadata
echo "🔐 Fixing permissions for internal storage..."
# DYNAMIC: Detect palmr user's actual UID and GID
# Works with any Docker --user configuration
PALMR_UID=\$(id -u palmr 2>/dev/null || echo "1001")
PALMR_GID=\$(id -g palmr 2>/dev/null || echo "1001")
echo " Target user: palmr (UID:\$PALMR_UID, GID:\$PALMR_GID)"
# ALWAYS remove storage system metadata to prevent corruption issues
# This is safe - storage system recreates it automatically
# User data (files) are NOT in .minio.sys, they're safe
if [ -d "/app/server/minio-data/.minio.sys" ]; then
echo " 🧹 Cleaning storage system metadata (safe, auto-regenerated)..."
rm -rf /app/server/minio-data/.minio.sys 2>/dev/null || true
fi
# Fix ownership and permissions (safe for updates)
echo " 🔧 Setting ownership and permissions..."
chown -R \$PALMR_UID:\$PALMR_GID /app/server 2>/dev/null || echo " ⚠️ chown skipped"
chmod -R 755 /app/server 2>/dev/null || echo " ⚠️ chmod skipped"
# Verify critical directories are writable
if touch /app/server/.test-write 2>/dev/null; then
rm -f /app/server/.test-write
echo " ✅ Storage directory is writable"
else
echo " ❌ FATAL: /app/server is NOT writable!"
echo " Check Docker volume permissions"
ls -la /app/server 2>/dev/null || true
fi
echo "✅ Storage ready, starting services..."
# Start supervisor
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
@@ -158,7 +204,7 @@ RUN chmod +x /app/start.sh
VOLUME ["/app/server"]
# Expose ports
EXPOSE 3333 5487
EXPOSE 3333 5487 9379 9378
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \

View File

@@ -1,6 +1,6 @@
{
"name": "palmr-docs",
"version": "3.2.4-beta",
"version": "3.2.5-beta",
"description": "Docs for Palmr",
"private": true,
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",

View File

@@ -1,6 +1,6 @@
{
"name": "palmr-api",
"version": "3.2.4-beta",
"version": "3.2.5-beta",
"description": "API for Palmr",
"private": true,
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",

View File

@@ -1,10 +1,57 @@
import * as fs from "fs";
import process from "node:process";
import { S3Client } from "@aws-sdk/client-s3";
import { env } from "../env";
import { StorageConfig } from "../types/storage";
export const storageConfig: StorageConfig = {
/**
* Load internal storage credentials if they exist
* This provides S3-compatible storage automatically when ENABLE_S3=false
*/
function loadInternalStorageCredentials(): Partial<StorageConfig> | null {
const credentialsPath = "/app/server/.minio-credentials";
try {
if (fs.existsSync(credentialsPath)) {
const content = fs.readFileSync(credentialsPath, "utf-8");
const credentials: any = {};
content.split("\n").forEach((line) => {
const [key, value] = line.split("=");
if (key && value) {
credentials[key.trim()] = value.trim();
}
});
console.log("[STORAGE] Using internal storage system");
return {
endpoint: credentials.S3_ENDPOINT || "127.0.0.1",
port: parseInt(credentials.S3_PORT || "9379", 10),
useSSL: credentials.S3_USE_SSL === "true",
accessKey: credentials.S3_ACCESS_KEY,
secretKey: credentials.S3_SECRET_KEY,
region: credentials.S3_REGION || "default",
bucketName: credentials.S3_BUCKET_NAME || "palmr-files",
forcePathStyle: true,
};
}
} catch (error) {
console.warn("[STORAGE] Could not load internal storage credentials:", error);
}
return null;
}
/**
* Storage configuration:
* - Default (ENABLE_S3=false or not set): Internal storage (auto-configured, zero config)
* - ENABLE_S3=true: External S3 (AWS, S3-compatible, etc) using env vars
*/
const internalStorageConfig = env.ENABLE_S3 === "true" ? null : loadInternalStorageCredentials();
export const storageConfig: StorageConfig = (internalStorageConfig as StorageConfig) || {
endpoint: env.S3_ENDPOINT || "",
port: env.S3_PORT ? Number(env.S3_PORT) : undefined,
useSSL: env.S3_USE_SSL === "true",
@@ -23,21 +70,74 @@ if (storageConfig.useSSL && env.S3_REJECT_UNAUTHORIZED === "false") {
}
}
export const s3Client =
env.ENABLE_S3 === "true"
? new S3Client({
endpoint: storageConfig.useSSL
? `https://${storageConfig.endpoint}${storageConfig.port ? `:${storageConfig.port}` : ""}`
: `http://${storageConfig.endpoint}${storageConfig.port ? `:${storageConfig.port}` : ""}`,
region: storageConfig.region,
credentials: {
accessKeyId: storageConfig.accessKey,
secretAccessKey: storageConfig.secretKey,
},
forcePathStyle: storageConfig.forcePathStyle,
})
: null;
/**
* Storage is ALWAYS S3-compatible:
* - ENABLE_S3=false → Internal storage (automatic)
* - ENABLE_S3=true → External S3 (AWS, S3-compatible, etc)
*/
const hasValidConfig = storageConfig.endpoint && storageConfig.accessKey && storageConfig.secretKey;
export const s3Client = hasValidConfig
? new S3Client({
endpoint: storageConfig.useSSL
? `https://${storageConfig.endpoint}${storageConfig.port ? `:${storageConfig.port}` : ""}`
: `http://${storageConfig.endpoint}${storageConfig.port ? `:${storageConfig.port}` : ""}`,
region: storageConfig.region,
credentials: {
accessKeyId: storageConfig.accessKey,
secretAccessKey: storageConfig.secretKey,
},
forcePathStyle: storageConfig.forcePathStyle,
})
: null;
export const bucketName = storageConfig.bucketName;
export const isS3Enabled = env.ENABLE_S3 === "true";
/**
* Storage is always S3-compatible
* ENABLE_S3=true means EXTERNAL S3, otherwise uses internal storage
*/
export const isS3Enabled = s3Client !== null;
export const isExternalS3 = env.ENABLE_S3 === "true";
export const isInternalStorage = s3Client !== null && env.ENABLE_S3 !== "true";
/**
* Creates a public S3 client for presigned URL generation.
* - Internal storage (ENABLE_S3=false): Uses STORAGE_URL (e.g., https://syrg.palmr.com)
* - External S3 (ENABLE_S3=true): Uses the original S3 endpoint configuration
*
* @returns S3Client configured with public endpoint, or null if S3 is disabled
*/
export function createPublicS3Client(): S3Client | null {
if (!s3Client) {
return null;
}
let publicEndpoint: string;
if (isInternalStorage) {
// Internal storage: use STORAGE_URL
if (!env.STORAGE_URL) {
throw new Error(
"[STORAGE] STORAGE_URL environment variable is required when using internal storage (ENABLE_S3=false). " +
"Set STORAGE_URL to your public storage URL with protocol (e.g., https://syrg.palmr.com or http://192.168.1.100:9379)"
);
}
publicEndpoint = env.STORAGE_URL;
} else {
// External S3: use the original endpoint configuration
publicEndpoint = storageConfig.useSSL
? `https://${storageConfig.endpoint}${storageConfig.port ? `:${storageConfig.port}` : ""}`
: `http://${storageConfig.endpoint}${storageConfig.port ? `:${storageConfig.port}` : ""}`;
}
return new S3Client({
endpoint: publicEndpoint,
region: storageConfig.region,
credentials: {
accessKeyId: storageConfig.accessKey,
secretAccessKey: storageConfig.secretKey,
},
forcePathStyle: storageConfig.forcePathStyle,
});
}

View File

@@ -1,9 +1,8 @@
import { z } from "zod";
const envSchema = z.object({
// Storage configuration
ENABLE_S3: z.union([z.literal("true"), z.literal("false")]).default("false"),
ENCRYPTION_KEY: z.string().optional(),
DISABLE_FILESYSTEM_ENCRYPTION: z.union([z.literal("true"), z.literal("false")]).default("true"),
S3_ENDPOINT: z.string().optional(),
S3_PORT: z.string().optional(),
S3_USE_SSL: z.string().optional(),
@@ -13,26 +12,16 @@ const envSchema = z.object({
S3_BUCKET_NAME: z.string().optional(),
S3_FORCE_PATH_STYLE: z.union([z.literal("true"), z.literal("false")]).default("false"),
S3_REJECT_UNAUTHORIZED: z.union([z.literal("true"), z.literal("false")]).default("true"),
// Legacy encryption vars (kept for backward compatibility but not used with S3/Garage)
ENCRYPTION_KEY: z.string().optional(),
DISABLE_FILESYSTEM_ENCRYPTION: z.union([z.literal("true"), z.literal("false")]).default("true"),
// Application configuration
PRESIGNED_URL_EXPIRATION: z.string().optional().default("3600"),
SECURE_SITE: z.union([z.literal("true"), z.literal("false")]).default("false"),
STORAGE_URL: z.string().optional(), // Storage URL for internal storage presigned URLs (required when ENABLE_S3=false, e.g., https://syrg.palmr.com or http://192.168.1.100:9379)
DATABASE_URL: z.string().optional().default("file:/app/server/prisma/palmr.db"),
DOWNLOAD_MAX_CONCURRENT: z
.string()
.optional()
.transform((val) => (val ? parseInt(val, 10) : undefined)),
DOWNLOAD_MEMORY_THRESHOLD_MB: z
.string()
.optional()
.transform((val) => (val ? parseInt(val, 10) : undefined)),
DOWNLOAD_QUEUE_SIZE: z
.string()
.optional()
.transform((val) => (val ? parseInt(val, 10) : undefined)),
DOWNLOAD_AUTO_SCALE: z.union([z.literal("true"), z.literal("false")]).default("true"),
DOWNLOAD_MIN_FILE_SIZE_GB: z
.string()
.optional()
.transform((val) => (val ? parseFloat(val) : undefined)),
CUSTOM_PATH: z.string().optional(),
});

View File

@@ -1,4 +1,3 @@
import { isS3Enabled } from "../../config/storage.config";
import { prisma } from "../../shared/prisma";
import { ConfigService } from "../config/service";
@@ -23,8 +22,8 @@ export class AppService {
async getSystemInfo() {
return {
storageProvider: isS3Enabled ? "s3" : "filesystem",
s3Enabled: isS3Enabled,
storageProvider: "s3",
s3Enabled: true,
};
}

View File

@@ -617,6 +617,11 @@ export class AuthProvidersService {
return await this.linkProviderToExistingUser(existingUser, provider.id, String(externalId), userInfo);
}
// Check if auto-registration is disabled
if (provider.autoRegister === false) {
throw new Error(`User registration via ${provider.displayName || provider.name} is disabled`);
}
return await this.createNewUserWithProvider(userInfo, provider.id, String(externalId));
}

View File

@@ -1,4 +1,3 @@
import * as fs from "fs";
import bcrypt from "bcryptjs";
import { FastifyReply, FastifyRequest } from "fastify";
@@ -29,31 +28,30 @@ export class FileController {
private fileService = new FileService();
private configService = new ConfigService();
async getPresignedUrl(request: FastifyRequest, reply: FastifyReply) {
try {
const { filename, extension } = request.query as {
filename?: string;
extension?: string;
};
if (!filename || !extension) {
return reply.status(400).send({
error: "The 'filename' and 'extension' parameters are required.",
});
}
async getPresignedUrl(request: FastifyRequest, reply: FastifyReply): Promise<void> {
const { filename, extension } = request.query as { filename: string; extension: string };
if (!filename || !extension) {
return reply.status(400).send({ error: "filename and extension are required" });
}
try {
// JWT already verified by preValidation in routes.ts
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
return reply.status(401).send({ error: "Unauthorized" });
}
const objectName = `${userId}/${Date.now()}-${filename}.${extension}`;
// Generate unique object name
const objectName = `${userId}/${Date.now()}-${Math.random().toString(36).substring(7)}-${filename}.${extension}`;
const expires = parseInt(env.PRESIGNED_URL_EXPIRATION);
const url = await this.fileService.getPresignedPutUrl(objectName, expires);
return reply.send({ url, objectName });
return reply.status(200).send({ url, objectName });
} catch (error) {
console.error("Error in getPresignedUrl:", error);
return reply.status(500).send({ error: "Internal server error." });
return reply.status(500).send({ error: "Internal server error" });
}
}
@@ -219,9 +217,6 @@ export class FileController {
let hasAccess = false;
// Don't log raw passwords. Log only whether a password was provided (for debugging access flow).
console.log(`Requested file access for object="${objectName}" passwordProvided=${password ? true : false}`);
const shares = await prisma.share.findMany({
where: {
files: {
@@ -264,6 +259,8 @@ export class FileController {
const fileName = fileRecord.name;
const expires = parseInt(env.PRESIGNED_URL_EXPIRATION);
// Always use presigned URLs (works for both internal and external storage)
const url = await this.fileService.getPresignedGetUrl(objectName, expires, fileName);
return reply.send({ url, expiresIn: expires });
} catch (error) {
@@ -309,16 +306,14 @@ export class FileController {
return reply.status(401).send({ error: "Unauthorized access to file." });
}
const storageProvider = (this.fileService as any).storageProvider;
const filePath = storageProvider.getFilePath(objectName);
// Stream from S3/storage system
const stream = await this.fileService.getObjectStream(objectName);
const contentType = getContentType(reverseShareFile.name);
const fileName = reverseShareFile.name;
reply.header("Content-Type", contentType);
reply.header("Content-Disposition", `inline; filename="${encodeURIComponent(fileName)}"`);
const stream = fs.createReadStream(filePath);
return reply.send(stream);
}
@@ -367,16 +362,14 @@ export class FileController {
return reply.status(401).send({ error: "Unauthorized access to file." });
}
const storageProvider = (this.fileService as any).storageProvider;
const filePath = storageProvider.getFilePath(objectName);
// Stream from S3/MinIO
const stream = await this.fileService.getObjectStream(objectName);
const contentType = getContentType(fileRecord.name);
const fileName = fileRecord.name;
reply.header("Content-Type", contentType);
reply.header("Content-Disposition", `inline; filename="${encodeURIComponent(fileName)}"`);
const stream = fs.createReadStream(filePath);
return reply.send(stream);
} catch (error) {
console.error("Error in downloadFile:", error);
@@ -585,6 +578,49 @@ export class FileController {
}
}
async embedFile(request: FastifyRequest, reply: FastifyReply) {
try {
const { id } = request.params as { id: string };
if (!id) {
return reply.status(400).send({ error: "File ID is required." });
}
const fileRecord = await prisma.file.findUnique({ where: { id } });
if (!fileRecord) {
return reply.status(404).send({ error: "File not found." });
}
const extension = fileRecord.extension.toLowerCase();
const imageExts = ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico", "avif"];
const videoExts = ["mp4", "webm", "ogg", "mov", "avi", "mkv", "flv", "wmv"];
const audioExts = ["mp3", "wav", "ogg", "m4a", "flac", "aac", "wma"];
const isMedia = imageExts.includes(extension) || videoExts.includes(extension) || audioExts.includes(extension);
if (!isMedia) {
return reply.status(403).send({
error: "Embed is only allowed for images, videos, and audio files.",
});
}
// Stream from S3/MinIO
const stream = await this.fileService.getObjectStream(fileRecord.objectName);
const contentType = getContentType(fileRecord.name);
const fileName = fileRecord.name;
reply.header("Content-Type", contentType);
reply.header("Content-Disposition", `inline; filename="${encodeURIComponent(fileName)}"`);
reply.header("Cache-Control", "public, max-age=31536000"); // Cache por 1 ano
return reply.send(stream);
} catch (error) {
console.error("Error in embedFile:", error);
return reply.status(500).send({ error: "Internal server error." });
}
}
private async getAllUserFilesRecursively(userId: string): Promise<any[]> {
const rootFiles = await prisma.file.findMany({
where: { userId, folderId: null },
@@ -609,4 +645,123 @@ export class FileController {
return allFiles;
}
// Multipart upload endpoints
async createMultipartUpload(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized" });
}
const { filename, extension } = request.body as { filename: string; extension: string };
if (!filename || !extension) {
return reply.status(400).send({ error: "filename and extension are required" });
}
// Generate unique object name (same pattern as simple upload)
const objectName = `${userId}/${Date.now()}-${Math.random().toString(36).substring(7)}-${filename}.${extension}`;
const uploadId = await this.fileService.createMultipartUpload(objectName);
return reply.status(200).send({
uploadId,
objectName,
message: "Multipart upload initialized",
});
} catch (error) {
console.error("[Multipart] Error creating multipart upload:", error);
return reply.status(500).send({ error: "Failed to create multipart upload" });
}
}
async getMultipartPartUrl(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized" });
}
const { uploadId, objectName, partNumber } = request.query as {
uploadId: string;
objectName: string;
partNumber: string;
};
if (!uploadId || !objectName || !partNumber) {
return reply.status(400).send({ error: "uploadId, objectName, and partNumber are required" });
}
const partNum = parseInt(partNumber);
if (isNaN(partNum) || partNum < 1 || partNum > 10000) {
return reply.status(400).send({ error: "partNumber must be between 1 and 10000" });
}
const expires = parseInt(env.PRESIGNED_URL_EXPIRATION);
const url = await this.fileService.getPresignedPartUrl(objectName, uploadId, partNum, expires);
return reply.status(200).send({ url });
} catch (error) {
console.error("[Multipart] Error getting part URL:", error);
return reply.status(500).send({ error: "Failed to get presigned URL for part" });
}
}
async completeMultipartUpload(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized" });
}
const { uploadId, objectName, parts } = request.body as {
uploadId: string;
objectName: string;
parts: Array<{ PartNumber: number; ETag: string }>;
};
if (!uploadId || !objectName || !parts || !Array.isArray(parts)) {
return reply.status(400).send({ error: "uploadId, objectName, and parts are required" });
}
await this.fileService.completeMultipartUpload(objectName, uploadId, parts);
return reply.status(200).send({
message: "Multipart upload completed successfully",
objectName,
});
} catch (error) {
console.error("[Multipart] Error completing multipart upload:", error);
return reply.status(500).send({ error: "Failed to complete multipart upload" });
}
}
async abortMultipartUpload(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized" });
}
const { uploadId, objectName } = request.body as {
uploadId: string;
objectName: string;
};
if (!uploadId || !objectName) {
return reply.status(400).send({ error: "uploadId and objectName are required" });
}
await this.fileService.abortMultipartUpload(objectName, uploadId);
return reply.status(200).send({
message: "Multipart upload aborted successfully",
});
} catch (error) {
console.error("[Multipart] Error aborting multipart upload:", error);
return reply.status(500).send({ error: "Failed to abort multipart upload" });
}
}
}

View File

@@ -131,6 +131,29 @@ export async function fileRoutes(app: FastifyInstance) {
fileController.getDownloadUrl.bind(fileController)
);
app.get(
"/embed/:id",
{
schema: {
tags: ["File"],
operationId: "embedFile",
summary: "Embed File (Public Access)",
description:
"Returns a media file (image/video/audio) for public embedding without authentication. Only works for media files.",
params: z.object({
id: z.string().min(1, "File ID is required").describe("The file ID"),
}),
response: {
400: z.object({ error: z.string().describe("Error message") }),
403: z.object({ error: z.string().describe("Error message - not a media file") }),
404: z.object({ error: z.string().describe("Error message") }),
500: z.object({ error: z.string().describe("Error message") }),
},
},
},
fileController.embedFile.bind(fileController)
);
app.get(
"/files/download",
{
@@ -286,4 +309,122 @@ export async function fileRoutes(app: FastifyInstance) {
},
fileController.deleteFile.bind(fileController)
);
// Multipart upload routes
app.post(
"/files/multipart/create",
{
preValidation,
schema: {
tags: ["File"],
operationId: "createMultipartUpload",
summary: "Create Multipart Upload",
description:
"Initializes a multipart upload for large files (≥100MB). Returns uploadId for subsequent part uploads.",
body: z.object({
filename: z.string().min(1).describe("The filename without extension"),
extension: z.string().min(1).describe("The file extension"),
}),
response: {
200: z.object({
uploadId: z.string().describe("The upload ID for this multipart upload"),
objectName: z.string().describe("The object name in storage"),
message: z.string().describe("Success message"),
}),
400: z.object({ error: z.string() }),
401: z.object({ error: z.string() }),
500: z.object({ error: z.string() }),
},
},
},
fileController.createMultipartUpload.bind(fileController)
);
app.get(
"/files/multipart/part-url",
{
preValidation,
schema: {
tags: ["File"],
operationId: "getMultipartPartUrl",
summary: "Get Presigned URL for Part",
description: "Gets a presigned URL for uploading a specific part of a multipart upload",
querystring: z.object({
uploadId: z.string().min(1).describe("The multipart upload ID"),
objectName: z.string().min(1).describe("The object name"),
partNumber: z.string().min(1).describe("The part number (1-10000)"),
}),
response: {
200: z.object({
url: z.string().describe("The presigned URL for uploading this part"),
}),
400: z.object({ error: z.string() }),
401: z.object({ error: z.string() }),
500: z.object({ error: z.string() }),
},
},
},
fileController.getMultipartPartUrl.bind(fileController)
);
app.post(
"/files/multipart/complete",
{
preValidation,
schema: {
tags: ["File"],
operationId: "completeMultipartUpload",
summary: "Complete Multipart Upload",
description: "Completes a multipart upload by combining all uploaded parts",
body: z.object({
uploadId: z.string().min(1).describe("The multipart upload ID"),
objectName: z.string().min(1).describe("The object name"),
parts: z
.array(
z.object({
PartNumber: z.number().min(1).max(10000).describe("The part number"),
ETag: z.string().min(1).describe("The ETag returned from uploading the part"),
})
)
.describe("Array of uploaded parts"),
}),
response: {
200: z.object({
message: z.string().describe("Success message"),
objectName: z.string().describe("The completed object name"),
}),
400: z.object({ error: z.string() }),
401: z.object({ error: z.string() }),
500: z.object({ error: z.string() }),
},
},
},
fileController.completeMultipartUpload.bind(fileController)
);
app.post(
"/files/multipart/abort",
{
preValidation,
schema: {
tags: ["File"],
operationId: "abortMultipartUpload",
summary: "Abort Multipart Upload",
description: "Aborts a multipart upload and cleans up all uploaded parts",
body: z.object({
uploadId: z.string().min(1).describe("The multipart upload ID"),
objectName: z.string().min(1).describe("The object name"),
}),
response: {
200: z.object({
message: z.string().describe("Success message"),
}),
400: z.object({ error: z.string() }),
401: z.object({ error: z.string() }),
500: z.object({ error: z.string() }),
},
},
},
fileController.abortMultipartUpload.bind(fileController)
);
}

View File

@@ -1,5 +1,3 @@
import { isS3Enabled } from "../../config/storage.config";
import { FilesystemStorageProvider } from "../../providers/filesystem-storage.provider";
import { S3StorageProvider } from "../../providers/s3-storage.provider";
import { StorageProvider } from "../../types/storage";
@@ -7,29 +5,16 @@ export class FileService {
private storageProvider: StorageProvider;
constructor() {
if (isS3Enabled) {
this.storageProvider = new S3StorageProvider();
} else {
this.storageProvider = FilesystemStorageProvider.getInstance();
}
// Always use S3 (Garage internal or external S3)
this.storageProvider = new S3StorageProvider();
}
async getPresignedPutUrl(objectName: string, expires: number): Promise<string> {
try {
return await this.storageProvider.getPresignedPutUrl(objectName, expires);
} catch (err) {
console.error("Erro no presignedPutObject:", err);
throw err;
}
async getPresignedPutUrl(objectName: string, expires: number = 3600): Promise<string> {
return await this.storageProvider.getPresignedPutUrl(objectName, expires);
}
async getPresignedGetUrl(objectName: string, expires: number, fileName?: string): Promise<string> {
try {
return await this.storageProvider.getPresignedGetUrl(objectName, expires, fileName);
} catch (err) {
console.error("Erro no presignedGetObject:", err);
throw err;
}
async getPresignedGetUrl(objectName: string, expires: number = 3600, fileName?: string): Promise<string> {
return await this.storageProvider.getPresignedGetUrl(objectName, expires, fileName);
}
async deleteObject(objectName: string): Promise<void> {
@@ -41,7 +26,38 @@ export class FileService {
}
}
isFilesystemMode(): boolean {
return !isS3Enabled;
async getObjectStream(objectName: string): Promise<NodeJS.ReadableStream> {
try {
return await this.storageProvider.getObjectStream(objectName);
} catch (err) {
console.error("Error getting object stream:", err);
throw err;
}
}
// Multipart upload methods
async createMultipartUpload(objectName: string): Promise<string> {
return await this.storageProvider.createMultipartUpload(objectName);
}
async getPresignedPartUrl(
objectName: string,
uploadId: string,
partNumber: number,
expires: number = 3600
): Promise<string> {
return await this.storageProvider.getPresignedPartUrl(objectName, uploadId, partNumber, expires);
}
async completeMultipartUpload(
objectName: string,
uploadId: string,
parts: Array<{ PartNumber: number; ETag: string }>
): Promise<void> {
await this.storageProvider.completeMultipartUpload(objectName, uploadId, parts);
}
async abortMultipartUpload(objectName: string, uploadId: string): Promise<void> {
await this.storageProvider.abortMultipartUpload(objectName, uploadId);
}
}

View File

@@ -1,345 +0,0 @@
import * as fs from "fs";
import * as path from "path";
import { getTempFilePath } from "../../config/directories.config";
import { FilesystemStorageProvider } from "../../providers/filesystem-storage.provider";
export interface ChunkMetadata {
fileId: string;
chunkIndex: number;
totalChunks: number;
chunkSize: number;
totalSize: number;
fileName: string;
isLastChunk: boolean;
}
export interface ChunkInfo {
fileId: string;
fileName: string;
totalSize: number;
totalChunks: number;
uploadedChunks: Set<number>;
tempPath: string;
createdAt: number;
}
export class ChunkManager {
private static instance: ChunkManager;
private activeUploads = new Map<string, ChunkInfo>();
private finalizingUploads = new Set<string>(); // Track uploads currently being finalized
private cleanupInterval: NodeJS.Timeout;
private constructor() {
// Cleanup expired uploads every 30 minutes
this.cleanupInterval = setInterval(
() => {
this.cleanupExpiredUploads();
},
30 * 60 * 1000
);
}
public static getInstance(): ChunkManager {
if (!ChunkManager.instance) {
ChunkManager.instance = new ChunkManager();
}
return ChunkManager.instance;
}
/**
* Process a chunk upload with streaming
*/
async processChunk(
metadata: ChunkMetadata,
inputStream: NodeJS.ReadableStream,
originalObjectName: string
): Promise<{ isComplete: boolean; finalPath?: string }> {
const startTime = Date.now();
const { fileId, chunkIndex, totalChunks, fileName, totalSize, isLastChunk } = metadata;
console.log(`Processing chunk ${chunkIndex + 1}/${totalChunks} for file ${fileName} (${fileId})`);
let chunkInfo = this.activeUploads.get(fileId);
if (!chunkInfo) {
if (chunkIndex !== 0) {
throw new Error("First chunk must be chunk 0");
}
const tempPath = getTempFilePath(fileId);
chunkInfo = {
fileId,
fileName,
totalSize,
totalChunks,
uploadedChunks: new Set(),
tempPath,
createdAt: Date.now(),
};
this.activeUploads.set(fileId, chunkInfo);
console.log(`Created new upload session for ${fileName} at ${tempPath}`);
}
console.log(
`Validating chunk ${chunkIndex} (total: ${totalChunks}, uploaded: ${Array.from(chunkInfo.uploadedChunks).join(",")})`
);
if (chunkIndex < 0 || chunkIndex >= totalChunks) {
throw new Error(`Invalid chunk index: ${chunkIndex} (must be 0-${totalChunks - 1})`);
}
if (chunkInfo.uploadedChunks.has(chunkIndex)) {
console.log(`Chunk ${chunkIndex} already uploaded, treating as success`);
if (isLastChunk && chunkInfo.uploadedChunks.size === totalChunks) {
if (this.finalizingUploads.has(fileId)) {
console.log(`Upload ${fileId} is already being finalized, waiting...`);
return { isComplete: false };
}
console.log(`All chunks uploaded, finalizing ${fileName}`);
return await this.finalizeUpload(chunkInfo, metadata, originalObjectName);
}
return { isComplete: false };
}
const tempDir = path.dirname(chunkInfo.tempPath);
await fs.promises.mkdir(tempDir, { recursive: true });
console.log(`Temp directory ensured: ${tempDir}`);
await this.writeChunkToFile(chunkInfo.tempPath, inputStream, chunkIndex === 0);
chunkInfo.uploadedChunks.add(chunkIndex);
try {
const stats = await fs.promises.stat(chunkInfo.tempPath);
const processingTime = Date.now() - startTime;
console.log(
`Chunk ${chunkIndex + 1}/${totalChunks} uploaded successfully in ${processingTime}ms. Temp file size: ${stats.size} bytes`
);
} catch (error) {
console.warn(`Could not get temp file stats:`, error);
}
console.log(
`Checking completion: isLastChunk=${isLastChunk}, uploadedChunks.size=${chunkInfo.uploadedChunks.size}, totalChunks=${totalChunks}`
);
if (isLastChunk && chunkInfo.uploadedChunks.size === totalChunks) {
if (this.finalizingUploads.has(fileId)) {
console.log(`Upload ${fileId} is already being finalized, waiting...`);
return { isComplete: false };
}
console.log(`All chunks uploaded, finalizing ${fileName}`);
const uploadedChunksArray = Array.from(chunkInfo.uploadedChunks).sort((a, b) => a - b);
console.log(`Uploaded chunks in order: ${uploadedChunksArray.join(", ")}`);
const expectedChunks = Array.from({ length: totalChunks }, (_, i) => i);
const missingChunks = expectedChunks.filter((chunk) => !chunkInfo.uploadedChunks.has(chunk));
if (missingChunks.length > 0) {
throw new Error(`Missing chunks: ${missingChunks.join(", ")}`);
}
return await this.finalizeUpload(chunkInfo, metadata, originalObjectName);
} else {
console.log(
`Not ready for finalization: isLastChunk=${isLastChunk}, uploadedChunks.size=${chunkInfo.uploadedChunks.size}, totalChunks=${totalChunks}`
);
}
return { isComplete: false };
}
/**
* Write chunk to file using streaming
*/
private async writeChunkToFile(
filePath: string,
inputStream: NodeJS.ReadableStream,
isFirstChunk: boolean
): Promise<void> {
return new Promise((resolve, reject) => {
console.log(`Writing chunk to ${filePath} (first: ${isFirstChunk})`);
if (isFirstChunk) {
const writeStream = fs.createWriteStream(filePath, {
highWaterMark: 64 * 1024 * 1024, // 64MB buffer for better performance
});
writeStream.on("error", (error) => {
console.error("Write stream error:", error);
reject(error);
});
writeStream.on("finish", () => {
console.log("Write stream finished successfully");
resolve();
});
inputStream.pipe(writeStream);
} else {
const writeStream = fs.createWriteStream(filePath, {
flags: "a",
highWaterMark: 64 * 1024 * 1024, // 64MB buffer for better performance
});
writeStream.on("error", (error) => {
console.error("Write stream error:", error);
reject(error);
});
writeStream.on("finish", () => {
console.log("Write stream finished successfully");
resolve();
});
inputStream.pipe(writeStream);
}
});
}
/**
* Finalize upload by moving temp file to final location and encrypting (if enabled)
*/
private async finalizeUpload(
chunkInfo: ChunkInfo,
metadata: ChunkMetadata,
originalObjectName: string
): Promise<{ isComplete: boolean; finalPath: string }> {
// Mark as finalizing to prevent race conditions
this.finalizingUploads.add(chunkInfo.fileId);
try {
console.log(`Finalizing upload for ${chunkInfo.fileName}`);
const tempStats = await fs.promises.stat(chunkInfo.tempPath);
console.log(`Temp file size: ${tempStats.size} bytes, expected: ${chunkInfo.totalSize} bytes`);
if (tempStats.size !== chunkInfo.totalSize) {
console.warn(`Size mismatch! Temp: ${tempStats.size}, Expected: ${chunkInfo.totalSize}`);
}
const provider = FilesystemStorageProvider.getInstance();
const finalObjectName = originalObjectName;
const filePath = provider.getFilePath(finalObjectName);
const dir = path.dirname(filePath);
console.log(`Starting finalization: ${finalObjectName}`);
await fs.promises.mkdir(dir, { recursive: true });
const tempReadStream = fs.createReadStream(chunkInfo.tempPath, {
highWaterMark: 64 * 1024 * 1024, // 64MB buffer for better performance
});
const writeStream = fs.createWriteStream(filePath, {
highWaterMark: 64 * 1024 * 1024,
});
const encryptStream = provider.createEncryptStream();
await new Promise<void>((resolve, reject) => {
const startTime = Date.now();
tempReadStream
.pipe(encryptStream)
.pipe(writeStream)
.on("finish", () => {
const duration = Date.now() - startTime;
console.log(`File processed and saved to: ${filePath} in ${duration}ms`);
resolve();
})
.on("error", (error) => {
console.error("Error during processing:", error);
reject(error);
});
});
console.log(`File successfully uploaded and processed: ${finalObjectName}`);
await this.cleanupTempFile(chunkInfo.tempPath);
this.activeUploads.delete(chunkInfo.fileId);
this.finalizingUploads.delete(chunkInfo.fileId);
return { isComplete: true, finalPath: finalObjectName };
} catch (error) {
console.error("Error during finalization:", error);
await this.cleanupTempFile(chunkInfo.tempPath);
this.activeUploads.delete(chunkInfo.fileId);
this.finalizingUploads.delete(chunkInfo.fileId);
throw error;
}
}
/**
* Cleanup temporary file
*/
private async cleanupTempFile(tempPath: string): Promise<void> {
try {
await fs.promises.access(tempPath);
await fs.promises.unlink(tempPath);
console.log(`Temp file cleaned up: ${tempPath}`);
} catch (error: any) {
if (error.code === "ENOENT") {
console.log(`Temp file already cleaned up: ${tempPath}`);
} else {
console.warn(`Failed to cleanup temp file ${tempPath}:`, error);
}
}
}
/**
* Cleanup expired uploads (older than 2 hours)
*/
private async cleanupExpiredUploads(): Promise<void> {
const now = Date.now();
const maxAge = 2 * 60 * 60 * 1000; // 2 hours
for (const [fileId, chunkInfo] of this.activeUploads.entries()) {
if (now - chunkInfo.createdAt > maxAge) {
console.log(`Cleaning up expired upload: ${fileId}`);
await this.cleanupTempFile(chunkInfo.tempPath);
this.activeUploads.delete(fileId);
this.finalizingUploads.delete(fileId);
}
}
}
/**
* Get upload progress
*/
getUploadProgress(fileId: string): { uploaded: number; total: number; percentage: number } | null {
const chunkInfo = this.activeUploads.get(fileId);
if (!chunkInfo) return null;
return {
uploaded: chunkInfo.uploadedChunks.size,
total: chunkInfo.totalChunks,
percentage: Math.round((chunkInfo.uploadedChunks.size / chunkInfo.totalChunks) * 100),
};
}
/**
* Cancel upload
*/
async cancelUpload(fileId: string): Promise<void> {
const chunkInfo = this.activeUploads.get(fileId);
if (chunkInfo) {
await this.cleanupTempFile(chunkInfo.tempPath);
this.activeUploads.delete(fileId);
this.finalizingUploads.delete(fileId);
}
}
/**
* Cleanup on shutdown
*/
destroy(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
for (const [fileId, chunkInfo] of this.activeUploads.entries()) {
this.cleanupTempFile(chunkInfo.tempPath);
}
this.activeUploads.clear();
this.finalizingUploads.clear();
}
}

View File

@@ -1,444 +0,0 @@
import * as fs from "fs";
import { pipeline } from "stream/promises";
import { FastifyReply, FastifyRequest } from "fastify";
import { FilesystemStorageProvider } from "../../providers/filesystem-storage.provider";
import { DownloadCancelResponse, QueueClearResponse, QueueStatusResponse } from "../../types/download-queue";
import { DownloadMemoryManager } from "../../utils/download-memory-manager";
import { getContentType } from "../../utils/mime-types";
import { ChunkManager, ChunkMetadata } from "./chunk-manager";
export class FilesystemController {
private chunkManager = ChunkManager.getInstance();
private memoryManager = DownloadMemoryManager.getInstance();
/**
* Check if a character is valid in an HTTP token (RFC 2616)
* Tokens can contain: alphanumeric and !#$%&'*+-.^_`|~
* Must exclude separators: ()<>@,;:\"/[]?={} and space/tab
*/
private isTokenChar(char: string): boolean {
const code = char.charCodeAt(0);
// Basic ASCII range check
if (code < 33 || code > 126) return false;
// Exclude separator characters per RFC 2616
const separators = '()<>@,;:\\"/[]?={} \t';
return !separators.includes(char);
}
private encodeFilenameForHeader(filename: string): string {
if (!filename || filename.trim() === "") {
return 'attachment; filename="download"';
}
let sanitized = filename
.replace(/"/g, "'")
.replace(/[\r\n\t\v\f]/g, "")
.replace(/[\\|/]/g, "-")
.replace(/[<>:|*?]/g, "");
sanitized = sanitized
.split("")
.filter((char) => {
const code = char.charCodeAt(0);
return code >= 32 && !(code >= 127 && code <= 159);
})
.join("")
.trim();
if (!sanitized) {
return 'attachment; filename="download"';
}
// Create ASCII-safe version with only valid token characters
const asciiSafe = sanitized
.split("")
.filter((char) => this.isTokenChar(char))
.join("");
if (asciiSafe && asciiSafe.trim()) {
const encoded = encodeURIComponent(sanitized);
return `attachment; filename="${asciiSafe}"; filename*=UTF-8''${encoded}`;
} else {
const encoded = encodeURIComponent(sanitized);
return `attachment; filename*=UTF-8''${encoded}`;
}
}
async upload(request: FastifyRequest, reply: FastifyReply) {
try {
const { token } = request.params as { token: string };
const provider = FilesystemStorageProvider.getInstance();
const tokenData = provider.validateUploadToken(token);
if (!tokenData) {
return reply.status(400).send({ error: "Invalid or expired upload token" });
}
const chunkMetadata = this.extractChunkMetadata(request);
if (chunkMetadata) {
try {
const result = await this.handleChunkedUpload(request, chunkMetadata, tokenData.objectName);
if (result.isComplete) {
reply.status(200).send({
message: "File uploaded successfully",
objectName: result.finalPath,
finalObjectName: result.finalPath,
});
} else {
reply.status(200).send({
message: "Chunk uploaded successfully",
progress: this.chunkManager.getUploadProgress(chunkMetadata.fileId),
});
}
} catch (chunkError: any) {
return reply.status(400).send({
error: chunkError.message || "Chunked upload failed",
details: chunkError.toString(),
});
}
} else {
await this.uploadFileStream(request, provider, tokenData.objectName);
reply.status(200).send({ message: "File uploaded successfully" });
}
} catch (error) {
return reply.status(500).send({ error: "Internal server error" });
}
}
private async uploadFileStream(request: FastifyRequest, provider: FilesystemStorageProvider, objectName: string) {
await provider.uploadFileFromStream(objectName, request.raw);
}
private extractChunkMetadata(request: FastifyRequest): ChunkMetadata | null {
const fileId = request.headers["x-file-id"] as string;
const chunkIndex = request.headers["x-chunk-index"] as string;
const totalChunks = request.headers["x-total-chunks"] as string;
const chunkSize = request.headers["x-chunk-size"] as string;
const totalSize = request.headers["x-total-size"] as string;
const encodedFileName = request.headers["x-file-name"] as string;
const isLastChunk = request.headers["x-is-last-chunk"] as string;
if (!fileId || !chunkIndex || !totalChunks || !chunkSize || !totalSize || !encodedFileName) {
return null;
}
// Decode the base64-encoded filename to handle UTF-8 characters
let fileName: string;
try {
fileName = decodeURIComponent(escape(Buffer.from(encodedFileName, "base64").toString("binary")));
} catch (error) {
// Fallback to the encoded value if decoding fails (for backward compatibility)
fileName = encodedFileName;
}
const metadata = {
fileId,
chunkIndex: parseInt(chunkIndex, 10),
totalChunks: parseInt(totalChunks, 10),
chunkSize: parseInt(chunkSize, 10),
totalSize: parseInt(totalSize, 10),
fileName,
isLastChunk: isLastChunk === "true",
};
return metadata;
}
private async handleChunkedUpload(request: FastifyRequest, metadata: ChunkMetadata, originalObjectName: string) {
const stream = request.raw;
stream.on("error", (error) => {
console.error("Request stream error:", error);
});
return await this.chunkManager.processChunk(metadata, stream, originalObjectName);
}
async getUploadProgress(request: FastifyRequest, reply: FastifyReply) {
try {
const { fileId } = request.params as { fileId: string };
const progress = this.chunkManager.getUploadProgress(fileId);
if (!progress) {
return reply.status(404).send({ error: "Upload not found" });
}
reply.status(200).send(progress);
} catch (error) {
return reply.status(500).send({ error: "Internal server error" });
}
}
async cancelUpload(request: FastifyRequest, reply: FastifyReply) {
try {
const { fileId } = request.params as { fileId: string };
await this.chunkManager.cancelUpload(fileId);
reply.status(200).send({ message: "Upload cancelled successfully" });
} catch (error) {
return reply.status(500).send({ error: "Internal server error" });
}
}
async download(request: FastifyRequest, reply: FastifyReply) {
const downloadId = `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
try {
const { token } = request.params as { token: string };
const provider = FilesystemStorageProvider.getInstance();
const tokenData = provider.validateDownloadToken(token);
if (!tokenData) {
return reply.status(400).send({ error: "Invalid or expired download token" });
}
const filePath = provider.getFilePath(tokenData.objectName);
const fileExists = await provider.fileExists(tokenData.objectName);
if (!fileExists) {
console.error(`[DOWNLOAD] File not found: ${tokenData.objectName}`);
return reply.status(404).send({
error: "File not found",
message:
"The requested file does not exist on the server. It may have been deleted or the upload was incomplete.",
});
}
const stats = await fs.promises.stat(filePath);
const fileSize = stats.size;
const fileName = tokenData.fileName || "download";
const fileSizeMB = fileSize / (1024 * 1024);
console.log(`[DOWNLOAD] Requesting slot for ${downloadId}: ${tokenData.objectName} (${fileSizeMB.toFixed(1)}MB)`);
try {
await this.memoryManager.requestDownloadSlot(downloadId, {
fileName,
fileSize,
objectName: tokenData.objectName,
});
} catch (error: any) {
console.warn(`[DOWNLOAD] Queue full for ${downloadId}: ${error.message}`);
return reply.status(503).send({
error: "Download queue is full",
message: error.message,
retryAfter: 60,
});
}
console.log(`[DOWNLOAD] Starting ${downloadId}: ${tokenData.objectName} (${fileSizeMB.toFixed(1)}MB)`);
this.memoryManager.startDownload(downloadId);
const range = request.headers.range;
reply.header("Content-Disposition", this.encodeFilenameForHeader(fileName));
reply.header("Content-Type", getContentType(fileName));
reply.header("Accept-Ranges", "bytes");
reply.header("X-Download-ID", downloadId);
reply.raw.on("close", () => {
this.memoryManager.endDownload(downloadId);
console.log(`[DOWNLOAD] Client disconnected: ${downloadId}`);
});
reply.raw.on("error", () => {
this.memoryManager.endDownload(downloadId);
console.log(`[DOWNLOAD] Client error: ${downloadId}`);
});
try {
if (range) {
const parts = range.replace(/bytes=/, "").split("-");
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
reply.status(206);
reply.header("Content-Range", `bytes ${start}-${end}/${fileSize}`);
reply.header("Content-Length", end - start + 1);
await this.downloadFileRange(reply, provider, tokenData.objectName, start, end, downloadId);
} else {
reply.header("Content-Length", fileSize);
await this.downloadFileStream(reply, provider, tokenData.objectName, downloadId);
}
} finally {
this.memoryManager.endDownload(downloadId);
}
} catch (error) {
this.memoryManager.endDownload(downloadId);
console.error(`[DOWNLOAD] Error in ${downloadId}:`, error);
return reply.status(500).send({ error: "Internal server error" });
}
}
private async downloadFileStream(
reply: FastifyReply,
provider: FilesystemStorageProvider,
objectName: string,
downloadId?: string
) {
try {
FilesystemStorageProvider.logMemoryUsage(`Download start: ${objectName} (${downloadId})`);
const downloadStream = provider.createDownloadStream(objectName);
downloadStream.on("error", (error) => {
console.error("Download stream error:", error);
FilesystemStorageProvider.logMemoryUsage(`Download error: ${objectName} (${downloadId})`);
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} (${downloadId})`);
});
if (this.memoryManager.shouldThrottleStream()) {
console.log(
`[MEMORY THROTTLE] ${objectName} - Pausing stream due to high memory usage: ${this.memoryManager.getCurrentMemoryUsageMB().toFixed(0)}MB`
);
const { Transform } = require("stream");
const memoryManager = this.memoryManager;
const throttleStream = new Transform({
highWaterMark: 256 * 1024,
transform(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null, data?: any) => void) {
if (memoryManager.shouldThrottleStream()) {
setImmediate(() => {
this.push(chunk);
callback();
});
} else {
this.push(chunk);
callback();
}
},
});
await pipeline(downloadStream, throttleStream, reply.raw);
} else {
await pipeline(downloadStream, reply.raw);
}
FilesystemStorageProvider.logMemoryUsage(`Download complete: ${objectName} (${downloadId})`);
} catch (error) {
console.error("Download error:", error);
FilesystemStorageProvider.logMemoryUsage(`Download failed: ${objectName} (${downloadId})`);
if (!reply.sent) {
reply.status(500).send({ error: "Download failed" });
}
}
}
private async downloadFileRange(
reply: FastifyReply,
provider: FilesystemStorageProvider,
objectName: string,
start: number,
end: number,
downloadId?: string
) {
try {
FilesystemStorageProvider.logMemoryUsage(`Range download start: ${objectName} (${start}-${end}) (${downloadId})`);
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}) (${downloadId})`
);
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}) (${downloadId})`
);
});
await pipeline(rangeStream, reply.raw);
FilesystemStorageProvider.logMemoryUsage(
`Range download complete: ${objectName} (${start}-${end}) (${downloadId})`
);
} catch (error) {
console.error("Range download error:", error);
FilesystemStorageProvider.logMemoryUsage(
`Range download failed: ${objectName} (${start}-${end}) (${downloadId})`
);
if (!reply.sent) {
reply.status(500).send({ error: "Download failed" });
}
}
}
async getQueueStatus(_request: FastifyRequest, reply: FastifyReply) {
try {
const queueStatus = this.memoryManager.getQueueStatus();
const response: QueueStatusResponse = {
status: "success",
data: queueStatus,
};
reply.status(200).send(response);
} catch (error) {
console.error("Error getting queue status:", error);
return reply.status(500).send({ error: "Internal server error" });
}
}
async cancelQueuedDownload(request: FastifyRequest, reply: FastifyReply) {
try {
const { downloadId } = request.params as { downloadId: string };
const cancelled = this.memoryManager.cancelQueuedDownload(downloadId);
if (cancelled) {
const response: DownloadCancelResponse = {
message: "Download cancelled successfully",
downloadId,
};
reply.status(200).send(response);
} else {
reply.status(404).send({
error: "Download not found in queue",
downloadId,
});
}
} catch (error) {
console.error("Error cancelling queued download:", error);
return reply.status(500).send({ error: "Internal server error" });
}
}
async clearDownloadQueue(_request: FastifyRequest, reply: FastifyReply) {
try {
const clearedCount = this.memoryManager.clearQueue();
const response: QueueClearResponse = {
message: "Download queue cleared successfully",
clearedCount,
};
reply.status(200).send(response);
} catch (error) {
console.error("Error clearing download queue:", error);
return reply.status(500).send({ error: "Internal server error" });
}
}
}

View File

@@ -1,95 +0,0 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { FilesystemController } from "./controller";
export async function downloadQueueRoutes(app: FastifyInstance) {
const filesystemController = new FilesystemController();
app.get(
"/filesystem/download-queue/status",
{
schema: {
tags: ["Download Queue"],
operationId: "getDownloadQueueStatus",
summary: "Get download queue status",
description: "Get current status of the download queue including active downloads and queue length",
response: {
200: z.object({
status: z.string(),
data: z.object({
queueLength: z.number(),
maxQueueSize: z.number(),
activeDownloads: z.number(),
maxConcurrent: z.number(),
queuedDownloads: z.array(
z.object({
downloadId: z.string(),
position: z.number(),
waitTime: z.number(),
fileName: z.string().optional(),
fileSize: z.number().optional(),
})
),
}),
}),
500: z.object({
error: z.string(),
}),
},
},
},
filesystemController.getQueueStatus.bind(filesystemController)
);
app.delete(
"/filesystem/download-queue/:downloadId",
{
schema: {
tags: ["Download Queue"],
operationId: "cancelQueuedDownload",
summary: "Cancel a queued download",
description: "Cancel a specific download that is waiting in the queue",
params: z.object({
downloadId: z.string().describe("Download ID"),
}),
response: {
200: z.object({
message: z.string(),
downloadId: z.string(),
}),
404: z.object({
error: z.string(),
downloadId: z.string(),
}),
500: z.object({
error: z.string(),
}),
},
},
},
filesystemController.cancelQueuedDownload.bind(filesystemController)
);
app.delete(
"/filesystem/download-queue",
{
schema: {
tags: ["Download Queue"],
operationId: "clearDownloadQueue",
summary: "Clear entire download queue",
description: "Cancel all downloads waiting in the queue (admin operation)",
response: {
200: z.object({
message: z.string(),
clearedCount: z.number(),
}),
500: z.object({
error: z.string(),
}),
},
},
},
filesystemController.clearDownloadQueue.bind(filesystemController)
);
}

View File

@@ -1,123 +0,0 @@
import { FastifyInstance, FastifyRequest } from "fastify";
import { z } from "zod";
import { FilesystemController } from "./controller";
export async function filesystemRoutes(app: FastifyInstance) {
const filesystemController = new FilesystemController();
app.addContentTypeParser("*", async (request: FastifyRequest, payload: any) => {
return payload;
});
app.addContentTypeParser("application/json", async (request: FastifyRequest, payload: any) => {
return payload;
});
app.put(
"/filesystem/upload/:token",
{
bodyLimit: 1024 * 1024 * 1024 * 1024 * 1024, // 1PB limit
schema: {
tags: ["Filesystem"],
operationId: "uploadToFilesystem",
summary: "Upload file to filesystem storage",
description: "Upload a file directly to the encrypted filesystem storage",
params: z.object({
token: z.string().describe("Upload token"),
}),
response: {
200: z.object({
message: z.string(),
}),
400: z.object({
error: z.string(),
}),
500: z.object({
error: z.string(),
}),
},
},
},
filesystemController.upload.bind(filesystemController)
);
app.get(
"/filesystem/download/:token",
{
bodyLimit: 1024 * 1024 * 1024 * 1024 * 1024, // 1PB limit
schema: {
tags: ["Filesystem"],
operationId: "downloadFromFilesystem",
summary: "Download file from filesystem storage",
description: "Download a file directly from the encrypted filesystem storage",
params: z.object({
token: z.string().describe("Download token"),
}),
response: {
200: z.string().describe("File content"),
400: z.object({
error: z.string(),
}),
500: z.object({
error: z.string(),
}),
},
},
},
filesystemController.download.bind(filesystemController)
);
app.get(
"/filesystem/upload-progress/:fileId",
{
schema: {
tags: ["Filesystem"],
operationId: "getUploadProgress",
summary: "Get chunked upload progress",
description: "Get the progress of a chunked upload",
params: z.object({
fileId: z.string().describe("File ID"),
}),
response: {
200: z.object({
uploaded: z.number(),
total: z.number(),
percentage: z.number(),
}),
404: z.object({
error: z.string(),
}),
500: z.object({
error: z.string(),
}),
},
},
},
filesystemController.getUploadProgress.bind(filesystemController)
);
app.delete(
"/filesystem/cancel-upload/:fileId",
{
schema: {
tags: ["Filesystem"],
operationId: "cancelUpload",
summary: "Cancel chunked upload",
description: "Cancel an ongoing chunked upload",
params: z.object({
fileId: z.string().describe("File ID"),
}),
response: {
200: z.object({
message: z.string(),
}),
500: z.object({
error: z.string(),
}),
},
},
},
filesystemController.cancelUpload.bind(filesystemController)
);
}

View File

@@ -1,5 +1,3 @@
import { isS3Enabled } from "../../config/storage.config";
import { FilesystemStorageProvider } from "../../providers/filesystem-storage.provider";
import { S3StorageProvider } from "../../providers/s3-storage.provider";
import { prisma } from "../../shared/prisma";
import { StorageProvider } from "../../types/storage";
@@ -8,11 +6,8 @@ export class FolderService {
private storageProvider: StorageProvider;
constructor() {
if (isS3Enabled) {
this.storageProvider = new S3StorageProvider();
} else {
this.storageProvider = FilesystemStorageProvider.getInstance();
}
// Always use S3 (Garage internal or external S3)
this.storageProvider = new S3StorageProvider();
}
async getPresignedPutUrl(objectName: string, expires: number): Promise<string> {
@@ -42,10 +37,6 @@ export class FolderService {
}
}
isFilesystemMode(): boolean {
return !isS3Enabled;
}
async getAllFilesInFolder(folderId: string, userId: string, basePath: string = ""): Promise<any[]> {
const files = await prisma.file.findMany({
where: { folderId, userId },

View File

@@ -319,59 +319,12 @@ export class ReverseShareController {
const { fileId } = request.params as { fileId: string };
const fileInfo = await this.reverseShareService.getFileInfo(fileId, userId);
const downloadId = `reverse-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
// Pass request context for internal storage proxy URLs
const requestContext = { protocol: "https", host: "localhost" }; // Simplified - frontend will handle the real URL
const { DownloadMemoryManager } = await import("../../utils/download-memory-manager.js");
const memoryManager = DownloadMemoryManager.getInstance();
const result = await this.reverseShareService.downloadReverseShareFile(fileId, userId, requestContext);
const fileSizeMB = Number(fileInfo.size) / (1024 * 1024);
console.log(
`[REVERSE-DOWNLOAD] Requesting slot for ${downloadId}: ${fileInfo.name} (${fileSizeMB.toFixed(1)}MB)`
);
try {
await memoryManager.requestDownloadSlot(downloadId, {
fileName: fileInfo.name,
fileSize: Number(fileInfo.size),
objectName: fileInfo.objectName,
});
} catch (error: any) {
console.warn(`[REVERSE-DOWNLOAD] Queued ${downloadId}: ${error.message}`);
return reply.status(202).send({
queued: true,
downloadId: downloadId,
message: "Download queued due to memory constraints",
estimatedWaitTime: error.estimatedWaitTime || 60,
});
}
console.log(`[REVERSE-DOWNLOAD] Starting ${downloadId}: ${fileInfo.name} (${fileSizeMB.toFixed(1)}MB)`);
memoryManager.startDownload(downloadId);
try {
const result = await this.reverseShareService.downloadReverseShareFile(fileId, userId);
const originalUrl = result.url;
reply.header("X-Download-ID", downloadId);
reply.raw.on("finish", () => {
memoryManager.endDownload(downloadId);
});
reply.raw.on("close", () => {
memoryManager.endDownload(downloadId);
});
reply.raw.on("error", () => {
memoryManager.endDownload(downloadId);
});
return reply.send(result);
} catch (downloadError) {
memoryManager.endDownload(downloadId);
throw downloadError;
}
return reply.send(result);
} catch (error: any) {
if (error.message === "File not found") {
return reply.status(404).send({ error: error.message });
@@ -513,12 +466,8 @@ export class ReverseShareController {
return reply.status(401).send({ error: "Unauthorized" });
}
console.log(`Copy to my files: User ${userId} copying file ${fileId}`);
const file = await this.reverseShareService.copyReverseShareFileToUserFiles(fileId, userId);
console.log(`Copy to my files: Successfully copied file ${fileId}`);
return reply.send({ file, message: "File copied to your files successfully" });
} catch (error: any) {
console.error(`Copy to my files: Error:`, error.message);

View File

@@ -228,9 +228,21 @@ export class ReverseShareService {
}
const expires = parseInt(env.PRESIGNED_URL_EXPIRATION);
const url = await this.fileService.getPresignedPutUrl(objectName, expires);
return { url, expiresIn: expires };
// Import storage config to check if using internal or external S3
const { isInternalStorage } = await import("../../config/storage.config.js");
if (isInternalStorage) {
// Internal storage: Use backend proxy for uploads (127.0.0.1 not accessible from client)
// Note: This would need request context, but reverse-shares are typically used by external users
// For now, we'll use presigned URLs and handle the error on the client side
const url = await this.fileService.getPresignedPutUrl(objectName, expires);
return { url, expiresIn: expires };
} else {
// External S3: Use presigned URLs directly (more efficient)
const url = await this.fileService.getPresignedPutUrl(objectName, expires);
return { url, expiresIn: expires };
}
}
async getPresignedUrlByAlias(alias: string, objectName: string, password?: string) {
@@ -258,9 +270,21 @@ export class ReverseShareService {
}
const expires = parseInt(env.PRESIGNED_URL_EXPIRATION);
const url = await this.fileService.getPresignedPutUrl(objectName, expires);
return { url, expiresIn: expires };
// Import storage config to check if using internal or external S3
const { isInternalStorage } = await import("../../config/storage.config.js");
if (isInternalStorage) {
// Internal storage: Use backend proxy for uploads (127.0.0.1 not accessible from client)
// Note: This would need request context, but reverse-shares are typically used by external users
// For now, we'll use presigned URLs and handle the error on the client side
const url = await this.fileService.getPresignedPutUrl(objectName, expires);
return { url, expiresIn: expires };
} else {
// External S3: Use presigned URLs directly (more efficient)
const url = await this.fileService.getPresignedPutUrl(objectName, expires);
return { url, expiresIn: expires };
}
}
async registerFileUpload(reverseShareId: string, fileData: UploadToReverseShareInput, password?: string) {
@@ -386,7 +410,11 @@ export class ReverseShareService {
};
}
async downloadReverseShareFile(fileId: string, creatorId: string) {
async downloadReverseShareFile(
fileId: string,
creatorId: string,
requestContext?: { protocol: string; host: string }
) {
const file = await this.reverseShareRepository.findFileById(fileId);
if (!file) {
throw new Error("File not found");
@@ -398,8 +426,19 @@ export class ReverseShareService {
const fileName = file.name;
const expires = parseInt(env.PRESIGNED_URL_EXPIRATION);
const url = await this.fileService.getPresignedGetUrl(file.objectName, expires, fileName);
return { url, expiresIn: expires };
// Import storage config to check if using internal or external S3
const { isInternalStorage } = await import("../../config/storage.config.js");
if (isInternalStorage) {
// Internal storage: Use frontend proxy (much simpler!)
const url = `/api/files/download?objectName=${encodeURIComponent(file.objectName)}`;
return { url, expiresIn: expires };
} else {
// External S3: Use presigned URLs directly (more efficient, no backend proxy)
const url = await this.fileService.getPresignedGetUrl(file.objectName, expires, fileName);
return { url, expiresIn: expires };
}
}
async deleteReverseShareFile(fileId: string, creatorId: string) {
@@ -568,76 +607,59 @@ export class ReverseShareService {
const newObjectName = `${creatorId}/${Date.now()}-${file.name}`;
if (this.fileService.isFilesystemMode()) {
const { FilesystemStorageProvider } = await import("../../providers/filesystem-storage.provider.js");
const provider = FilesystemStorageProvider.getInstance();
// Copy file using S3 presigned URLs
const fileSizeMB = Number(file.size) / (1024 * 1024);
const needsStreaming = fileSizeMB > 100;
const sourcePath = provider.getFilePath(file.objectName);
const fs = await import("fs");
const downloadUrl = await this.fileService.getPresignedGetUrl(file.objectName, 300);
const uploadUrl = await this.fileService.getPresignedPutUrl(newObjectName, 300);
const targetPath = provider.getFilePath(newObjectName);
let retries = 0;
const maxRetries = 3;
let success = false;
const path = await import("path");
const targetDir = path.dirname(targetPath);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
while (retries < maxRetries && !success) {
try {
const response = await fetch(downloadUrl, {
signal: AbortSignal.timeout(600000), // 10 minutes timeout
});
const { copyFile } = await import("fs/promises");
await copyFile(sourcePath, targetPath);
} else {
const fileSizeMB = Number(file.size) / (1024 * 1024);
const needsStreaming = fileSizeMB > 100;
const downloadUrl = await this.fileService.getPresignedGetUrl(file.objectName, 300);
const uploadUrl = await this.fileService.getPresignedPutUrl(newObjectName, 300);
let retries = 0;
const maxRetries = 3;
let success = false;
while (retries < maxRetries && !success) {
try {
const response = await fetch(downloadUrl, {
signal: AbortSignal.timeout(600000), // 10 minutes timeout
});
if (!response.ok) {
throw new Error(`Failed to download file: ${response.statusText}`);
}
if (!response.body) {
throw new Error("No response body received");
}
const uploadOptions: any = {
method: "PUT",
body: response.body,
headers: {
"Content-Type": "application/octet-stream",
"Content-Length": file.size.toString(),
},
signal: AbortSignal.timeout(600000), // 10 minutes timeout
};
const uploadResponse = await fetch(uploadUrl, uploadOptions);
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text();
throw new Error(`Failed to upload file: ${uploadResponse.statusText} - ${errorText}`);
}
success = true;
} catch (error: any) {
retries++;
if (retries >= maxRetries) {
throw new Error(`Failed to copy file after ${maxRetries} attempts: ${error.message}`);
}
const delay = Math.min(1000 * Math.pow(2, retries - 1), 10000);
await new Promise((resolve) => setTimeout(resolve, delay));
if (!response.ok) {
throw new Error(`Failed to download file: ${response.statusText}`);
}
if (!response.body) {
throw new Error("No response body received");
}
const uploadOptions: any = {
method: "PUT",
body: response.body,
duplex: "half",
headers: {
"Content-Type": "application/octet-stream",
"Content-Length": file.size.toString(),
},
signal: AbortSignal.timeout(9600000), // 160 minutes timeout
};
const uploadResponse = await fetch(uploadUrl, uploadOptions);
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text();
throw new Error(`Failed to upload file: ${uploadResponse.statusText} - ${errorText}`);
}
success = true;
} catch (error: any) {
retries++;
if (retries >= maxRetries) {
throw new Error(`Failed to copy file after ${maxRetries} attempts: ${error.message}`);
}
const delay = Math.min(1000 * Math.pow(2, retries - 1), 10000);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
@@ -781,7 +803,7 @@ export class ReverseShareService {
}
// Check if reverse share is expired
const isExpired = reverseShare.expiration && new Date(reverseShare.expiration) < new Date();
const isExpired = reverseShare.expiration ? new Date(reverseShare.expiration) < new Date() : false;
// Check if inactive
const isInactive = !reverseShare.isActive;

View File

@@ -0,0 +1,174 @@
/**
* S3 Storage Controller (Simplified)
*
* This controller handles uploads/downloads using S3-compatible storage (Garage).
* It's much simpler than the filesystem controller because:
* - Uses S3 multipart uploads (no chunk management needed)
* - Uses presigned URLs (no streaming through Node.js)
* - No memory management needed (Garage handles it)
* - No encryption needed (Garage handles it)
*
* Replaces ~800 lines of complex code with ~100 lines of simple code.
*/
import { FastifyReply, FastifyRequest } from "fastify";
import { S3StorageProvider } from "../../providers/s3-storage.provider";
export class S3StorageController {
private storageProvider = new S3StorageProvider();
/**
* Generate presigned upload URL
* Client uploads directly to S3 (Garage)
*/
async getUploadUrl(request: FastifyRequest, reply: FastifyReply) {
try {
const { objectName, expires } = request.body as { objectName: string; expires?: number };
if (!objectName) {
return reply.status(400).send({ error: "objectName is required" });
}
const expiresIn = expires || 3600; // 1 hour default
// Import storage config to check if using internal or external S3
const { isInternalStorage } = await import("../../config/storage.config.js");
let uploadUrl: string;
if (isInternalStorage) {
// Internal storage: Use frontend proxy (much simpler!)
uploadUrl = `/api/files/upload?objectName=${encodeURIComponent(objectName)}`;
} else {
// External S3: Use presigned URLs directly (more efficient)
uploadUrl = await this.storageProvider.getPresignedPutUrl(objectName, expiresIn);
}
return reply.status(200).send({
uploadUrl,
objectName,
expiresIn,
message: isInternalStorage ? "Upload via backend proxy" : "Upload directly to this URL using PUT request",
});
} catch (error) {
console.error("[S3] Error generating upload URL:", error);
return reply.status(500).send({ error: "Failed to generate upload URL" });
}
}
/**
* Generate presigned download URL
* For internal storage: Uses backend proxy
* For external S3: Uses presigned URLs directly
*/
async getDownloadUrl(request: FastifyRequest, reply: FastifyReply) {
try {
const { objectName, expires, fileName } = request.query as {
objectName: string;
expires?: string;
fileName?: string;
};
if (!objectName) {
return reply.status(400).send({ error: "objectName is required" });
}
// Check if file exists
const exists = await this.storageProvider.fileExists(objectName);
if (!exists) {
return reply.status(404).send({ error: "File not found" });
}
const expiresIn = expires ? parseInt(expires, 10) : 3600;
// Import storage config to check if using internal or external S3
const { isInternalStorage } = await import("../../config/storage.config.js");
let downloadUrl: string;
if (isInternalStorage) {
// Internal storage: Use frontend proxy (much simpler!)
downloadUrl = `/api/files/download?objectName=${encodeURIComponent(objectName)}`;
} else {
// External S3: Use presigned URLs directly (more efficient)
downloadUrl = await this.storageProvider.getPresignedGetUrl(objectName, expiresIn, fileName);
}
return reply.status(200).send({
downloadUrl,
objectName,
expiresIn,
message: isInternalStorage ? "Download via backend proxy" : "Download directly from this URL",
});
} catch (error) {
console.error("[S3] Error generating download URL:", error);
return reply.status(500).send({ error: "Failed to generate download URL" });
}
}
/**
* Upload directly (for small files)
* Receives file and uploads to S3
*/
async upload(request: FastifyRequest, reply: FastifyReply) {
try {
// For large files, clients should use presigned URLs
// This is just for backward compatibility or small files
return reply.status(501).send({
error: "Not implemented",
message: "Use getUploadUrl endpoint for efficient uploads",
});
} catch (error) {
console.error("[S3] Error in upload:", error);
return reply.status(500).send({ error: "Upload failed" });
}
}
/**
* Delete object from S3
*/
async deleteObject(request: FastifyRequest, reply: FastifyReply) {
try {
const { objectName } = request.params as { objectName: string };
if (!objectName) {
return reply.status(400).send({ error: "objectName is required" });
}
await this.storageProvider.deleteObject(objectName);
return reply.status(200).send({
message: "Object deleted successfully",
objectName,
});
} catch (error) {
console.error("[S3] Error deleting object:", error);
return reply.status(500).send({ error: "Failed to delete object" });
}
}
/**
* Check if object exists
*/
async checkExists(request: FastifyRequest, reply: FastifyReply) {
try {
const { objectName } = request.query as { objectName: string };
if (!objectName) {
return reply.status(400).send({ error: "objectName is required" });
}
const exists = await this.storageProvider.fileExists(objectName);
return reply.status(200).send({
exists,
objectName,
});
} catch (error) {
console.error("[S3] Error checking existence:", error);
return reply.status(500).send({ error: "Failed to check existence" });
}
}
}

View File

@@ -0,0 +1,112 @@
/**
* S3 Storage Routes
*
* Simple routes for S3-based storage using presigned URLs.
* Much simpler than filesystem routes - no chunk management, no streaming.
*/
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { S3StorageController } from "./controller";
export async function s3StorageRoutes(app: FastifyInstance) {
const controller = new S3StorageController();
// Get presigned upload URL
app.post(
"/s3/upload-url",
{
schema: {
tags: ["S3 Storage"],
operationId: "getS3UploadUrl",
summary: "Get presigned URL for upload",
description: "Returns a presigned URL that clients can use to upload directly to S3",
body: z.object({
objectName: z.string().describe("Object name/path in S3"),
expires: z.number().optional().describe("URL expiration in seconds (default: 3600)"),
}),
response: {
200: z.object({
uploadUrl: z.string(),
objectName: z.string(),
expiresIn: z.number(),
message: z.string(),
}),
},
},
},
controller.getUploadUrl.bind(controller)
);
// Get presigned download URL
app.get(
"/s3/download-url",
{
schema: {
tags: ["S3 Storage"],
operationId: "getS3DownloadUrl",
summary: "Get presigned URL for download",
description: "Returns a presigned URL that clients can use to download directly from S3",
querystring: z.object({
objectName: z.string().describe("Object name/path in S3"),
expires: z.string().optional().describe("URL expiration in seconds (default: 3600)"),
fileName: z.string().optional().describe("Optional filename for download"),
}),
response: {
200: z.object({
downloadUrl: z.string(),
objectName: z.string(),
expiresIn: z.number(),
message: z.string(),
}),
},
},
},
controller.getDownloadUrl.bind(controller)
);
// Delete object
app.delete(
"/s3/object/:objectName",
{
schema: {
tags: ["S3 Storage"],
operationId: "deleteS3Object",
summary: "Delete object from S3",
params: z.object({
objectName: z.string().describe("Object name/path in S3"),
}),
response: {
200: z.object({
message: z.string(),
objectName: z.string(),
}),
},
},
},
controller.deleteObject.bind(controller)
);
// Check if object exists
app.get(
"/s3/exists",
{
schema: {
tags: ["S3 Storage"],
operationId: "checkS3ObjectExists",
summary: "Check if object exists in S3",
querystring: z.object({
objectName: z.string().describe("Object name/path in S3"),
}),
response: {
200: z.object({
exists: z.boolean(),
objectName: z.string(),
}),
},
},
},
controller.checkExists.bind(controller)
);
}

View File

@@ -448,10 +448,10 @@ export class ShareService {
}
// Check if share is expired
const isExpired = share.expiration && new Date(share.expiration) < new Date();
const isExpired = share.expiration ? new Date(share.expiration) < new Date() : false;
// Check if max views reached
const isMaxViewsReached = share.security.maxViews !== null && share.views >= share.security.maxViews;
const isMaxViewsReached = share.security.maxViews !== null ? share.views >= share.security.maxViews : false;
const totalFiles = share.files?.length || 0;
const totalFolders = share.folders?.length || 0;

View File

@@ -1,715 +0,0 @@
import * as crypto from "crypto";
import * as fsSync from "fs";
import * as fs from "fs/promises";
import * as path from "path";
import { Transform } from "stream";
import { pipeline } from "stream/promises";
import { directoriesConfig, getTempFilePath } from "../config/directories.config";
import { env } from "../env";
import { StorageProvider } from "../types/storage";
export class FilesystemStorageProvider implements StorageProvider {
private static instance: FilesystemStorageProvider;
private uploadsDir: string;
private encryptionKey = env.ENCRYPTION_KEY;
private isEncryptionDisabled = env.DISABLE_FILESYSTEM_ENCRYPTION === "true";
private uploadTokens = new Map<string, { objectName: string; expiresAt: number }>();
private downloadTokens = new Map<string, { objectName: string; expiresAt: number; fileName?: string }>();
private constructor() {
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();
setInterval(() => this.cleanExpiredTokens(), 5 * 60 * 1000);
setInterval(() => this.cleanupEmptyTempDirs(), 10 * 60 * 1000);
}
public static getInstance(): FilesystemStorageProvider {
if (!FilesystemStorageProvider.instance) {
FilesystemStorageProvider.instance = new FilesystemStorageProvider();
}
return FilesystemStorageProvider.instance;
}
private async ensureUploadsDir(): Promise<void> {
try {
await fs.access(this.uploadsDir);
} catch {
await fs.mkdir(this.uploadsDir, { recursive: true });
}
}
private cleanExpiredTokens(): void {
const now = Date.now();
for (const [token, data] of this.uploadTokens.entries()) {
if (now > data.expiresAt) {
this.uploadTokens.delete(token);
}
}
for (const [token, data] of this.downloadTokens.entries()) {
if (now > data.expiresAt) {
this.downloadTokens.delete(token);
}
}
}
public getFilePath(objectName: string): string {
const sanitizedName = objectName.replace(/[^a-zA-Z0-9\-_./]/g, "_");
return path.join(this.uploadsDir, sanitizedName);
}
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);
}
public createEncryptStream(): Transform {
if (this.isEncryptionDisabled) {
return new Transform({
highWaterMark: 64 * 1024,
transform(chunk, _encoding, callback) {
this.push(chunk);
callback();
},
});
}
const key = this.createEncryptionKey();
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
let isFirstChunk = true;
return new Transform({
highWaterMark: 64 * 1024,
transform(chunk, _encoding, callback) {
try {
if (isFirstChunk) {
this.push(iv);
isFirstChunk = false;
}
const encrypted = cipher.update(chunk);
this.push(encrypted);
callback();
} catch (error) {
callback(error as Error);
}
},
flush(callback) {
try {
const final = cipher.final();
this.push(final);
callback();
} catch (error) {
callback(error as Error);
}
},
});
}
public createDecryptStream(): Transform {
if (this.isEncryptionDisabled) {
return new Transform({
highWaterMark: 64 * 1024,
transform(chunk, _encoding, callback) {
this.push(chunk);
callback();
},
});
}
const key = this.createEncryptionKey();
let iv: Buffer | null = null;
let decipher: crypto.Decipher | null = null;
let ivBuffer = Buffer.alloc(0);
return new Transform({
highWaterMark: 64 * 1024,
transform(chunk, _encoding, callback) {
try {
if (!iv) {
ivBuffer = Buffer.concat([ivBuffer, chunk]);
if (ivBuffer.length >= 16) {
iv = ivBuffer.subarray(0, 16);
decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
const remainingData = ivBuffer.subarray(16);
if (remainingData.length > 0) {
const decrypted = decipher.update(remainingData);
this.push(decrypted);
}
}
callback();
return;
}
if (decipher) {
const decrypted = decipher.update(chunk);
this.push(decrypted);
}
callback();
} catch (error) {
callback(error as Error);
}
},
flush(callback) {
try {
if (decipher) {
const final = decipher.final();
this.push(final);
}
callback();
} catch (error) {
callback(error as Error);
}
},
});
}
async getPresignedPutUrl(objectName: string, expires: number): Promise<string> {
const token = crypto.randomBytes(32).toString("hex");
const expiresAt = Date.now() + expires * 1000;
this.uploadTokens.set(token, { objectName, expiresAt });
return `/api/filesystem/upload/${token}`;
}
async getPresignedGetUrl(objectName: string): Promise<string> {
const encodedObjectName = encodeURIComponent(objectName);
return `/api/files/download?objectName=${encodedObjectName}`;
}
async deleteObject(objectName: string): Promise<void> {
const filePath = this.getFilePath(objectName);
try {
await fs.unlink(filePath);
} catch (error: any) {
if (error.code !== "ENOENT") {
throw error;
}
}
}
async uploadFile(objectName: string, buffer: Buffer): Promise<void> {
const filePath = this.getFilePath(objectName);
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
const { Readable } = await import("stream");
const readable = Readable.from(buffer);
await this.uploadFileFromStream(objectName, readable);
}
async uploadFileFromStream(objectName: string, inputStream: NodeJS.ReadableStream): Promise<void> {
const filePath = this.getFilePath(objectName);
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
const tempPath = getTempFilePath(objectName);
const tempDir = path.dirname(tempPath);
await fs.mkdir(tempDir, { recursive: true });
const writeStream = fsSync.createWriteStream(tempPath);
const encryptStream = this.createEncryptStream();
try {
await pipeline(inputStream, encryptStream, writeStream);
await this.moveFile(tempPath, filePath);
} catch (error) {
await this.cleanupTempFile(tempPath);
throw error;
}
}
async downloadFile(objectName: string): Promise<Buffer> {
const filePath = this.getFilePath(objectName);
const fileBuffer = await fs.readFile(filePath);
if (this.isEncryptionDisabled) {
return fileBuffer;
}
if (fileBuffer.length > 16) {
try {
return this.decryptFileBuffer(fileBuffer);
} catch (error: unknown) {
if (error instanceof Error) {
console.warn("Failed to decrypt with new method, trying legacy format", error.message);
}
return this.decryptFileLegacy(fileBuffer);
}
}
return this.decryptFileLegacy(fileBuffer);
}
createDownloadStream(objectName: string): NodeJS.ReadableStream {
const filePath = this.getFilePath(objectName);
const streamOptions = {
highWaterMark: 64 * 1024,
autoDestroy: true,
emitClose: true,
};
const fileStream = fsSync.createReadStream(filePath, streamOptions);
if (this.isEncryptionDisabled) {
this.setupStreamMemoryManagement(fileStream, objectName);
return fileStream;
}
const decryptStream = this.createDecryptStream();
const { PassThrough } = require("stream");
const outputStream = new PassThrough(streamOptions);
let isDestroyed = false;
let memoryCheckInterval: NodeJS.Timeout;
const cleanup = () => {
if (isDestroyed) return;
isDestroyed = true;
if (memoryCheckInterval) {
clearInterval(memoryCheckInterval);
}
try {
if (fileStream && !fileStream.destroyed) {
fileStream.destroy();
}
if (decryptStream && !decryptStream.destroyed) {
decryptStream.destroy();
}
if (outputStream && !outputStream.destroyed) {
outputStream.destroy();
}
} catch (error) {
console.warn("Error during download stream cleanup:", error);
}
setImmediate(() => {
if (global.gc) {
global.gc();
}
});
};
memoryCheckInterval = setInterval(() => {
const memUsage = process.memoryUsage();
const memoryUsageMB = memUsage.heapUsed / 1024 / 1024;
if (memoryUsageMB > 1024) {
if (!fileStream.readableFlowing) return;
console.warn(
`[MEMORY THROTTLE] ${objectName} - Pausing stream due to high memory usage: ${memoryUsageMB.toFixed(2)}MB`
);
fileStream.pause();
if (global.gc) {
global.gc();
}
setTimeout(() => {
if (!isDestroyed && fileStream && !fileStream.destroyed) {
fileStream.resume();
console.log(`[MEMORY THROTTLE] ${objectName} - Stream resumed`);
}
}, 100);
}
}, 1000);
fileStream.on("error", (error: any) => {
console.error("File stream error:", error);
cleanup();
});
decryptStream.on("error", (error: any) => {
console.error("Decrypt stream error:", error);
cleanup();
});
outputStream.on("error", (error: any) => {
console.error("Output stream error:", error);
cleanup();
});
outputStream.on("close", cleanup);
outputStream.on("finish", cleanup);
outputStream.on("pipe", (src: any) => {
if (src && src.on) {
src.on("close", cleanup);
src.on("error", cleanup);
}
});
pipeline(fileStream, decryptStream, outputStream)
.then(() => {})
.catch((error: any) => {
console.error("Pipeline error during download:", error);
cleanup();
});
this.setupStreamMemoryManagement(outputStream, objectName);
return outputStream;
}
private setupStreamMemoryManagement(stream: NodeJS.ReadableStream, objectName: string): void {
let lastMemoryLog = 0;
stream.on("data", () => {
const now = Date.now();
if (now - lastMemoryLog > 30000) {
FilesystemStorageProvider.logMemoryUsage(`Active download: ${objectName}`);
lastMemoryLog = now;
}
});
stream.on("end", () => {
FilesystemStorageProvider.logMemoryUsage(`Download completed: ${objectName}`);
setImmediate(() => {
if (global.gc) {
global.gc();
}
});
});
stream.on("close", () => {
FilesystemStorageProvider.logMemoryUsage(`Download closed: ${objectName}`);
});
}
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 {
const key = this.createEncryptionKey();
const iv = encryptedBuffer.slice(0, 16);
const encrypted = encryptedBuffer.slice(16);
const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
}
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 decrypted = CryptoJS.AES.decrypt(encryptedBuffer.toString("utf8"), this.encryptionKey);
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> {
const filePath = this.getFilePath(objectName);
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
validateUploadToken(token: string): { objectName: string } | null {
const data = this.uploadTokens.get(token);
if (!data || Date.now() > data.expiresAt) {
this.uploadTokens.delete(token);
return null;
}
return { objectName: data.objectName };
}
validateDownloadToken(token: string): { objectName: string; fileName?: string } | null {
const data = this.downloadTokens.get(token);
if (!data) {
return null;
}
const now = Date.now();
if (now > data.expiresAt) {
this.downloadTokens.delete(token);
return null;
}
return { objectName: data.objectName, fileName: data.fileName };
}
// Tokens are automatically cleaned up by cleanExpiredTokens() every 5 minutes
// No need to manually consume tokens - allows reuse for previews, range requests, etc.
private async cleanupTempFile(tempPath: string): Promise<void> {
try {
await fs.unlink(tempPath);
const tempDir = path.dirname(tempPath);
try {
const files = await fs.readdir(tempDir);
if (files.length === 0) {
await fs.rmdir(tempDir);
}
} catch (dirError: any) {
if (dirError.code !== "ENOTEMPTY" && dirError.code !== "ENOENT") {
console.warn("Warning: Could not remove temp directory:", dirError.message);
}
}
} catch (cleanupError: any) {
if (cleanupError.code !== "ENOENT") {
console.error("Error deleting temp file:", cleanupError);
}
}
}
private async cleanupEmptyTempDirs(): Promise<void> {
try {
const tempUploadsDir = directoriesConfig.tempUploads;
try {
await fs.access(tempUploadsDir);
} catch {
return;
}
const items = await fs.readdir(tempUploadsDir);
for (const item of items) {
const itemPath = path.join(tempUploadsDir, item);
try {
const stat = await fs.stat(itemPath);
if (stat.isDirectory()) {
const dirContents = await fs.readdir(itemPath);
if (dirContents.length === 0) {
await fs.rmdir(itemPath);
console.log(`🧹 Cleaned up empty temp directory: ${itemPath}`);
}
} else if (stat.isFile()) {
const oneHourAgo = Date.now() - 60 * 60 * 1000;
if (stat.mtime.getTime() < oneHourAgo) {
await fs.unlink(itemPath);
console.log(`🧹 Cleaned up stale temp file: ${itemPath}`);
}
}
} catch (error: any) {
if (error.code !== "ENOENT") {
console.warn(`Warning: Could not process temp item ${itemPath}:`, error.message);
}
}
}
} catch (error) {
console.error("Error during temp directory cleanup:", error);
}
}
private async moveFile(src: string, dest: string): Promise<void> {
try {
await fs.rename(src, dest);
} catch (err: any) {
if (err.code === "EXDEV") {
// cross-device: fallback to copy + delete
await fs.copyFile(src, dest);
await fs.unlink(src);
} else {
throw err;
}
}
}
}

View File

@@ -1,17 +1,25 @@
import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
import {
AbortMultipartUploadCommand,
CompleteMultipartUploadCommand,
CreateMultipartUploadCommand,
DeleteObjectCommand,
GetObjectCommand,
HeadObjectCommand,
PutObjectCommand,
UploadPartCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { bucketName, s3Client } from "../config/storage.config";
import { bucketName, createPublicS3Client, s3Client } from "../config/storage.config";
import { StorageProvider } from "../types/storage";
import { getContentType } from "../utils/mime-types";
export class S3StorageProvider implements StorageProvider {
constructor() {
private ensureClient() {
if (!s3Client) {
throw new Error(
"S3 client is not configured. Make sure ENABLE_S3=true and all S3 environment variables are set."
);
throw new Error("S3 client is not configured. Storage is initializing, please wait...");
}
return s3Client;
}
/**
@@ -71,8 +79,10 @@ export class S3StorageProvider implements StorageProvider {
}
async getPresignedPutUrl(objectName: string, expires: number): Promise<string> {
if (!s3Client) {
throw new Error("S3 client is not available");
// Always use public S3 client for presigned URLs (uses SERVER_IP)
const client = createPublicS3Client();
if (!client) {
throw new Error("S3 client could not be created");
}
const command = new PutObjectCommand({
@@ -80,12 +90,14 @@ export class S3StorageProvider implements StorageProvider {
Key: objectName,
});
return await getSignedUrl(s3Client, command, { expiresIn: expires });
return await getSignedUrl(client, command, { expiresIn: expires });
}
async getPresignedGetUrl(objectName: string, expires: number, fileName?: string): Promise<string> {
if (!s3Client) {
throw new Error("S3 client is not available");
// Always use public S3 client for presigned URLs (uses SERVER_IP)
const client = createPublicS3Client();
if (!client) {
throw new Error("S3 client could not be created");
}
let rcdFileName: string;
@@ -107,26 +119,22 @@ export class S3StorageProvider implements StorageProvider {
ResponseContentType: getContentType(rcdFileName),
});
return await getSignedUrl(s3Client, command, { expiresIn: expires });
return await getSignedUrl(client, command, { expiresIn: expires });
}
async deleteObject(objectName: string): Promise<void> {
if (!s3Client) {
throw new Error("S3 client is not available");
}
const client = this.ensureClient();
const command = new DeleteObjectCommand({
Bucket: bucketName,
Key: objectName,
});
await s3Client.send(command);
await client.send(command);
}
async fileExists(objectName: string): Promise<boolean> {
if (!s3Client) {
throw new Error("S3 client is not available");
}
const client = this.ensureClient();
try {
const command = new HeadObjectCommand({
@@ -134,7 +142,7 @@ export class S3StorageProvider implements StorageProvider {
Key: objectName,
});
await s3Client.send(command);
await client.send(command);
return true;
} catch (error: any) {
if (error.name === "NotFound" || error.$metadata?.httpStatusCode === 404) {
@@ -143,4 +151,115 @@ export class S3StorageProvider implements StorageProvider {
throw error;
}
}
/**
* Get a readable stream for downloading an object
* Used for proxying downloads through the backend
*/
async getObjectStream(objectName: string): Promise<NodeJS.ReadableStream> {
const client = this.ensureClient();
const command = new GetObjectCommand({
Bucket: bucketName,
Key: objectName,
});
const response = await client.send(command);
if (!response.Body) {
throw new Error("No body in S3 response");
}
// AWS SDK v3 returns a readable stream
return response.Body as NodeJS.ReadableStream;
}
/**
* Initialize a multipart upload
* Returns uploadId for subsequent part uploads
*/
async createMultipartUpload(objectName: string): Promise<string> {
const client = createPublicS3Client();
if (!client) {
throw new Error("S3 client could not be created");
}
const command = new CreateMultipartUploadCommand({
Bucket: bucketName,
Key: objectName,
});
const response = await client.send(command);
if (!response.UploadId) {
throw new Error("Failed to create multipart upload - no UploadId returned");
}
return response.UploadId;
}
/**
* Get presigned URL for uploading a specific part
*/
async getPresignedPartUrl(
objectName: string,
uploadId: string,
partNumber: number,
expires: number
): Promise<string> {
const client = createPublicS3Client();
if (!client) {
throw new Error("S3 client could not be created");
}
const command = new UploadPartCommand({
Bucket: bucketName,
Key: objectName,
UploadId: uploadId,
PartNumber: partNumber,
});
const url = await getSignedUrl(client, command, { expiresIn: expires });
return url;
}
/**
* Complete a multipart upload
*/
async completeMultipartUpload(
objectName: string,
uploadId: string,
parts: Array<{ PartNumber: number; ETag: string }>
): Promise<void> {
const client = this.ensureClient();
const command = new CompleteMultipartUploadCommand({
Bucket: bucketName,
Key: objectName,
UploadId: uploadId,
MultipartUpload: {
Parts: parts.map((part) => ({
PartNumber: part.PartNumber,
ETag: part.ETag,
})),
},
});
await client.send(command);
}
/**
* Abort a multipart upload
*/
async abortMultipartUpload(objectName: string, uploadId: string): Promise<void> {
const client = this.ensureClient();
const command = new AbortMultipartUploadCommand({
Bucket: bucketName,
Key: objectName,
UploadId: uploadId,
});
await client.send(command);
}
}

View File

@@ -1,5 +1,3 @@
import { isS3Enabled } from "../config/storage.config";
import { FilesystemStorageProvider } from "../providers/filesystem-storage.provider";
import { S3StorageProvider } from "../providers/s3-storage.provider";
import { prisma } from "../shared/prisma";
import { StorageProvider } from "../types/storage";
@@ -10,14 +8,10 @@ import { StorageProvider } from "../types/storage";
*/
async function cleanupOrphanFiles() {
console.log("Starting orphan file cleanup...");
console.log(`Storage mode: ${isS3Enabled ? "S3" : "Filesystem"}`);
console.log(`Storage mode: S3 (Garage or External)`);
let storageProvider: StorageProvider;
if (isS3Enabled) {
storageProvider = new S3StorageProvider();
} else {
storageProvider = FilesystemStorageProvider.getInstance();
}
// Always use S3 storage provider
const storageProvider: StorageProvider = new S3StorageProvider();
// Get all files from database
const allFiles = await prisma.file.findMany({

View File

@@ -0,0 +1,305 @@
/**
* Automatic Migration Script: Filesystem → S3 (Garage)
*
* This script runs automatically on server start and:
* 1. Detects existing filesystem files
* 2. Migrates them to S3 in background
* 3. Updates database references
* 4. Keeps filesystem as fallback during migration
* 5. Zero downtime, zero user intervention
*/
import { createReadStream } from "fs";
import * as fs from "fs/promises";
import * as path from "path";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { directoriesConfig } from "../config/directories.config";
import { bucketName, s3Client } from "../config/storage.config";
import { prisma } from "../shared/prisma";
interface MigrationStats {
totalFiles: number;
migratedFiles: number;
failedFiles: number;
skippedFiles: number;
totalSizeBytes: number;
startTime: number;
endTime?: number;
}
const MIGRATION_STATE_FILE = path.join(directoriesConfig.uploads, ".migration-state.json");
const MIGRATION_BATCH_SIZE = 10; // Migrate 10 files at a time
const MIGRATION_DELAY_MS = 100; // Small delay between batches to avoid overwhelming
export class FilesystemToS3Migrator {
private stats: MigrationStats = {
totalFiles: 0,
migratedFiles: 0,
failedFiles: 0,
skippedFiles: 0,
totalSizeBytes: 0,
startTime: Date.now(),
};
/**
* Check if migration is needed and should run
*/
async shouldMigrate(): Promise<boolean> {
// Only migrate if S3 client is available
if (!s3Client) {
console.log("[MIGRATION] S3 not configured, skipping migration");
return false;
}
// Check if migration already completed
try {
const stateExists = await fs
.access(MIGRATION_STATE_FILE)
.then(() => true)
.catch(() => false);
if (stateExists) {
const state = JSON.parse(await fs.readFile(MIGRATION_STATE_FILE, "utf-8"));
if (state.completed) {
console.log("[MIGRATION] Migration already completed");
return false;
}
console.log("[MIGRATION] Previous migration incomplete, resuming...");
this.stats = { ...state, startTime: Date.now() };
return true;
}
} catch (error) {
console.warn("[MIGRATION] Could not read migration state:", error);
}
// Check if there are files to migrate
try {
const uploadsDir = directoriesConfig.uploads;
const files = await this.scanDirectory(uploadsDir);
if (files.length === 0) {
console.log("[MIGRATION] No filesystem files found, nothing to migrate");
await this.markMigrationComplete();
return false;
}
console.log(`[MIGRATION] Found ${files.length} files to migrate`);
this.stats.totalFiles = files.length;
return true;
} catch (error) {
console.error("[MIGRATION] Error scanning files:", error);
return false;
}
}
/**
* Run the migration process
*/
async migrate(): Promise<void> {
console.log("[MIGRATION] Starting automatic filesystem → S3 migration");
console.log("[MIGRATION] This runs in background, zero downtime");
try {
const uploadsDir = directoriesConfig.uploads;
const files = await this.scanDirectory(uploadsDir);
// Process in batches
for (let i = 0; i < files.length; i += MIGRATION_BATCH_SIZE) {
const batch = files.slice(i, i + MIGRATION_BATCH_SIZE);
await Promise.all(
batch.map((file) =>
this.migrateFile(file).catch((error) => {
console.error(`[MIGRATION] Failed to migrate ${file}:`, error);
this.stats.failedFiles++;
})
)
);
// Save progress
await this.saveState();
// Small delay between batches
if (i + MIGRATION_BATCH_SIZE < files.length) {
await new Promise((resolve) => setTimeout(resolve, MIGRATION_DELAY_MS));
}
// Log progress
const progress = Math.round(((i + batch.length) / files.length) * 100);
console.log(`[MIGRATION] Progress: ${progress}% (${this.stats.migratedFiles}/${files.length})`);
}
this.stats.endTime = Date.now();
await this.markMigrationComplete();
const durationSeconds = Math.round((this.stats.endTime - this.stats.startTime) / 1000);
const sizeMB = Math.round(this.stats.totalSizeBytes / 1024 / 1024);
console.log("[MIGRATION] ✓✓✓ Migration completed successfully!");
console.log(`[MIGRATION] Stats:`);
console.log(` - Total files: ${this.stats.totalFiles}`);
console.log(` - Migrated: ${this.stats.migratedFiles}`);
console.log(` - Failed: ${this.stats.failedFiles}`);
console.log(` - Skipped: ${this.stats.skippedFiles}`);
console.log(` - Total size: ${sizeMB}MB`);
console.log(` - Duration: ${durationSeconds}s`);
} catch (error) {
console.error("[MIGRATION] Migration failed:", error);
await this.saveState();
throw error;
}
}
/**
* Scan directory recursively for files
*/
private async scanDirectory(dir: string, baseDir: string = dir): Promise<string[]> {
const files: string[] = [];
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
// Skip special files and directories
if (entry.name.startsWith(".") || entry.name === "temp-uploads") {
continue;
}
if (entry.isDirectory()) {
const subFiles = await this.scanDirectory(fullPath, baseDir);
files.push(...subFiles);
} else if (entry.isFile()) {
// Get relative path for S3 key
const relativePath = path.relative(baseDir, fullPath);
files.push(relativePath);
}
}
} catch (error) {
console.warn(`[MIGRATION] Could not scan directory ${dir}:`, error);
}
return files;
}
/**
* Migrate a single file to S3
*/
private async migrateFile(relativeFilePath: string): Promise<void> {
const fullPath = path.join(directoriesConfig.uploads, relativeFilePath);
try {
// Check if file still exists
const stats = await fs.stat(fullPath);
if (!stats.isFile()) {
this.stats.skippedFiles++;
return;
}
// S3 object name (preserve directory structure)
const objectName = relativeFilePath.replace(/\\/g, "/");
// Check if already exists in S3
if (s3Client) {
try {
const { HeadObjectCommand } = await import("@aws-sdk/client-s3");
await s3Client.send(
new HeadObjectCommand({
Bucket: bucketName,
Key: objectName,
})
);
// Already exists in S3, skip
console.log(`[MIGRATION] Already in S3: ${objectName}`);
this.stats.skippedFiles++;
return;
} catch (error: any) {
// Not found, proceed with migration
if (error.$metadata?.httpStatusCode !== 404) {
throw error;
}
}
}
// Upload to S3
if (s3Client) {
const fileStream = createReadStream(fullPath);
await s3Client.send(
new PutObjectCommand({
Bucket: bucketName,
Key: objectName,
Body: fileStream,
})
);
this.stats.migratedFiles++;
this.stats.totalSizeBytes += stats.size;
console.log(`[MIGRATION] ✓ Migrated: ${objectName} (${Math.round(stats.size / 1024)}KB)`);
// Delete filesystem file after successful migration to free up space
try {
await fs.unlink(fullPath);
console.log(`[MIGRATION] 🗑️ Deleted from filesystem: ${relativeFilePath}`);
} catch (unlinkError) {
console.warn(`[MIGRATION] Warning: Could not delete ${relativeFilePath}:`, unlinkError);
}
}
} catch (error) {
console.error(`[MIGRATION] Failed to migrate ${relativeFilePath}:`, error);
this.stats.failedFiles++;
throw error;
}
}
/**
* Save migration state
*/
private async saveState(): Promise<void> {
try {
await fs.writeFile(MIGRATION_STATE_FILE, JSON.stringify({ ...this.stats, completed: false }, null, 2));
} catch (error) {
console.warn("[MIGRATION] Could not save state:", error);
}
}
/**
* Mark migration as complete
*/
private async markMigrationComplete(): Promise<void> {
try {
await fs.writeFile(MIGRATION_STATE_FILE, JSON.stringify({ ...this.stats, completed: true }, null, 2));
console.log("[MIGRATION] Migration marked as complete");
} catch (error) {
console.warn("[MIGRATION] Could not mark migration complete:", error);
}
}
}
/**
* Auto-run migration on import (called by server.ts)
*/
export async function runAutoMigration(): Promise<void> {
const migrator = new FilesystemToS3Migrator();
if (await migrator.shouldMigrate()) {
// Run in background, don't block server start
setTimeout(async () => {
try {
await migrator.migrate();
} catch (error) {
console.error("[MIGRATION] Auto-migration failed:", error);
console.log("[MIGRATION] Will retry on next server restart");
}
}, 5000); // Start after 5 seconds
console.log("[MIGRATION] Background migration scheduled");
}
}

View File

@@ -1,27 +1,21 @@
import * as fs from "fs/promises";
import crypto from "node:crypto";
import path from "path";
import fastifyMultipart from "@fastify/multipart";
import fastifyStatic from "@fastify/static";
import { buildApp } from "./app";
import { directoriesConfig } from "./config/directories.config";
import { env } from "./env";
import { appRoutes } from "./modules/app/routes";
import { authProvidersRoutes } from "./modules/auth-providers/routes";
import { authRoutes } from "./modules/auth/routes";
import { fileRoutes } from "./modules/file/routes";
import { ChunkManager } from "./modules/filesystem/chunk-manager";
import { downloadQueueRoutes } from "./modules/filesystem/download-queue-routes";
import { filesystemRoutes } from "./modules/filesystem/routes";
import { folderRoutes } from "./modules/folder/routes";
import { healthRoutes } from "./modules/health/routes";
import { reverseShareRoutes } from "./modules/reverse-share/routes";
import { s3StorageRoutes } from "./modules/s3-storage/routes";
import { shareRoutes } from "./modules/share/routes";
import { storageRoutes } from "./modules/storage/routes";
import { twoFactorRoutes } from "./modules/two-factor/routes";
import { userRoutes } from "./modules/user/routes";
import { IS_RUNNING_IN_CONTAINER } from "./utils/container-detection";
if (typeof globalThis.crypto === "undefined") {
globalThis.crypto = crypto.webcrypto as any;
@@ -52,6 +46,14 @@ async function startServer() {
await ensureDirectories();
// Import storage config once at the beginning
const { isInternalStorage, isExternalS3 } = await import("./config/storage.config.js");
// Run automatic migration from legacy storage to S3-compatible storage
// Transparently migrates any existing files
const { runAutoMigration } = await import("./scripts/migrate-filesystem-to-s3.js");
await runAutoMigration();
await app.register(fastifyMultipart, {
limits: {
fieldNameSize: 100,
@@ -63,29 +65,29 @@ async function startServer() {
},
});
if (env.ENABLE_S3 !== "true") {
await app.register(fastifyStatic, {
root: directoriesConfig.uploads,
prefix: "/uploads/",
decorateReply: false,
});
}
// No static files needed - S3 serves files directly via presigned URLs
app.register(authRoutes);
app.register(authProvidersRoutes, { prefix: "/auth" });
app.register(twoFactorRoutes, { prefix: "/auth" });
app.register(userRoutes);
app.register(fileRoutes);
app.register(folderRoutes);
app.register(downloadQueueRoutes);
app.register(fileRoutes);
app.register(shareRoutes);
app.register(reverseShareRoutes);
app.register(storageRoutes);
app.register(appRoutes);
app.register(healthRoutes);
if (env.ENABLE_S3 !== "true") {
app.register(filesystemRoutes);
// Always use S3-compatible storage routes
app.register(s3StorageRoutes);
if (isInternalStorage) {
console.log("📦 Using internal storage (auto-configured)");
} else if (isExternalS3) {
console.log("📦 Using external S3 storage (AWS/S3-compatible)");
} else {
console.log("⚠️ WARNING: Storage not configured! Storage may not work.");
}
await app.listen({
@@ -93,36 +95,11 @@ async function startServer() {
host: "0.0.0.0",
});
let authProviders = "Disabled";
try {
const { AuthProvidersService } = await import("./modules/auth-providers/service.js");
const authService = new AuthProvidersService();
const enabledProviders = await authService.getEnabledProviders();
authProviders = enabledProviders.length > 0 ? `Enabled (${enabledProviders.length} providers)` : "Disabled";
} catch (error) {
console.error("Error getting auth providers status:", error);
}
console.log(`🌴 Palmr server running on port 3333`);
console.log(`🌴 Palmr server running on port 3333 🌴`);
console.log(
`📦 Storage mode: ${env.ENABLE_S3 === "true" ? "S3" : `Local Filesystem ${env.DISABLE_FILESYSTEM_ENCRYPTION === "true" ? "(Unencrypted)" : "(Encrypted)"}`}`
);
console.log(`🔐 Auth Providers: ${authProviders}`);
console.log("\n📚 API Documentation:");
console.log(` - API Reference: http://localhost:3333/docs\n`);
process.on("SIGINT", async () => {
const chunkManager = ChunkManager.getInstance();
chunkManager.destroy();
process.exit(0);
});
process.on("SIGTERM", async () => {
const chunkManager = ChunkManager.getInstance();
chunkManager.destroy();
process.exit(0);
});
// Cleanup on shutdown
process.on("SIGINT", () => process.exit(0));
process.on("SIGTERM", () => process.exit(0));
}
startServer().catch((err) => {

View File

@@ -1,52 +0,0 @@
/**
* TypeScript interfaces for download queue management
*/
export interface QueuedDownloadInfo {
downloadId: string;
position: number;
waitTime: number;
fileName?: string;
fileSize?: number;
}
export interface QueueStatus {
queueLength: number;
maxQueueSize: number;
activeDownloads: number;
maxConcurrent: number;
queuedDownloads: QueuedDownloadInfo[];
}
export interface DownloadCancelResponse {
message: string;
downloadId: string;
}
export interface QueueClearResponse {
message: string;
clearedCount: number;
}
export interface ApiResponse<T = any> {
status: "success" | "error";
data?: T;
error?: string;
message?: string;
}
export interface QueueStatusResponse extends ApiResponse<QueueStatus> {
status: "success";
data: QueueStatus;
}
export interface DownloadSlotRequest {
fileName?: string;
fileSize?: number;
objectName: string;
}
export interface ActiveDownloadInfo {
startTime: number;
memoryAtStart: number;
}

View File

@@ -3,6 +3,17 @@ export interface StorageProvider {
getPresignedGetUrl(objectName: string, expires: number, fileName?: string): Promise<string>;
deleteObject(objectName: string): Promise<void>;
fileExists(objectName: string): Promise<boolean>;
getObjectStream(objectName: string): Promise<NodeJS.ReadableStream>;
// Multipart upload methods
createMultipartUpload(objectName: string): Promise<string>;
getPresignedPartUrl(objectName: string, uploadId: string, partNumber: number, expires: number): Promise<string>;
completeMultipartUpload(
objectName: string,
uploadId: string,
parts: Array<{ PartNumber: number; ETag: string }>
): Promise<void>;
abortMultipartUpload(objectName: string, uploadId: string): Promise<void>;
}
export interface StorageConfig {

View File

@@ -1,423 +0,0 @@
import { ActiveDownloadInfo, DownloadSlotRequest, QueuedDownloadInfo, QueueStatus } from "../types/download-queue";
interface QueuedDownload {
downloadId: string;
queuedAt: number;
resolve: () => void;
reject: (error: Error) => void;
metadata?: DownloadSlotRequest;
}
export class DownloadMemoryManager {
private static instance: DownloadMemoryManager;
private activeDownloads = new Map<string, ActiveDownloadInfo>();
private downloadQueue: QueuedDownload[] = [];
private maxConcurrentDownloads: number;
private memoryThresholdMB: number;
private maxQueueSize: number;
private cleanupInterval: NodeJS.Timeout;
private isAutoScalingEnabled: boolean;
private minFileSizeGB: number;
private constructor() {
const { env } = require("../env");
const totalMemoryGB = require("os").totalmem() / 1024 ** 3;
this.isAutoScalingEnabled = env.DOWNLOAD_AUTO_SCALE === "true";
if (env.DOWNLOAD_MAX_CONCURRENT !== undefined) {
this.maxConcurrentDownloads = env.DOWNLOAD_MAX_CONCURRENT;
} else if (this.isAutoScalingEnabled) {
this.maxConcurrentDownloads = this.calculateDefaultConcurrentDownloads(totalMemoryGB);
} else {
this.maxConcurrentDownloads = 3;
}
if (env.DOWNLOAD_MEMORY_THRESHOLD_MB !== undefined) {
this.memoryThresholdMB = env.DOWNLOAD_MEMORY_THRESHOLD_MB;
} else if (this.isAutoScalingEnabled) {
this.memoryThresholdMB = this.calculateDefaultMemoryThreshold(totalMemoryGB);
} else {
this.memoryThresholdMB = 1024;
}
if (env.DOWNLOAD_QUEUE_SIZE !== undefined) {
this.maxQueueSize = env.DOWNLOAD_QUEUE_SIZE;
} else if (this.isAutoScalingEnabled) {
this.maxQueueSize = this.calculateDefaultQueueSize(totalMemoryGB);
} else {
this.maxQueueSize = 15;
}
if (env.DOWNLOAD_MIN_FILE_SIZE_GB !== undefined) {
this.minFileSizeGB = env.DOWNLOAD_MIN_FILE_SIZE_GB;
} else {
this.minFileSizeGB = 3.0;
}
this.validateConfiguration();
console.log(`[DOWNLOAD MANAGER] Configuration loaded:`);
console.log(` System Memory: ${totalMemoryGB.toFixed(1)}GB`);
console.log(
` Max Concurrent: ${this.maxConcurrentDownloads} ${env.DOWNLOAD_MAX_CONCURRENT !== undefined ? "(ENV)" : "(AUTO)"}`
);
console.log(
` Memory Threshold: ${this.memoryThresholdMB}MB ${env.DOWNLOAD_MEMORY_THRESHOLD_MB !== undefined ? "(ENV)" : "(AUTO)"}`
);
console.log(` Queue Size: ${this.maxQueueSize} ${env.DOWNLOAD_QUEUE_SIZE !== undefined ? "(ENV)" : "(AUTO)"}`);
console.log(
` Min File Size: ${this.minFileSizeGB}GB ${env.DOWNLOAD_MIN_FILE_SIZE_GB !== undefined ? "(ENV)" : "(DEFAULT)"}`
);
console.log(` Auto-scaling: ${this.isAutoScalingEnabled ? "enabled" : "disabled"}`);
this.cleanupInterval = setInterval(() => {
this.cleanupStaleDownloads();
}, 30000);
}
public static getInstance(): DownloadMemoryManager {
if (!DownloadMemoryManager.instance) {
DownloadMemoryManager.instance = new DownloadMemoryManager();
}
return DownloadMemoryManager.instance;
}
private calculateDefaultConcurrentDownloads(totalMemoryGB: number): number {
if (totalMemoryGB > 16) return 10;
if (totalMemoryGB > 8) return 5;
if (totalMemoryGB > 4) return 3;
if (totalMemoryGB > 2) return 2;
return 1;
}
private calculateDefaultMemoryThreshold(totalMemoryGB: number): number {
if (totalMemoryGB > 16) return 4096; // 4GB
if (totalMemoryGB > 8) return 2048; // 2GB
if (totalMemoryGB > 4) return 1024; // 1GB
if (totalMemoryGB > 2) return 512; // 512MB
return 256; // 256MB
}
private calculateDefaultQueueSize(totalMemoryGB: number): number {
if (totalMemoryGB > 16) return 50; // Large queue for powerful servers
if (totalMemoryGB > 8) return 25; // Medium queue
if (totalMemoryGB > 4) return 15; // Small queue
if (totalMemoryGB > 2) return 10; // Very small queue
return 5; // Minimal queue
}
private validateConfiguration(): void {
const warnings: string[] = [];
const errors: string[] = [];
if (this.maxConcurrentDownloads < 1) {
errors.push(`DOWNLOAD_MAX_CONCURRENT must be >= 1, got: ${this.maxConcurrentDownloads}`);
}
if (this.maxConcurrentDownloads > 50) {
warnings.push(
`DOWNLOAD_MAX_CONCURRENT is very high (${this.maxConcurrentDownloads}), this may cause performance issues`
);
}
if (this.memoryThresholdMB < 128) {
warnings.push(
`DOWNLOAD_MEMORY_THRESHOLD_MB is very low (${this.memoryThresholdMB}MB), downloads may be throttled frequently`
);
}
if (this.memoryThresholdMB > 16384) {
warnings.push(
`DOWNLOAD_MEMORY_THRESHOLD_MB is very high (${this.memoryThresholdMB}MB), system may run out of memory`
);
}
if (this.maxQueueSize < 1) {
errors.push(`DOWNLOAD_QUEUE_SIZE must be >= 1, got: ${this.maxQueueSize}`);
}
if (this.maxQueueSize > 1000) {
warnings.push(`DOWNLOAD_QUEUE_SIZE is very high (${this.maxQueueSize}), this may consume significant memory`);
}
if (this.minFileSizeGB < 0.1) {
warnings.push(
`DOWNLOAD_MIN_FILE_SIZE_GB is very low (${this.minFileSizeGB}GB), most downloads will use memory management`
);
}
if (this.minFileSizeGB > 50) {
warnings.push(
`DOWNLOAD_MIN_FILE_SIZE_GB is very high (${this.minFileSizeGB}GB), memory management may rarely activate`
);
}
const recommendedQueueSize = this.maxConcurrentDownloads * 5;
if (this.maxQueueSize < this.maxConcurrentDownloads) {
warnings.push(
`DOWNLOAD_QUEUE_SIZE (${this.maxQueueSize}) is smaller than DOWNLOAD_MAX_CONCURRENT (${this.maxConcurrentDownloads})`
);
} else if (this.maxQueueSize < recommendedQueueSize) {
warnings.push(
`DOWNLOAD_QUEUE_SIZE (${this.maxQueueSize}) might be too small. Recommended: ${recommendedQueueSize} (5x concurrent downloads)`
);
}
if (warnings.length > 0) {
console.warn(`[DOWNLOAD MANAGER] Configuration warnings:`);
warnings.forEach((warning) => console.warn(` - ${warning}`));
}
if (errors.length > 0) {
console.error(`[DOWNLOAD MANAGER] Configuration errors:`);
errors.forEach((error) => console.error(` - ${error}`));
throw new Error(`Invalid download manager configuration: ${errors.join(", ")}`);
}
}
public async requestDownloadSlot(downloadId: string, metadata?: DownloadSlotRequest): Promise<void> {
if (metadata?.fileSize) {
const fileSizeGB = metadata.fileSize / 1024 ** 3;
if (fileSizeGB < this.minFileSizeGB) {
console.log(
`[DOWNLOAD MANAGER] File ${metadata.fileName || "unknown"} (${fileSizeGB.toFixed(2)}GB) below threshold (${this.minFileSizeGB}GB), bypassing queue`
);
return Promise.resolve();
}
}
if (this.canStartImmediately()) {
console.log(`[DOWNLOAD MANAGER] Immediate start: ${downloadId}`);
return Promise.resolve();
}
if (this.downloadQueue.length >= this.maxQueueSize) {
const error = new Error(`Download queue is full: ${this.downloadQueue.length}/${this.maxQueueSize}`);
throw error;
}
return new Promise<void>((resolve, reject) => {
const queuedDownload: QueuedDownload = {
downloadId,
queuedAt: Date.now(),
resolve,
reject,
metadata,
};
this.downloadQueue.push(queuedDownload);
const position = this.downloadQueue.length;
console.log(`[DOWNLOAD MANAGER] Queued: ${downloadId} (Position: ${position}/${this.maxQueueSize})`);
if (metadata?.fileName && metadata?.fileSize) {
const sizeMB = (metadata.fileSize / (1024 * 1024)).toFixed(1);
console.log(`[DOWNLOAD MANAGER] Queued file: ${metadata.fileName} (${sizeMB}MB)`);
}
});
}
private canStartImmediately(): boolean {
const currentMemoryMB = this.getCurrentMemoryUsage();
if (currentMemoryMB > this.memoryThresholdMB) {
return false;
}
if (this.activeDownloads.size >= this.maxConcurrentDownloads) {
return false;
}
return true;
}
public canStartDownload(): { allowed: boolean; reason?: string } {
if (this.canStartImmediately()) {
return { allowed: true };
}
const currentMemoryMB = this.getCurrentMemoryUsage();
if (currentMemoryMB > this.memoryThresholdMB) {
return {
allowed: false,
reason: `Memory usage too high: ${currentMemoryMB.toFixed(0)}MB > ${this.memoryThresholdMB}MB`,
};
}
return {
allowed: false,
reason: `Too many concurrent downloads: ${this.activeDownloads.size}/${this.maxConcurrentDownloads}`,
};
}
public startDownload(downloadId: string): void {
const memUsage = process.memoryUsage();
this.activeDownloads.set(downloadId, {
startTime: Date.now(),
memoryAtStart: memUsage.rss + memUsage.external,
});
console.log(
`[DOWNLOAD MANAGER] Started: ${downloadId} (${this.activeDownloads.size}/${this.maxConcurrentDownloads} active)`
);
}
public endDownload(downloadId: string): void {
const downloadInfo = this.activeDownloads.get(downloadId);
this.activeDownloads.delete(downloadId);
if (downloadInfo) {
const duration = Date.now() - downloadInfo.startTime;
const memUsage = process.memoryUsage();
const currentMemory = memUsage.rss + memUsage.external;
const memoryDiff = currentMemory - downloadInfo.memoryAtStart;
console.log(
`[DOWNLOAD MANAGER] Ended: ${downloadId} (Duration: ${(duration / 1000).toFixed(1)}s, Memory delta: ${(memoryDiff / 1024 / 1024).toFixed(1)}MB)`
);
if (memoryDiff > 100 * 1024 * 1024 && global.gc) {
setImmediate(() => {
global.gc!();
console.log(`[DOWNLOAD MANAGER] Forced GC after download ${downloadId}`);
});
}
}
this.processQueue();
}
private processQueue(): void {
if (this.downloadQueue.length === 0 || !this.canStartImmediately()) {
return;
}
const nextDownload = this.downloadQueue.shift();
if (!nextDownload) {
return;
}
console.log(
`[DOWNLOAD MANAGER] Processing queue: ${nextDownload.downloadId} (${this.downloadQueue.length} remaining)`
);
if (nextDownload.metadata?.fileName && nextDownload.metadata?.fileSize) {
const sizeMB = (nextDownload.metadata.fileSize / (1024 * 1024)).toFixed(1);
console.log(`[DOWNLOAD MANAGER] Starting queued file: ${nextDownload.metadata.fileName} (${sizeMB}MB)`);
}
nextDownload.resolve();
}
public getActiveDownloadsCount(): number {
return this.activeDownloads.size;
}
private getCurrentMemoryUsage(): number {
const usage = process.memoryUsage();
return (usage.rss + usage.external) / (1024 * 1024);
}
public getCurrentMemoryUsageMB(): number {
return this.getCurrentMemoryUsage();
}
public getQueueStatus(): QueueStatus {
return {
queueLength: this.downloadQueue.length,
maxQueueSize: this.maxQueueSize,
activeDownloads: this.activeDownloads.size,
maxConcurrent: this.maxConcurrentDownloads,
queuedDownloads: this.downloadQueue.map((download, index) => ({
downloadId: download.downloadId,
position: index + 1,
waitTime: Date.now() - download.queuedAt,
fileName: download.metadata?.fileName,
fileSize: download.metadata?.fileSize,
})),
};
}
public cancelQueuedDownload(downloadId: string): boolean {
const index = this.downloadQueue.findIndex((item) => item.downloadId === downloadId);
if (index === -1) {
return false;
}
const canceledDownload = this.downloadQueue.splice(index, 1)[0];
canceledDownload.reject(new Error(`Download ${downloadId} was cancelled`));
console.log(`[DOWNLOAD MANAGER] Cancelled queued download: ${downloadId} (was at position ${index + 1})`);
return true;
}
private cleanupStaleDownloads(): void {
const now = Date.now();
const staleThreshold = 10 * 60 * 1000; // 10 minutes
const queueStaleThreshold = 30 * 60 * 1000;
for (const [downloadId, info] of this.activeDownloads.entries()) {
if (now - info.startTime > staleThreshold) {
console.warn(`[DOWNLOAD MANAGER] Cleaning up stale active download: ${downloadId}`);
this.activeDownloads.delete(downloadId);
}
}
const initialQueueLength = this.downloadQueue.length;
this.downloadQueue = this.downloadQueue.filter((download) => {
if (now - download.queuedAt > queueStaleThreshold) {
console.warn(`[DOWNLOAD MANAGER] Cleaning up stale queued download: ${download.downloadId}`);
download.reject(new Error(`Download ${download.downloadId} timed out in queue`));
return false;
}
return true;
});
if (this.downloadQueue.length < initialQueueLength) {
console.log(
`[DOWNLOAD MANAGER] Cleaned up ${initialQueueLength - this.downloadQueue.length} stale queued downloads`
);
}
this.processQueue();
}
public shouldThrottleStream(): boolean {
const currentMemoryMB = this.getCurrentMemoryUsageMB();
return currentMemoryMB > this.memoryThresholdMB * 0.8;
}
public getThrottleDelay(): number {
const currentMemoryMB = this.getCurrentMemoryUsageMB();
const thresholdRatio = currentMemoryMB / this.memoryThresholdMB;
if (thresholdRatio > 0.9) return 200;
if (thresholdRatio > 0.8) return 100;
return 50;
}
public destroy(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
this.downloadQueue.forEach((download) => {
download.reject(new Error("Download manager is shutting down"));
});
this.activeDownloads.clear();
this.downloadQueue = [];
console.log("[DOWNLOAD MANAGER] Shutdown completed");
}
public clearQueue(): number {
const clearedCount = this.downloadQueue.length;
this.downloadQueue.forEach((download) => {
download.reject(new Error("Queue was cleared by administrator"));
});
this.downloadQueue = [];
console.log(`[DOWNLOAD MANAGER] Cleared queue: ${clearedCount} downloads cancelled`);
return clearedCount;
}
}

View File

@@ -6,7 +6,9 @@
"token_expired": "انتهت صلاحية الرمز المميز. حاول مرة أخرى.",
"config_error": "خطأ في التكوين. اتصل بالدعم الفني.",
"auth_failed": "فشل في المصادقة. حاول مرة أخرى."
}
},
"authenticationFailed": "فشلت المصادقة",
"successfullyAuthenticated": "تم المصادقة بنجاح!"
},
"authProviders": {
"title": "مزودي المصادقة",
@@ -150,7 +152,9 @@
"move": "نقل",
"rename": "إعادة تسمية",
"search": "بحث",
"share": "مشاركة"
"share": "مشاركة",
"copied": "تم النسخ",
"copy": "نسخ"
},
"createShare": {
"title": "إنشاء مشاركة",
@@ -172,7 +176,14 @@
"tabs": {
"shareDetails": "تفاصيل المشاركة",
"selectFiles": "اختيار الملفات"
}
},
"errors": {
"nameRequired": "اسم المشاركة مطلوب",
"selectItems": "يرجى اختيار ملف أو مجلد واحد على الأقل"
},
"itemsSelected": "{count} عنصر محدد",
"passwordPlaceholder": "أدخل كلمة المرور",
"selectItemsPrompt": "اختر الملفات والمجلدات للمشاركة"
},
"customization": {
"breadcrumb": "التخصيص",
@@ -338,7 +349,8 @@
"addToShare": "إضافة إلى المشاركة",
"removeFromShare": "إزالة من المشاركة",
"saveChanges": "حفظ التغييرات",
"editFolder": "تحرير المجلد"
"editFolder": "تحرير المجلد",
"itemsSelected": "{count} عنصر محدد"
},
"files": {
"title": "جميع الملفات",
@@ -374,7 +386,12 @@
"description": "ارفع ملفك الأول أو أنشئ مجلدًا للبدء"
},
"files": "ملفات",
"folders": "مجلدات"
"folders": "مجلدات",
"errors": {
"moveItemsFailed": "فشل نقل العناصر. يرجى المحاولة مرة أخرى.",
"cannotMoveHere": "لا يمكن نقل العناصر إلى هذا الموقع"
},
"openFolder": "فتح المجلد"
},
"filesTable": {
"ariaLabel": "جدول الملفات",
@@ -537,7 +554,10 @@
"movingTo": "النقل إلى:",
"title": "نقل {count, plural, =1 {عنصر} other {عناصر}}",
"description": "نقل {count, plural, =1 {عنصر} other {عناصر}} إلى موقع جديد",
"success": "تم نقل {count} {count, plural, =1 {عنصر} other {عناصر}} بنجاح"
"success": "تم نقل {count} {count, plural, =1 {عنصر} other {عناصر}} بنجاح",
"errors": {
"moveFailed": "فشل نقل العناصر"
}
},
"navbar": {
"logoAlt": "شعار التطبيق",
@@ -1147,14 +1167,10 @@
"components": {
"fileRow": {
"addDescription": "إضافة وصف...",
"anonymous": "مجهول",
"deleteError": "خطأ في حذف الملف",
"deleteSuccess": "تم حذف الملف بنجاح"
"anonymous": "مجهول"
},
"fileActions": {
"edit": "تحرير",
"save": "حفظ",
"cancel": "إلغاء",
"preview": "معاينة",
"download": "تحميل",
"delete": "حذف",
@@ -1377,16 +1393,6 @@
"deleteTitle": "حذف المشاركة",
"deleteConfirmation": "هل أنت متأكد من أنك تريد حذف هذه المشاركة؟ لا يمكن التراجع عن هذا الإجراء.",
"editTitle": "تحرير المشاركة",
"nameLabel": "اسم المشاركة",
"descriptionLabel": "الوصف",
"descriptionPlaceholder": "أدخل وصفاً (اختياري)",
"expirationLabel": "تاريخ انتهاء الصلاحية",
"expirationPlaceholder": "YYYY/MM/DD HH:MM",
"maxViewsLabel": "الحد الأقصى للمشاهدات",
"maxViewsPlaceholder": "اتركه فارغاً للمشاهدات غير المحدودة",
"passwordProtection": "محمي بكلمة مرور",
"passwordLabel": "كلمة المرور",
"passwordPlaceholder": "أدخل كلمة المرور",
"newPasswordLabel": "كلمة المرور الجديدة (اتركها فارغة للاحتفاظ بالحالية)",
"newPasswordPlaceholder": "أدخل كلمة المرور الجديدة",
"manageFilesTitle": "إدارة الملفات",
@@ -1405,7 +1411,9 @@
"linkDescriptionFile": "إنشاء رابط مخصص لمشاركة الملف",
"linkDescriptionFolder": "إنشاء رابط مخصص لمشاركة المجلد",
"linkReady": "رابط المشاركة جاهز:",
"linkTitle": "إنشاء رابط"
"linkTitle": "إنشاء رابط",
"itemsSelected": "{count} عنصر محدد",
"manageFilesDescription": "اختر الملفات والمجلدات لتضمينها في هذه المشاركة"
},
"shareDetails": {
"title": "تفاصيل المشاركة",
@@ -1421,7 +1429,6 @@
"noLink": "لم يتم إنشاء رابط بعد",
"copyLink": "نسخ الرابط",
"openLink": "فتح في علامة تبويب جديدة",
"linkCopied": "تم نسخ الرابط إلى الحافظة",
"views": "المشاهدات",
"dates": "التواريخ",
"created": "تم الإنشاء",
@@ -1469,28 +1476,6 @@
"expires": "تنتهي صلاحيته:",
"expirationDate": "تاريخ انتهاء الصلاحية"
},
"shareFile": {
"title": "مشاركة ملف",
"linkTitle": "إنشاء رابط",
"nameLabel": "اسم المشاركة",
"namePlaceholder": "أدخل اسم المشاركة",
"descriptionLabel": "الوصف",
"descriptionPlaceholder": "أدخل وصفاً (اختياري)",
"expirationLabel": "تاريخ انتهاء الصلاحية",
"expirationPlaceholder": "YYYY/MM/DD HH:MM",
"maxViewsLabel": "الحد الأقصى للمشاهدات",
"maxViewsPlaceholder": "اتركه فارغاً للمشاهدات غير المحدودة",
"passwordProtection": "محمي بكلمة مرور",
"passwordLabel": "كلمة المرور",
"passwordPlaceholder": "أدخل كلمة المرور",
"linkDescription": "إنشاء رابط مخصص لمشاركة الملف",
"aliasLabel": "اسم مستعار للرابط",
"aliasPlaceholder": "أدخل اسماً مستعاراً مخصصاً",
"linkReady": "رابط المشاركة جاهز:",
"createShare": "إنشاء مشاركة",
"generateLink": "إنشاء رابط",
"copyLink": "نسخ الرابط"
},
"shareManager": {
"deleteSuccess": "تم حذف المشاركة بنجاح",
"deleteError": "فشل في حذف المشاركة",
@@ -1520,7 +1505,10 @@
"noFilesToDownload": "لا توجد ملفات متاحة للتنزيل",
"singleShareZipName": "{ShareName} _files.zip",
"zipDownloadError": "فشل في إنشاء ملف مضغوط",
"zipDownloadSuccess": "تم تنزيل ملف zip بنجاح"
"zipDownloadSuccess": "تم تنزيل ملف zip بنجاح",
"errors": {
"multipleDownloadNotSupported": "تنزيل مشاركات متعددة غير مدعوم حالياً - يرجى تنزيل المشاركات بشكل فردي"
}
},
"shareMultipleFiles": {
"title": "مشاركة ملفات متعددة",
@@ -1933,5 +1921,21 @@
"passwordRequired": "كلمة المرور مطلوبة",
"nameRequired": "الاسم مطلوب",
"required": "هذا الحقل مطلوب"
},
"embedCode": {
"title": "تضمين الوسائط",
"description": "استخدم هذه الأكواد لتضمين هذه الوسائط في المنتديات أو المواقع الإلكترونية أو المنصات الأخرى",
"tabs": {
"directLink": "رابط مباشر",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "عنوان URL مباشر لملف الوسائط",
"htmlDescription": "استخدم هذا الكود لتضمين الوسائط في صفحات HTML",
"bbcodeDescription": "استخدم هذا الكود لتضمين الوسائط في المنتديات التي تدعم BBCode"
},
"contextMenu": {
"newFolder": "مجلد جديد",
"uploadFile": "رفع ملف"
}
}
}

View File

@@ -6,7 +6,9 @@
"token_expired": "Token abgelaufen. Versuchen Sie es erneut.",
"config_error": "Konfigurationsfehler. Kontaktieren Sie den Support.",
"auth_failed": "Authentifizierung fehlgeschlagen. Versuchen Sie es erneut."
}
},
"authenticationFailed": "Authentifizierung fehlgeschlagen",
"successfullyAuthenticated": "Erfolgreich authentifiziert!"
},
"authProviders": {
"title": "Authentifizierungsanbieter",
@@ -150,7 +152,9 @@
"move": "Verschieben",
"rename": "Umbenennen",
"search": "Suchen",
"share": "Teilen"
"share": "Teilen",
"copied": "Kopiert",
"copy": "Kopieren"
},
"createShare": {
"title": "Freigabe Erstellen",
@@ -172,7 +176,14 @@
"tabs": {
"shareDetails": "Freigabe-Details",
"selectFiles": "Dateien auswählen"
}
},
"errors": {
"nameRequired": "Freigabename ist erforderlich",
"selectItems": "Bitte wählen Sie mindestens eine Datei oder einen Ordner aus"
},
"itemsSelected": "{count} Elemente ausgewählt",
"passwordPlaceholder": "Passwort eingeben",
"selectItemsPrompt": "Wählen Sie Dateien und Ordner zum Teilen aus"
},
"customization": {
"breadcrumb": "Anpassung",
@@ -338,7 +349,8 @@
"addToShare": "Zur Freigabe hinzufügen",
"removeFromShare": "Aus Freigabe entfernen",
"saveChanges": "Änderungen Speichern",
"editFolder": "Ordner bearbeiten"
"editFolder": "Ordner bearbeiten",
"itemsSelected": "{count} Elemente ausgewählt"
},
"files": {
"title": "Alle Dateien",
@@ -374,7 +386,12 @@
"description": "Laden Sie Ihre erste Datei hoch oder erstellen Sie einen Ordner, um zu beginnen"
},
"files": "Dateien",
"folders": "Ordner"
"folders": "Ordner",
"errors": {
"moveItemsFailed": "Verschieben der Elemente fehlgeschlagen. Bitte erneut versuchen.",
"cannotMoveHere": "Elemente können nicht an diesen Speicherort verschoben werden"
},
"openFolder": "Ordner öffnen"
},
"filesTable": {
"ariaLabel": "Dateien-Tabelle",
@@ -537,7 +554,10 @@
"movingTo": "Verschieben nach:",
"title": "{count, plural, =1 {Element} other {Elemente}} verschieben",
"description": "{count, plural, =1 {Element} other {Elemente}} an einen neuen Ort verschieben",
"success": "Erfolgreich {count} {count, plural, =1 {Element} other {Elemente}} verschoben"
"success": "Erfolgreich {count} {count, plural, =1 {Element} other {Elemente}} verschoben",
"errors": {
"moveFailed": "Verschieben der Elemente fehlgeschlagen"
}
},
"navbar": {
"logoAlt": "Anwendungslogo",
@@ -1151,8 +1171,6 @@
},
"fileActions": {
"edit": "Bearbeiten",
"save": "Speichern",
"cancel": "Abbrechen",
"preview": "Vorschau",
"download": "Herunterladen",
"delete": "Löschen",
@@ -1376,16 +1394,6 @@
"deleteConfirmation": "Sind Sie sicher, dass Sie diese Freigabe löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"addDescriptionPlaceholder": "Beschreibung hinzufügen...",
"editTitle": "Freigabe Bearbeiten",
"nameLabel": "Freigabe-Name",
"descriptionLabel": "Beschreibung",
"descriptionPlaceholder": "Geben Sie eine Beschreibung ein (optional)",
"expirationLabel": "Ablaufdatum",
"expirationPlaceholder": "TT.MM.JJJJ HH:MM",
"maxViewsLabel": "Maximale Ansichten",
"maxViewsPlaceholder": "Leer lassen für unbegrenzt",
"passwordProtection": "Passwort-geschützt",
"passwordLabel": "Passwort",
"passwordPlaceholder": "Passwort eingeben",
"newPasswordLabel": "Neues Passwort (leer lassen, um das aktuelle zu behalten)",
"newPasswordPlaceholder": "Neues Passwort eingeben",
"manageFilesTitle": "Dateien Verwalten",
@@ -1403,7 +1411,9 @@
"linkDescriptionFile": "Erstellen Sie einen benutzerdefinierten Link zum Teilen der Datei",
"linkDescriptionFolder": "Erstellen Sie einen benutzerdefinierten Link zum Teilen des Ordners",
"linkReady": "Ihr Freigabe-Link ist bereit:",
"linkTitle": "Link generieren"
"linkTitle": "Link generieren",
"itemsSelected": "{count} Elemente ausgewählt",
"manageFilesDescription": "Wählen Sie Dateien und Ordner aus, die in diese Freigabe aufgenommen werden sollen"
},
"shareDetails": {
"title": "Freigabe-Details",
@@ -1419,7 +1429,6 @@
"noLink": "Noch kein Link generiert",
"copyLink": "Link kopieren",
"openLink": "In neuem Tab öffnen",
"linkCopied": "Link in Zwischenablage kopiert",
"views": "Ansichten",
"dates": "Daten",
"created": "Erstellt",
@@ -1467,28 +1476,6 @@
"expires": "Läuft ab:",
"expirationDate": "Ablaufdatum"
},
"shareFile": {
"title": "Datei Freigeben",
"linkTitle": "Link Generieren",
"nameLabel": "Freigabe-Name",
"namePlaceholder": "Freigabe-Namen eingeben",
"descriptionLabel": "Beschreibung",
"descriptionPlaceholder": "Geben Sie eine Beschreibung ein (optional)",
"expirationLabel": "Ablaufdatum",
"expirationPlaceholder": "TT.MM.JJJJ HH:MM",
"maxViewsLabel": "Maximale Ansichten",
"maxViewsPlaceholder": "Leer lassen für unbegrenzt",
"passwordProtection": "Passwort-geschützt",
"passwordLabel": "Passwort",
"passwordPlaceholder": "Passwort eingeben",
"linkDescription": "Einen benutzerdefinierten Link zum Teilen der Datei generieren",
"aliasLabel": "Link-Alias",
"aliasPlaceholder": "Benutzerdefinierten Alias eingeben",
"linkReady": "Ihr Freigabe-Link ist bereit:",
"createShare": "Freigabe Erstellen",
"generateLink": "Link Generieren",
"copyLink": "Link Kopieren"
},
"shareManager": {
"deleteSuccess": "Freigabe erfolgreich gelöscht",
"deleteError": "Fehler beim Löschen der Freigabe",
@@ -1518,7 +1505,10 @@
"noFilesToDownload": "Keine Dateien zum Download zur Verfügung stehen",
"singleShareZipName": "{ShareName} _files.zip",
"zipDownloadError": "Die ZIP -Datei erstellt nicht",
"zipDownloadSuccess": "ZIP -Datei erfolgreich heruntergeladen"
"zipDownloadSuccess": "ZIP -Datei erfolgreich heruntergeladen",
"errors": {
"multipleDownloadNotSupported": "Download mehrerer Freigaben wird noch nicht unterstützt - bitte laden Sie Freigaben einzeln herunter"
}
},
"shareMultipleFiles": {
"title": "Mehrere Dateien Teilen",
@@ -1931,5 +1921,21 @@
"passwordRequired": "Passwort ist erforderlich",
"nameRequired": "Name ist erforderlich",
"required": "Dieses Feld ist erforderlich"
},
"embedCode": {
"title": "Medien einbetten",
"description": "Verwenden Sie diese Codes, um diese Medien in Foren, Websites oder anderen Plattformen einzubetten",
"tabs": {
"directLink": "Direkter Link",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "Direkte URL zur Mediendatei",
"htmlDescription": "Verwenden Sie diesen Code, um die Medien in HTML-Seiten einzubetten",
"bbcodeDescription": "Verwenden Sie diesen Code, um die Medien in Foren einzubetten, die BBCode unterstützen"
},
"contextMenu": {
"newFolder": "Neuer Ordner",
"uploadFile": "Datei hochladen"
}
}
}

1941
apps/web/messages/el-GR.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,7 @@
{
"auth": {
"successfullyAuthenticated": "Successfully authenticated!",
"authenticationFailed": "Authentication failed",
"errors": {
"account_inactive": "Account inactive. Please contact the administrator.",
"registration_disabled": "SSO registration is disabled.",
@@ -8,6 +10,10 @@
"auth_failed": "Authentication failed. Please try again."
}
},
"contextMenu": {
"newFolder": "New folder",
"uploadFile": "Upload file"
},
"authProviders": {
"title": "Authentication Providers",
"description": "Configure external authentication providers for SSO",
@@ -150,7 +156,9 @@
"rename": "Rename",
"move": "Move",
"share": "Share",
"search": "Search"
"search": "Search",
"copy": "Copy",
"copied": "Copied"
},
"createShare": {
"title": "Create Share",
@@ -164,9 +172,16 @@
"maxViewsPlaceholder": "Leave empty for unlimited",
"passwordProtection": "Password Protected",
"passwordLabel": "Password",
"passwordPlaceholder": "Enter password",
"create": "Create Share",
"success": "Share created successfully",
"error": "Failed to create share",
"errors": {
"nameRequired": "Share name is required",
"selectItems": "Please select at least one file or folder"
},
"itemsSelected": "{count} items selected",
"selectItemsPrompt": "Select files and folders to share",
"tabs": {
"shareDetails": "Share Details",
"selectFiles": "Select Files"
@@ -333,6 +348,7 @@
"uploadNewFiles": "Upload new files to add them",
"fileCount": "{count, plural, =1 {file} other {files}}",
"filesSelected": "{count, plural, =0 {No files selected} =1 {1 file selected} other {# files selected}}",
"itemsSelected": "{count} items selected",
"editFile": "Edit file",
"editFolder": "Edit folder",
"previewFile": "Preview file",
@@ -360,6 +376,11 @@
"bulkDeleteTitle": "Delete Selected Items",
"bulkDeleteConfirmation": "Are you sure you want to delete {count, plural, =1 {1 item} other {# items}}? This action cannot be undone.",
"totalFiles": "{count, plural, =0 {No files} =1 {1 file} other {# files}}",
"openFolder": "Open folder",
"errors": {
"moveItemsFailed": "Failed to move items. Please try again.",
"cannotMoveHere": "Cannot move items to this location"
},
"empty": {
"title": "No files or folders yet",
"description": "Upload your first file or create a folder to get started"
@@ -537,7 +558,10 @@
"movingTo": "Moving to:",
"title": "Move {count, plural, =1 {Item} other {Items}}",
"description": "Move {count, plural, =1 {item} other {items}} to a new location",
"success": "Successfully moved {count} {count, plural, =1 {item} other {items}}"
"success": "Successfully moved {count} {count, plural, =1 {item} other {items}}",
"errors": {
"moveFailed": "Failed to move items"
}
},
"navbar": {
"logoAlt": "App Logo",
@@ -1387,7 +1411,9 @@
"newPasswordLabel": "New Password (leave empty to keep current)",
"newPasswordPlaceholder": "Enter new password",
"manageFilesTitle": "Manage Files",
"manageFilesDescription": "Select files and folders to include in this share",
"manageRecipientsTitle": "Manage Recipients",
"itemsSelected": "{count} items selected",
"editSuccess": "Share updated successfully",
"editError": "Failed to update share",
"bulkDeleteConfirmation": "Are you sure you want to delete {count, plural, =1 {1 share} other {# shares}}? This action cannot be undone.",
@@ -1481,6 +1507,9 @@
"creatingZip": "Creating ZIP file...",
"zipDownloadSuccess": "ZIP file downloaded successfully",
"zipDownloadError": "Failed to create ZIP file",
"errors": {
"multipleDownloadNotSupported": "Multiple share download not yet supported - please download shares individually"
},
"singleShareZipName": "{shareName}_files.zip",
"multipleSharesZipName": "{count}_shares_files.zip",
"defaultShareName": "Share"
@@ -1882,6 +1911,18 @@
"userr": "User"
}
},
"embedCode": {
"title": "Embed Media",
"description": "Use these codes to embed this media in forums, websites, or other platforms",
"tabs": {
"directLink": "Direct Link",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "Direct URL to the media file",
"htmlDescription": "Use this code to embed the media in HTML pages",
"bbcodeDescription": "Use this code to embed the media in forums that support BBCode"
},
"validation": {
"firstNameRequired": "First name is required",
"lastNameRequired": "Last name is required",

View File

@@ -6,7 +6,13 @@
"token_expired": "Token expirado. Por favor, inténtelo de nuevo.",
"config_error": "Error de configuración. Por favor, contacte al soporte.",
"auth_failed": "Error de autenticación. Por favor, inténtelo de nuevo."
}
},
"authenticationFailed": "Autenticación fallida",
"successfullyAuthenticated": "¡Autenticado exitosamente!"
},
"contextMenu": {
"newFolder": "Nueva carpeta",
"uploadFile": "Subir archivo"
},
"authProviders": {
"title": "Proveedores de Autenticación",
@@ -150,7 +156,9 @@
"move": "Mover",
"rename": "Renombrar",
"search": "Buscar",
"share": "Compartir"
"share": "Compartir",
"copied": "Copiado",
"copy": "Copiar"
},
"createShare": {
"title": "Crear Compartir",
@@ -172,7 +180,14 @@
"tabs": {
"shareDetails": "Detalles del compartido",
"selectFiles": "Seleccionar archivos"
}
},
"errors": {
"nameRequired": "El nombre del compartir es obligatorio",
"selectItems": "Por favor seleccione al menos un archivo o carpeta"
},
"itemsSelected": "{count} elementos seleccionados",
"passwordPlaceholder": "Ingrese contraseña",
"selectItemsPrompt": "Seleccione archivos y carpetas para compartir"
},
"customization": {
"breadcrumb": "Personalización",
@@ -338,7 +353,8 @@
"addToShare": "Agregar a compartición",
"removeFromShare": "Quitar de compartición",
"saveChanges": "Guardar Cambios",
"editFolder": "Editar carpeta"
"editFolder": "Editar carpeta",
"itemsSelected": "{count} elementos seleccionados"
},
"files": {
"title": "Todos los Archivos",
@@ -374,7 +390,12 @@
"description": "Suba su primer archivo o cree una carpeta para comenzar"
},
"files": "archivos",
"folders": "carpetas"
"folders": "carpetas",
"errors": {
"moveItemsFailed": "Error al mover elementos. Por favor, inténtelo de nuevo.",
"cannotMoveHere": "No se pueden mover elementos a esta ubicación"
},
"openFolder": "Abrir carpeta"
},
"filesTable": {
"ariaLabel": "Tabla de archivos",
@@ -537,7 +558,10 @@
"movingTo": "Moviendo a:",
"title": "Mover {count, plural, =1 {elemento} other {elementos}}",
"description": "Mover {count, plural, =1 {elemento} other {elementos}} a una nueva ubicación",
"success": "Se movieron exitosamente {count} {count, plural, =1 {elemento} other {elementos}}"
"success": "Se movieron exitosamente {count} {count, plural, =1 {elemento} other {elementos}}",
"errors": {
"moveFailed": "Error al mover elementos"
}
},
"navbar": {
"logoAlt": "Logo de la aplicación",
@@ -1151,8 +1175,6 @@
},
"fileActions": {
"edit": "Editar",
"save": "Guardar",
"cancel": "Cancelar",
"preview": "Vista previa",
"download": "Descargar",
"delete": "Eliminar",
@@ -1376,16 +1398,6 @@
"deleteConfirmation": "¿Estás seguro de que deseas eliminar esta compartición? Esta acción no se puede deshacer.",
"addDescriptionPlaceholder": "Agregar descripción...",
"editTitle": "Editar Compartir",
"nameLabel": "Nombre del Compartir",
"descriptionLabel": "Descripción",
"descriptionPlaceholder": "Ingrese una descripción (opcional)",
"expirationLabel": "Fecha de Expiración",
"expirationPlaceholder": "DD/MM/AAAA HH:MM",
"maxViewsLabel": "Vistas Máximas",
"maxViewsPlaceholder": "Deje vacío para ilimitado",
"passwordProtection": "Protegido por Contraseña",
"passwordLabel": "Contraseña",
"passwordPlaceholder": "Ingrese contraseña",
"newPasswordLabel": "Nueva Contraseña (deje vacío para mantener la actual)",
"newPasswordPlaceholder": "Ingrese nueva contraseña",
"manageFilesTitle": "Administrar Archivos",
@@ -1403,7 +1415,9 @@
"linkDescriptionFile": "Genere un enlace personalizado para compartir el archivo",
"linkDescriptionFolder": "Genere un enlace personalizado para compartir la carpeta",
"linkReady": "Su enlace de compartición está listo:",
"linkTitle": "Generar enlace"
"linkTitle": "Generar enlace",
"itemsSelected": "{count} elementos seleccionados",
"manageFilesDescription": "Selecciona archivos y carpetas para incluir en este compartido"
},
"shareDetails": {
"title": "Detalles del Compartir",
@@ -1419,7 +1433,6 @@
"noLink": "Ningún enlace generado aún",
"copyLink": "Copiar enlace",
"openLink": "Abrir en nueva pestaña",
"linkCopied": "Enlace copiado al portapapeles",
"views": "Visualizaciones",
"dates": "Fechas",
"created": "Creado",
@@ -1467,28 +1480,6 @@
"expires": "Expira:",
"expirationDate": "Fecha de Expiración"
},
"shareFile": {
"title": "Compartir Archivo",
"linkTitle": "Generar Enlace",
"nameLabel": "Nombre del Compartir",
"namePlaceholder": "Ingrese nombre del compartir",
"descriptionLabel": "Descripción",
"descriptionPlaceholder": "Ingrese una descripción (opcional)",
"expirationLabel": "Fecha de Expiración",
"expirationPlaceholder": "DD/MM/AAAA HH:MM",
"maxViewsLabel": "Vistas Máximas",
"maxViewsPlaceholder": "Deje vacío para ilimitado",
"passwordProtection": "Protegido por Contraseña",
"passwordLabel": "Contraseña",
"passwordPlaceholder": "Ingrese contraseña",
"linkDescription": "Genere un enlace personalizado para compartir el archivo",
"aliasLabel": "Alias del Enlace",
"aliasPlaceholder": "Ingrese alias personalizado",
"linkReady": "Su enlace de compartir está listo:",
"createShare": "Crear Compartir",
"generateLink": "Generar Enlace",
"copyLink": "Copiar Enlace"
},
"shareManager": {
"deleteSuccess": "Compartición eliminada exitosamente",
"deleteError": "Error al eliminar la compartición",
@@ -1518,7 +1509,10 @@
"noFilesToDownload": "No hay archivos disponibles para descargar",
"singleShareZipName": "{Sharename} _files.zip",
"zipDownloadError": "No se pudo crear un archivo zip",
"zipDownloadSuccess": "Archivo zip descargado correctamente"
"zipDownloadSuccess": "Archivo zip descargado correctamente",
"errors": {
"multipleDownloadNotSupported": "Descarga múltiple de compartidos aún no soportada - por favor descarga los compartidos individualmente"
}
},
"shareMultipleFiles": {
"title": "Compartir Múltiples Archivos",
@@ -1931,5 +1925,17 @@
"passwordRequired": "Se requiere la contraseña",
"nameRequired": "El nombre es obligatorio",
"required": "Este campo es obligatorio"
},
"embedCode": {
"title": "Insertar multimedia",
"description": "Utiliza estos códigos para insertar este contenido multimedia en foros, sitios web u otras plataformas",
"tabs": {
"directLink": "Enlace directo",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "URL directa al archivo multimedia",
"htmlDescription": "Utiliza este código para insertar el contenido multimedia en páginas HTML",
"bbcodeDescription": "Utiliza este código para insertar el contenido multimedia en foros que admiten BBCode"
}
}
}

1941
apps/web/messages/fa-IR.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,9 @@
"token_expired": "Jeton expiré. Veuillez réessayer.",
"config_error": "Erreur de configuration. Veuillez contacter le support.",
"auth_failed": "Échec de l'authentification. Veuillez réessayer."
}
},
"authenticationFailed": "Échec de l'authentification",
"successfullyAuthenticated": "Authentification réussie !"
},
"authProviders": {
"title": "Fournisseurs d'authentification",
@@ -150,7 +152,9 @@
"move": "Déplacer",
"rename": "Renommer",
"search": "Rechercher",
"share": "Partager"
"share": "Partager",
"copied": "Copié",
"copy": "Copier"
},
"createShare": {
"title": "Créer un Partage",
@@ -172,7 +176,14 @@
"tabs": {
"shareDetails": "Détails du partage",
"selectFiles": "Sélectionner les fichiers"
}
},
"errors": {
"nameRequired": "Le nom du partage est requis",
"selectItems": "Veuillez sélectionner au moins un fichier ou dossier"
},
"itemsSelected": "{count, plural, =0 {Aucun élément sélectionné} =1 {1 élément sélectionné} other {# éléments sélectionnés}}",
"passwordPlaceholder": "Entrez le mot de passe",
"selectItemsPrompt": "Sélectionnez les fichiers et dossiers à partager"
},
"customization": {
"breadcrumb": "Personnalisation",
@@ -338,7 +349,8 @@
"addToShare": "Ajouter au partage",
"removeFromShare": "Retirer du partage",
"saveChanges": "Sauvegarder les Modifications",
"editFolder": "Modifier le dossier"
"editFolder": "Modifier le dossier",
"itemsSelected": "{count, plural, =0 {Aucun élément sélectionné} =1 {1 élément sélectionné} other {# éléments sélectionnés}}"
},
"files": {
"title": "Tous les Fichiers",
@@ -374,7 +386,12 @@
"description": "Téléchargez votre premier fichier ou créez un dossier pour commencer"
},
"files": "fichiers",
"folders": "dossiers"
"folders": "dossiers",
"errors": {
"moveItemsFailed": "Échec du déplacement des éléments. Veuillez réessayer.",
"cannotMoveHere": "Impossible de déplacer les éléments vers cet emplacement"
},
"openFolder": "Ouvrir le dossier"
},
"filesTable": {
"ariaLabel": "Tableau des fichiers",
@@ -537,7 +554,10 @@
"movingTo": "Déplacement vers :",
"title": "Déplacer {count, plural, =1 {élément} other {éléments}}",
"description": "Déplacer {count, plural, =1 {élément} other {éléments}} vers un nouvel emplacement",
"success": "{count} {count, plural, =1 {élément déplacé} other {éléments déplacés}} avec succès"
"success": "{count} {count, plural, =1 {élément déplacé} other {éléments déplacés}} avec succès",
"errors": {
"moveFailed": "Échec du déplacement des éléments"
}
},
"navbar": {
"logoAlt": "Logo de l'Application",
@@ -1151,8 +1171,6 @@
},
"fileActions": {
"edit": "Modifier",
"save": "Enregistrer",
"cancel": "Annuler",
"preview": "Aperçu",
"download": "Télécharger",
"delete": "Supprimer",
@@ -1376,16 +1394,6 @@
"deleteConfirmation": "Êtes-vous sûr de vouloir supprimer ce partage ? Cette action ne peut pas être annulée.",
"addDescriptionPlaceholder": "Ajouter une description...",
"editTitle": "Modifier le Partage",
"nameLabel": "Nom du Partage",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Entrez une description (optionnel)",
"expirationLabel": "Date d'Expiration",
"expirationPlaceholder": "DD/MM/AAAA HH:MM",
"maxViewsLabel": "Vues Maximales",
"maxViewsPlaceholder": "Laissez vide pour illimité",
"passwordProtection": "Protégé par Mot de Passe",
"passwordLabel": "Mot de Passe",
"passwordPlaceholder": "Entrez le mot de passe",
"newPasswordLabel": "Nouveau Mot de Passe (laissez vide pour conserver l'actuel)",
"newPasswordPlaceholder": "Entrez le nouveau mot de passe",
"manageFilesTitle": "Gérer les Fichiers",
@@ -1403,7 +1411,9 @@
"linkDescriptionFile": "Générez un lien personnalisé pour partager le fichier",
"linkDescriptionFolder": "Générez un lien personnalisé pour partager le dossier",
"linkReady": "Votre lien de partage est prêt :",
"linkTitle": "Générer un lien"
"linkTitle": "Générer un lien",
"itemsSelected": "{count, plural, =0 {Aucun élément sélectionné} =1 {1 élément sélectionné} other {# éléments sélectionnés}}",
"manageFilesDescription": "Sélectionnez les fichiers et dossiers à inclure dans ce partage"
},
"shareDetails": {
"title": "Détails du Partage",
@@ -1419,7 +1429,6 @@
"noLink": "Aucun lien généré pour le moment",
"copyLink": "Copier le lien",
"openLink": "Ouvrir dans un nouvel onglet",
"linkCopied": "Lien copié dans le presse-papiers",
"views": "Vues",
"dates": "Dates",
"created": "Créé",
@@ -1467,28 +1476,6 @@
"expires": "Expire:",
"expirationDate": "Date d'Expiration"
},
"shareFile": {
"title": "Partager un Fichier",
"linkTitle": "Générer un Lien",
"nameLabel": "Nom du Partage",
"namePlaceholder": "Entrez le nom du partage",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Entrez une description (optionnel)",
"expirationLabel": "Date d'Expiration",
"expirationPlaceholder": "DD/MM/AAAA HH:MM",
"maxViewsLabel": "Vues Maximales",
"maxViewsPlaceholder": "Laissez vide pour illimité",
"passwordProtection": "Protégé par Mot de Passe",
"passwordLabel": "Mot de Passe",
"passwordPlaceholder": "Entrez le mot de passe",
"linkDescription": "Générez un lien personnalisé pour partager le fichier",
"aliasLabel": "Alias du Lien",
"aliasPlaceholder": "Entrez un alias personnalisé",
"linkReady": "Votre lien de partage est prêt :",
"createShare": "Créer un Partage",
"generateLink": "Générer un Lien",
"copyLink": "Copier le Lien"
},
"shareManager": {
"deleteSuccess": "Partage supprimé avec succès",
"deleteError": "Échec de la suppression du partage",
@@ -1518,7 +1505,10 @@
"noFilesToDownload": "Aucun fichier disponible en téléchargement",
"singleShareZipName": "{sharename} _files.zip",
"zipDownloadError": "Échec de la création du fichier zip",
"zipDownloadSuccess": "Fichier zip téléchargé avec succès"
"zipDownloadSuccess": "Fichier zip téléchargé avec succès",
"errors": {
"multipleDownloadNotSupported": "Le téléchargement de plusieurs partages n'est pas encore pris en charge - veuillez télécharger les partages individuellement"
}
},
"shareMultipleFiles": {
"title": "Partager Plusieurs Fichiers",
@@ -1931,5 +1921,21 @@
"passwordRequired": "Le mot de passe est requis",
"nameRequired": "Nome é obrigatório",
"required": "Este campo é obrigatório"
},
"embedCode": {
"title": "Intégrer le média",
"description": "Utilisez ces codes pour intégrer ce média dans des forums, sites web ou autres plateformes",
"tabs": {
"directLink": "Lien direct",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "URL directe vers le fichier média",
"htmlDescription": "Utilisez ce code pour intégrer le média dans des pages HTML",
"bbcodeDescription": "Utilisez ce code pour intégrer le média dans des forums prenant en charge BBCode"
},
"contextMenu": {
"newFolder": "Nouveau dossier",
"uploadFile": "Télécharger un fichier"
}
}
}

1941
apps/web/messages/he-IL.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,9 @@
"token_expired": "टोकन समाप्त हो गया है। कृपया पुनः प्रयास करें।",
"config_error": "कॉन्फ़िगरेशन त्रुटि। कृपया सहायता से संपर्क करें।",
"auth_failed": "प्रमाणीकरण विफल। कृपया पुनः प्रयास करें।"
}
},
"authenticationFailed": "प्रमाणीकरण विफल",
"successfullyAuthenticated": "सफलतापूर्वक प्रमाणित!"
},
"authProviders": {
"title": "प्रमाणीकरण प्रदाता",
@@ -150,7 +152,9 @@
"move": "स्थानांतरित करें",
"rename": "नाम बदलें",
"search": "खोजें",
"share": "साझा करें"
"share": "साझा करें",
"copied": "कॉपी किया गया",
"copy": "कॉपी करें"
},
"createShare": {
"title": "साझाकरण बनाएं",
@@ -172,7 +176,14 @@
"tabs": {
"shareDetails": "साझाकरण विवरण",
"selectFiles": "फ़ाइलें चुनें"
}
},
"errors": {
"nameRequired": "शेयर का नाम आवश्यक है",
"selectItems": "कृपया कम से कम एक फ़ाइल या फ़ोल्डर चुनें"
},
"itemsSelected": "{count, plural, =0 {कोई आइटम चयनित नहीं} =1 {1 आइटम चयनित} other {# आइटम चयनित}}",
"passwordPlaceholder": "पासवर्ड दर्ज करें",
"selectItemsPrompt": "साझा करने के लिए फ़ाइलें और फ़ोल्डर चुनें"
},
"customization": {
"breadcrumb": "अनुकूलन",
@@ -338,7 +349,8 @@
"addToShare": "साझाकरण में जोड़ें",
"removeFromShare": "साझाकरण से हटाएं",
"saveChanges": "परिवर्तन सहेजें",
"editFolder": "फ़ोल्डर संपादित करें"
"editFolder": "फ़ोल्डर संपादित करें",
"itemsSelected": "{count, plural, =0 {कोई आइटम चयनित नहीं} =1 {1 आइटम चयनित} other {# आइटम चयनित}}"
},
"files": {
"title": "सभी फाइलें",
@@ -374,7 +386,12 @@
"description": "आरंभ करने के लिए अपनी पहली फ़ाइल अपलोड करें या फ़ोल्डर बनाएं"
},
"files": "फ़ाइलें",
"folders": "फ़ोल्डर"
"folders": "फ़ोल्डर",
"errors": {
"moveItemsFailed": "आइटम स्थानांतरित करने में विफल। कृपया पुनः प्रयास करें।",
"cannotMoveHere": "इस स्थान पर आइटम स्थानांतरित नहीं कर सकते"
},
"openFolder": "फ़ोल्डर खोलें"
},
"filesTable": {
"ariaLabel": "फाइल तालिका",
@@ -537,7 +554,10 @@
"movingTo": "यहाँ स्थानांतरित कर रहे हैं:",
"title": "आइटम स्थानांतरित करें",
"description": "आइटम को नए स्थान पर स्थानांतरित करें",
"success": "{count} आइटम सफलतापूर्वक स्थानांतरित किए गए"
"success": "{count} आइटम सफलतापूर्वक स्थानांतरित किए गए",
"errors": {
"moveFailed": "आइटम स्थानांतरित करने में विफल"
}
},
"navbar": {
"logoAlt": "एप्लिकेशन लोगो",
@@ -1151,8 +1171,6 @@
},
"fileActions": {
"edit": "संपादित करें",
"save": "सहेजें",
"cancel": "रद्द करें",
"preview": "पूर्वावलोकन",
"download": "डाउनलोड",
"delete": "हटाएं",
@@ -1375,16 +1393,6 @@
"deleteTitle": "साझाकरण हटाएं",
"deleteConfirmation": "क्या आप वाकई इस साझाकरण को हटाना चाहते हैं? यह क्रिया पूर्ववत नहीं की जा सकती।",
"editTitle": "साझाकरण संपादित करें",
"nameLabel": "साझाकरण नाम",
"descriptionLabel": "विवरण",
"descriptionPlaceholder": "विवरण दर्ज करें (वैकल्पिक)",
"expirationLabel": "समाप्ति तिथि",
"expirationPlaceholder": "DD/MM/YYYY HH:MM",
"maxViewsLabel": "अधिकतम दृश्य",
"maxViewsPlaceholder": "असीमित के लिए खाली छोड़ें",
"passwordProtection": "पासवर्ड संरक्षित",
"passwordLabel": "पासवर्ड",
"passwordPlaceholder": "पासवर्ड दर्ज करें",
"newPasswordLabel": "नया पासवर्ड (वर्तमान रखने के लिए खाली छोड़ें)",
"newPasswordPlaceholder": "नया पासवर्ड दर्ज करें",
"manageFilesTitle": "फाइलें प्रबंधित करें",
@@ -1403,7 +1411,9 @@
"linkDescriptionFile": "फ़ाइल साझा करने के लिए कस्टम लिंक जेनरेट करें",
"linkDescriptionFolder": "फ़ोल्डर साझा करने के लिए कस्टम लिंक जेनरेट करें",
"linkReady": "आपका साझाकरण लिंक तैयार है:",
"linkTitle": "लिंक जेनरेट करें"
"linkTitle": "लिंक जेनरेट करें",
"itemsSelected": "{count, plural, =0 {कोई आइटम चयनित नहीं} =1 {1 आइटम चयनित} other {# आइटम चयनित}}",
"manageFilesDescription": "इस साझाकरण में शामिल करने के लिए फ़ाइलें और फ़ोल्डर चुनें"
},
"shareDetails": {
"title": "साझाकरण विवरण",
@@ -1419,7 +1429,6 @@
"noLink": "अभी तक कोई लिंक जेनरेट नहीं किया गया",
"copyLink": "लिंक कॉपी करें",
"openLink": "नए टैब में खोलें",
"linkCopied": "लिंक क्लिपबोर्ड में कॉपी कर दिया गया",
"views": "दृश्य",
"dates": "तिथियां",
"created": "बनाया गया",
@@ -1467,28 +1476,6 @@
"expires": "समाप्त होता है:",
"expirationDate": "समाप्ति तिथि"
},
"shareFile": {
"title": "फाइल साझा करें",
"linkTitle": "लिंक जेनरेट करें",
"nameLabel": "साझाकरण नाम",
"namePlaceholder": "साझाकरण नाम दर्ज करें",
"descriptionLabel": "विवरण",
"descriptionPlaceholder": "विवरण दर्ज करें (वैकल्पिक)",
"expirationLabel": "समाप्ति तिथि",
"expirationPlaceholder": "DD/MM/YYYY HH:MM",
"maxViewsLabel": "अधिकतम दृश्य",
"maxViewsPlaceholder": "असीमित के लिए खाली छोड़ें",
"passwordProtection": "पासवर्ड संरक्षित",
"passwordLabel": "पासवर्ड",
"passwordPlaceholder": "पासवर्ड दर्ज करें",
"linkDescription": "फाइल साझा करने के लिए कस्टम लिंक जेनरेट करें",
"aliasLabel": "लिंक उपनाम",
"aliasPlaceholder": "कस्टम उपनाम दर्ज करें",
"linkReady": "आपका साझाकरण लिंक तैयार है:",
"createShare": "साझाकरण बनाएं",
"generateLink": "लिंक जेनरेट करें",
"copyLink": "लिंक कॉपी करें"
},
"shareManager": {
"deleteSuccess": "साझाकरण सफलतापूर्वक हटाया गया",
"deleteError": "साझाकरण हटाने में त्रुटि",
@@ -1518,7 +1505,10 @@
"noFilesToDownload": "डाउनलोड करने के लिए कोई फाइलें उपलब्ध नहीं हैं",
"singleShareZipName": "{Sharename} _files.zip",
"zipDownloadError": "ज़िप फ़ाइल बनाने में विफल",
"zipDownloadSuccess": "ज़िप फ़ाइल सफलतापूर्वक डाउनलोड की गई"
"zipDownloadSuccess": "ज़िप फ़ाइल सफलतापूर्वक डाउनलोड की गई",
"errors": {
"multipleDownloadNotSupported": "कई साझाकरण डाउनलोड अभी तक समर्थित नहीं है - कृपया साझाकरण को अलग-अलग डाउनलोड करें"
}
},
"shareMultipleFiles": {
"title": "कई फाइलें साझा करें",
@@ -1931,5 +1921,21 @@
"passwordRequired": "पासवर्ड आवश्यक है",
"nameRequired": "नाम आवश्यक है",
"required": "यह फ़ील्ड आवश्यक है"
},
"embedCode": {
"title": "मीडिया एम्बेड करें",
"description": "इस मीडिया को मंचों, वेबसाइटों या अन्य प्लेटफार्मों में एम्बेड करने के लिए इन कोड का उपयोग करें",
"tabs": {
"directLink": "सीधा लिंक",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "मीडिया फ़ाइल का सीधा URL",
"htmlDescription": "HTML पेजों में मीडिया एम्बेड करने के लिए इस कोड का उपयोग करें",
"bbcodeDescription": "BBCode का समर्थन करने वाले मंचों में मीडिया एम्बेड करने के लिए इस कोड का उपयोग करें"
},
"contextMenu": {
"newFolder": "नया फ़ोल्डर",
"uploadFile": "फ़ाइल अपलोड करें"
}
}
}

1941
apps/web/messages/id-ID.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,9 @@
"token_expired": "Token scaduto. Riprova.",
"config_error": "Errore di configurazione. Contatta il supporto.",
"auth_failed": "Autenticazione fallita. Riprova."
}
},
"authenticationFailed": "Autenticazione fallita",
"successfullyAuthenticated": "Autenticazione completata con successo!"
},
"authProviders": {
"title": "Provider di Autenticazione",
@@ -150,7 +152,9 @@
"move": "Sposta",
"rename": "Rinomina",
"search": "Cerca",
"share": "Condividi"
"share": "Condividi",
"copied": "Copiato",
"copy": "Copia"
},
"createShare": {
"title": "Crea Condivisione",
@@ -172,7 +176,14 @@
"tabs": {
"shareDetails": "Dettagli condivisione",
"selectFiles": "Seleziona file"
}
},
"errors": {
"nameRequired": "Il nome della condivisione è obbligatorio",
"selectItems": "Seleziona almeno un file o una cartella"
},
"itemsSelected": "{count, plural, =0 {Nessun elemento selezionato} =1 {1 elemento selezionato} other {# elementi selezionati}}",
"passwordPlaceholder": "Inserisci password",
"selectItemsPrompt": "Seleziona file e cartelle da condividere"
},
"customization": {
"breadcrumb": "Personalizzazione",
@@ -338,7 +349,8 @@
"addToShare": "Aggiungi alla condivisione",
"removeFromShare": "Rimuovi dalla condivisione",
"saveChanges": "Salva Modifiche",
"editFolder": "Modifica cartella"
"editFolder": "Modifica cartella",
"itemsSelected": "{count, plural, =0 {Nessun elemento selezionato} =1 {1 elemento selezionato} other {# elementi selezionati}}"
},
"files": {
"title": "Tutti i File",
@@ -374,7 +386,12 @@
"description": "Carica il tuo primo file o crea una cartella per iniziare"
},
"files": "file",
"folders": "cartelle"
"folders": "cartelle",
"errors": {
"moveItemsFailed": "Impossibile spostare gli elementi. Riprova.",
"cannotMoveHere": "Impossibile spostare gli elementi in questa posizione"
},
"openFolder": "Apri cartella"
},
"filesTable": {
"ariaLabel": "Tabella dei file",
@@ -537,7 +554,10 @@
"movingTo": "Spostamento in:",
"title": "Sposta {count, plural, =1 {elemento} other {elementi}}",
"description": "Sposta {count, plural, =1 {elemento} other {elementi}} in una nuova posizione",
"success": "Spostati con successo {count} {count, plural, =1 {elemento} other {elementi}}"
"success": "Spostati con successo {count} {count, plural, =1 {elemento} other {elementi}}",
"errors": {
"moveFailed": "Impossibile spostare gli elementi"
}
},
"navbar": {
"logoAlt": "Logo dell'App",
@@ -1151,8 +1171,6 @@
},
"fileActions": {
"edit": "Modifica",
"save": "Salva",
"cancel": "Annulla",
"preview": "Anteprima",
"download": "Scarica",
"delete": "Elimina",
@@ -1376,16 +1394,6 @@
"deleteConfirmation": "Sei sicuro di voler eliminare questa condivisione? Questa azione non può essere annullata.",
"addDescriptionPlaceholder": "Aggiungi descrizione...",
"editTitle": "Modifica Condivisione",
"nameLabel": "Nome Condivisione",
"descriptionLabel": "Descrizione",
"descriptionPlaceholder": "Inserisci una descrizione (opzionale)",
"expirationLabel": "Data di Scadenza",
"expirationPlaceholder": "GG/MM/AAAA HH:MM",
"maxViewsLabel": "Visualizzazioni Massime",
"maxViewsPlaceholder": "Lasciare vuoto per illimitato",
"passwordProtection": "Protetto da Password",
"passwordLabel": "Password",
"passwordPlaceholder": "Inserisci password",
"newPasswordLabel": "Nuova Password (lasciare vuoto per mantenere quella attuale)",
"newPasswordPlaceholder": "Inserisci nuova password",
"manageFilesTitle": "Gestisci File",
@@ -1403,7 +1411,9 @@
"linkDescriptionFile": "Genera un collegamento personalizzato per condividere il file",
"linkDescriptionFolder": "Genera un collegamento personalizzato per condividere la cartella",
"linkReady": "Il tuo collegamento di condivisione è pronto:",
"linkTitle": "Genera collegamento"
"linkTitle": "Genera collegamento",
"itemsSelected": "{count, plural, =0 {Nessun elemento selezionato} =1 {1 elemento selezionato} other {# elementi selezionati}}",
"manageFilesDescription": "Seleziona file e cartelle da includere in questa condivisione"
},
"shareDetails": {
"title": "Dettagli Condivisione",
@@ -1419,7 +1429,6 @@
"noLink": "Nessun link generato ancora",
"copyLink": "Copia link",
"openLink": "Apri in nuova scheda",
"linkCopied": "Link copiato negli appunti",
"views": "Visualizzazioni",
"dates": "Date",
"created": "Creato",
@@ -1467,28 +1476,6 @@
"expires": "Scade:",
"expirationDate": "Data di Scadenza"
},
"shareFile": {
"title": "Condividi File",
"linkTitle": "Genera Link",
"nameLabel": "Nome Condivisione",
"namePlaceholder": "Inserisci nome condivisione",
"descriptionLabel": "Descrizione",
"descriptionPlaceholder": "Inserisci una descrizione (opzionale)",
"expirationLabel": "Data di Scadenza",
"expirationPlaceholder": "GG/MM/AAAA HH:MM",
"maxViewsLabel": "Visualizzazioni Massime",
"maxViewsPlaceholder": "Lasciare vuoto per illimitato",
"passwordProtection": "Protetto da Password",
"passwordLabel": "Password",
"passwordPlaceholder": "Inserisci password",
"linkDescription": "Genera un link personalizzato per condividere il file",
"aliasLabel": "Alias Link",
"aliasPlaceholder": "Inserisci alias personalizzato",
"linkReady": "Il tuo link di condivisione è pronto:",
"createShare": "Crea Condivisione",
"generateLink": "Genera Link",
"copyLink": "Copia Link"
},
"shareManager": {
"deleteSuccess": "Condivisione eliminata con successo",
"deleteError": "Errore durante l'eliminazione della condivisione",
@@ -1518,7 +1505,10 @@
"noFilesToDownload": "Nessun file disponibile per il download",
"singleShareZipName": "{Sharename} _files.zip",
"zipDownloadError": "Impossibile creare un file zip",
"zipDownloadSuccess": "File zip scaricato correttamente"
"zipDownloadSuccess": "File zip scaricato correttamente",
"errors": {
"multipleDownloadNotSupported": "Il download di più condivisioni non è ancora supportato - scarica le condivisioni singolarmente"
}
},
"shareMultipleFiles": {
"title": "Condividi File Multipli",
@@ -1931,5 +1921,21 @@
"passwordMinLength": "La password deve contenere almeno 6 caratteri",
"nameRequired": "Il nome è obbligatorio",
"required": "Questo campo è obbligatorio"
},
"embedCode": {
"title": "Incorpora contenuto multimediale",
"description": "Usa questi codici per incorporare questo contenuto multimediale in forum, siti web o altre piattaforme",
"tabs": {
"directLink": "Link diretto",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "URL diretto al file multimediale",
"htmlDescription": "Usa questo codice per incorporare il contenuto multimediale nelle pagine HTML",
"bbcodeDescription": "Usa questo codice per incorporare il contenuto multimediale nei forum che supportano BBCode"
},
"contextMenu": {
"newFolder": "Nuova cartella",
"uploadFile": "Carica file"
}
}
}

View File

@@ -6,7 +6,9 @@
"token_expired": "トークンの有効期限が切れました。もう一度お試しください。",
"config_error": "設定エラー。サポートにお問い合わせください。",
"auth_failed": "認証に失敗しました。もう一度お試しください。"
}
},
"authenticationFailed": "認証に失敗しました",
"successfullyAuthenticated": "認証に成功しました!"
},
"authProviders": {
"title": "認証プロバイダー",
@@ -150,7 +152,9 @@
"move": "移動",
"rename": "名前を変更",
"search": "検索",
"share": "共有"
"share": "共有",
"copied": "コピーしました",
"copy": "コピー"
},
"createShare": {
"title": "共有を作成",
@@ -172,7 +176,14 @@
"tabs": {
"shareDetails": "共有の詳細",
"selectFiles": "ファイルを選択"
}
},
"errors": {
"nameRequired": "共有名は必須です",
"selectItems": "少なくとも1つのファイルまたはフォルダを選択してください"
},
"itemsSelected": "{count, plural, =0 {アイテムが選択されていません} =1 {1つのアイテムが選択されています} other {#つのアイテムが選択されています}}",
"passwordPlaceholder": "パスワードを入力してください",
"selectItemsPrompt": "共有するファイルとフォルダを選択してください"
},
"customization": {
"breadcrumb": "カスタマイズ",
@@ -338,7 +349,8 @@
"addToShare": "共有に追加",
"removeFromShare": "共有から削除",
"saveChanges": "変更を保存",
"editFolder": "フォルダを編集"
"editFolder": "フォルダを編集",
"itemsSelected": "{count, plural, =0 {アイテムが選択されていません} =1 {1つのアイテムが選択されています} other {#つのアイテムが選択されています}}"
},
"files": {
"title": "すべてのファイル",
@@ -374,7 +386,12 @@
"description": "最初のファイルをアップロードするか、フォルダを作成して始めましょう"
},
"files": "ファイル",
"folders": "フォルダ"
"folders": "フォルダ",
"errors": {
"moveItemsFailed": "アイテムの移動に失敗しました。もう一度お試しください。",
"cannotMoveHere": "この場所にアイテムを移動できません"
},
"openFolder": "フォルダを開く"
},
"filesTable": {
"ariaLabel": "ファイルテーブル",
@@ -537,7 +554,10 @@
"movingTo": "移動先:",
"title": "アイテムを移動",
"description": "アイテムを新しい場所に移動",
"success": "{count}個のアイテムが正常に移動されました"
"success": "{count}個のアイテムが正常に移動されました",
"errors": {
"moveFailed": "アイテムの移動に失敗しました"
}
},
"navbar": {
"logoAlt": "アプリケーションロゴ",
@@ -1151,8 +1171,6 @@
},
"fileActions": {
"edit": "編集",
"save": "保存",
"cancel": "キャンセル",
"preview": "プレビュー",
"download": "ダウンロード",
"delete": "削除",
@@ -1375,16 +1393,6 @@
"deleteTitle": "共有を削除",
"deleteConfirmation": "この共有を削除しますか?この操作は元に戻すことができません。",
"editTitle": "共有を編集",
"nameLabel": "共有名",
"descriptionLabel": "説明",
"descriptionPlaceholder": "説明を入力してください(任意)",
"expirationLabel": "有効期限",
"expirationPlaceholder": "YYYY/MM/DD HH:MM",
"maxViewsLabel": "最大表示回数",
"maxViewsPlaceholder": "無制限の場合は空白にしてください",
"passwordProtection": "パスワード保護",
"passwordLabel": "パスワード",
"passwordPlaceholder": "パスワードを入力してください",
"newPasswordLabel": "新しいパスワード(現在のパスワードを保持する場合は空白)",
"newPasswordPlaceholder": "新しいパスワードを入力してください",
"manageFilesTitle": "ファイル管理",
@@ -1403,7 +1411,9 @@
"linkDescriptionFile": "ファイルを共有するためのカスタムリンクを生成",
"linkDescriptionFolder": "フォルダを共有するためのカスタムリンクを生成",
"linkReady": "共有リンクの準備ができました:",
"linkTitle": "リンクを生成"
"linkTitle": "リンクを生成",
"itemsSelected": "{count, plural, =0 {アイテムが選択されていません} =1 {1つのアイテムが選択されています} other {#つのアイテムが選択されています}}",
"manageFilesDescription": "この共有に含めるファイルとフォルダを選択してください"
},
"shareDetails": {
"title": "共有詳細",
@@ -1419,7 +1429,6 @@
"noLink": "まだリンクが生成されていません",
"copyLink": "リンクをコピー",
"openLink": "新しいタブで開く",
"linkCopied": "リンクがクリップボードにコピーされました",
"views": "表示回数",
"dates": "日付",
"created": "作成日",
@@ -1467,28 +1476,6 @@
"expires": "期限:",
"expirationDate": "有効期限"
},
"shareFile": {
"title": "ファイル共有",
"linkTitle": "リンク生成",
"nameLabel": "共有名",
"namePlaceholder": "共有名を入力してください",
"descriptionLabel": "説明",
"descriptionPlaceholder": "説明を入力してください(任意)",
"expirationLabel": "有効期限",
"expirationPlaceholder": "YYYY/MM/DD HH:MM",
"maxViewsLabel": "最大表示回数",
"maxViewsPlaceholder": "無制限の場合は空白にしてください",
"passwordProtection": "パスワード保護",
"passwordLabel": "パスワード",
"passwordPlaceholder": "パスワードを入力してください",
"linkDescription": "ファイルを共有するためのカスタムリンクを生成する",
"aliasLabel": "リンクエイリアス",
"aliasPlaceholder": "カスタムエイリアスを入力してください",
"linkReady": "共有リンクの準備ができました:",
"createShare": "共有を作成",
"generateLink": "リンク生成",
"copyLink": "リンクコピー"
},
"shareManager": {
"deleteSuccess": "共有が正常に削除されました",
"deleteError": "共有の削除に失敗しました",
@@ -1518,7 +1505,10 @@
"noFilesToDownload": "ダウンロードできるファイルはありません",
"singleShareZipName": "{sharename} _files.zip",
"zipDownloadError": "zipファイルの作成に失敗しました",
"zipDownloadSuccess": "zipファイルは正常にダウンロードされました"
"zipDownloadSuccess": "zipファイルは正常にダウンロードされました",
"errors": {
"multipleDownloadNotSupported": "複数の共有のダウンロードはまだサポートされていません - 共有を個別にダウンロードしてください"
}
},
"shareMultipleFiles": {
"title": "複数ファイルを共有",
@@ -1931,5 +1921,21 @@
"passwordRequired": "パスワードは必須です",
"nameRequired": "名前は必須です",
"required": "このフィールドは必須です"
},
"embedCode": {
"title": "メディアを埋め込む",
"description": "これらのコードを使用して、このメディアをフォーラム、ウェブサイト、またはその他のプラットフォームに埋め込みます",
"tabs": {
"directLink": "直接リンク",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "メディアファイルへの直接URL",
"htmlDescription": "このコードを使用してHTMLページにメディアを埋め込みます",
"bbcodeDescription": "BBCodeをサポートするフォーラムにメディアを埋め込むには、このコードを使用します"
},
"contextMenu": {
"newFolder": "新規フォルダ",
"uploadFile": "ファイルをアップロード"
}
}
}

View File

@@ -6,7 +6,9 @@
"token_expired": "토큰이 만료되었습니다. 다시 시도하세요.",
"config_error": "구성 오류. 지원팀에 문의하세요.",
"auth_failed": "인증에 실패했습니다. 다시 시도하세요."
}
},
"authenticationFailed": "인증에 실패했습니다",
"successfullyAuthenticated": "인증에 성공했습니다!"
},
"authProviders": {
"title": "인증 제공자",
@@ -150,7 +152,9 @@
"move": "이동",
"rename": "이름 변경",
"search": "검색",
"share": "공유"
"share": "공유",
"copied": "복사됨",
"copy": "복사"
},
"createShare": {
"title": "공유 생성",
@@ -172,7 +176,14 @@
"tabs": {
"shareDetails": "공유 세부사항",
"selectFiles": "파일 선택"
}
},
"errors": {
"nameRequired": "공유 이름은 필수입니다",
"selectItems": "최소 하나의 파일 또는 폴더를 선택해주세요"
},
"itemsSelected": "{count, plural, =0 {선택된 항목 없음} =1 {1개 항목 선택됨} other {#개 항목 선택됨}}",
"passwordPlaceholder": "비밀번호를 입력하세요",
"selectItemsPrompt": "공유할 파일과 폴더를 선택하세요"
},
"customization": {
"breadcrumb": "커스터마이징",
@@ -338,7 +349,8 @@
"addToShare": "공유에 추가",
"removeFromShare": "공유에서 제거",
"saveChanges": "변경사항 저장",
"editFolder": "폴더 편집"
"editFolder": "폴더 편집",
"itemsSelected": "{count, plural, =0 {선택된 항목 없음} =1 {1개 항목 선택됨} other {#개 항목 선택됨}}"
},
"files": {
"title": "모든 파일",
@@ -374,7 +386,12 @@
"description": "첫 번째 파일을 업로드하거나 폴더를 만들어 시작하세요"
},
"files": "파일",
"folders": "폴더"
"folders": "폴더",
"errors": {
"moveItemsFailed": "항목 이동에 실패했습니다. 다시 시도해주세요.",
"cannotMoveHere": "이 위치로 항목을 이동할 수 없습니다"
},
"openFolder": "폴더 열기"
},
"filesTable": {
"ariaLabel": "파일 테이블",
@@ -537,7 +554,10 @@
"movingTo": "이동 위치:",
"title": "항목 이동",
"description": "항목을 새 위치로 이동",
"success": "{count}개 항목이 성공적으로 이동되었습니다"
"success": "{count}개 항목이 성공적으로 이동되었습니다",
"errors": {
"moveFailed": "항목 이동에 실패했습니다"
}
},
"navbar": {
"logoAlt": "애플리케이션 로고",
@@ -1151,8 +1171,6 @@
},
"fileActions": {
"edit": "편집",
"save": "저장",
"cancel": "취소",
"preview": "미리보기",
"download": "다운로드",
"delete": "삭제",
@@ -1375,16 +1393,6 @@
"deleteTitle": "공유 삭제",
"deleteConfirmation": "이 공유를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"editTitle": "공유 편집",
"nameLabel": "공유 이름",
"descriptionLabel": "설명",
"descriptionPlaceholder": "설명을 입력하세요 (선택사항)",
"expirationLabel": "만료 날짜",
"expirationPlaceholder": "YYYY/MM/DD HH:MM",
"maxViewsLabel": "최대 조회수",
"maxViewsPlaceholder": "무제한은 공백으로 두세요",
"passwordProtection": "비밀번호 보호",
"passwordLabel": "비밀번호",
"passwordPlaceholder": "비밀번호를 입력하세요",
"newPasswordLabel": "새 비밀번호 (현재 비밀번호를 유지하려면 공백으로 두세요)",
"newPasswordPlaceholder": "새 비밀번호를 입력하세요",
"manageFilesTitle": "파일 관리",
@@ -1403,7 +1411,9 @@
"linkDescriptionFile": "파일을 공유할 사용자 정의 링크 생성",
"linkDescriptionFolder": "폴더를 공유할 사용자 정의 링크 생성",
"linkReady": "공유 링크가 준비되었습니다:",
"linkTitle": "링크 생성"
"linkTitle": "링크 생성",
"itemsSelected": "{count}개 항목 선택됨",
"manageFilesDescription": "이 공유에 포함할 파일 및 폴더 선택"
},
"shareDetails": {
"title": "공유 세부 정보",
@@ -1419,7 +1429,6 @@
"noLink": "아직 링크가 생성되지 않았습니다",
"copyLink": "링크 복사",
"openLink": "새 탭에서 열기",
"linkCopied": "링크가 클립보드에 복사되었습니다",
"views": "조회수",
"dates": "날짜",
"created": "생성됨",
@@ -1467,28 +1476,6 @@
"expires": "만료:",
"expirationDate": "만료 날짜"
},
"shareFile": {
"title": "파일 공유",
"linkTitle": "링크 생성",
"nameLabel": "공유 이름",
"namePlaceholder": "공유 이름을 입력하세요",
"descriptionLabel": "설명",
"descriptionPlaceholder": "설명을 입력하세요 (선택사항)",
"expirationLabel": "만료 날짜",
"expirationPlaceholder": "YYYY/MM/DD HH:MM",
"maxViewsLabel": "최대 조회수",
"maxViewsPlaceholder": "무제한은 공백으로 두세요",
"passwordProtection": "비밀번호 보호",
"passwordLabel": "비밀번호",
"passwordPlaceholder": "비밀번호를 입력하세요",
"linkDescription": "파일을 공유할 맞춤 링크를 생성하세요",
"aliasLabel": "링크 별칭",
"aliasPlaceholder": "맞춤 별칭을 입력하세요",
"linkReady": "공유 링크가 준비되었습니다:",
"createShare": "공유 생성",
"generateLink": "링크 생성",
"copyLink": "링크 복사"
},
"shareManager": {
"deleteSuccess": "공유가 성공적으로 삭제되었습니다",
"deleteError": "공유 삭제에 실패했습니다",
@@ -1518,7 +1505,10 @@
"noFilesToDownload": "다운로드 할 수있는 파일이 없습니다",
"singleShareZipName": "{sharename} _files.zip",
"zipDownloadError": "zip 파일을 만들지 못했습니다",
"zipDownloadSuccess": "zip 파일이 성공적으로 다운로드되었습니다"
"zipDownloadSuccess": "zip 파일이 성공적으로 다운로드되었습니다",
"errors": {
"multipleDownloadNotSupported": "여러 공유 다운로드는 아직 지원되지 않습니다 - 공유를 개별적으로 다운로드해주세요"
}
},
"shareMultipleFiles": {
"title": "여러 파일 공유",
@@ -1931,5 +1921,21 @@
"passwordRequired": "비밀번호는 필수입니다",
"nameRequired": "이름은 필수입니다",
"required": "이 필드는 필수입니다"
},
"embedCode": {
"title": "미디어 삽입",
"description": "이 코드를 사용하여 포럼, 웹사이트 또는 기타 플랫폼에 이 미디어를 삽입하세요",
"tabs": {
"directLink": "직접 링크",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "미디어 파일에 대한 직접 URL",
"htmlDescription": "이 코드를 사용하여 HTML 페이지에 미디어를 삽입하세요",
"bbcodeDescription": "BBCode를 지원하는 포럼에 미디어를 삽입하려면 이 코드를 사용하세요"
},
"contextMenu": {
"newFolder": "새 폴더",
"uploadFile": "파일 업로드"
}
}
}

View File

@@ -6,7 +6,9 @@
"token_expired": "Token verlopen. Probeer het opnieuw.",
"config_error": "Configuratiefout. Neem contact op met support.",
"auth_failed": "Authenticatie mislukt. Probeer het opnieuw."
}
},
"authenticationFailed": "Authenticatie mislukt",
"successfullyAuthenticated": "Succesvol geauthenticeerd!"
},
"authProviders": {
"title": "Authenticatie Providers",
@@ -150,7 +152,9 @@
"move": "Verplaatsen",
"rename": "Hernoemen",
"search": "Zoeken",
"share": "Delen"
"share": "Delen",
"copied": "Gekopieerd",
"copy": "Kopiëren"
},
"createShare": {
"title": "Delen Maken",
@@ -172,7 +176,14 @@
"tabs": {
"shareDetails": "Deel details",
"selectFiles": "Bestanden selecteren"
}
},
"errors": {
"nameRequired": "Deelnaam is verplicht",
"selectItems": "Selecteer ten minste één bestand of map"
},
"itemsSelected": "{count} items geselecteerd",
"passwordPlaceholder": "Voer wachtwoord in",
"selectItemsPrompt": "Selecteer bestanden en mappen om te delen"
},
"customization": {
"breadcrumb": "Aanpassing",
@@ -338,7 +349,8 @@
"addToShare": "Toevoegen aan share",
"removeFromShare": "Verwijderen uit share",
"saveChanges": "Wijzigingen Opslaan",
"editFolder": "Map bewerken"
"editFolder": "Map bewerken",
"itemsSelected": "{count} items geselecteerd"
},
"files": {
"title": "Alle Bestanden",
@@ -374,7 +386,12 @@
"description": "Upload uw eerste bestand of maak een map om te beginnen"
},
"files": "bestanden",
"folders": "mappen"
"folders": "mappen",
"errors": {
"moveItemsFailed": "Verplaatsen van items mislukt. Probeer het opnieuw.",
"cannotMoveHere": "Kan items niet naar deze locatie verplaatsen"
},
"openFolder": "Map openen"
},
"filesTable": {
"ariaLabel": "Bestanden tabel",
@@ -537,7 +554,10 @@
"movingTo": "Verplaatsen naar:",
"title": "{count, plural, =1 {Item} other {Items}} verplaatsen",
"description": "{count, plural, =1 {Item} other {Items}} naar een nieuwe locatie verplaatsen",
"success": "{count} {count, plural, =1 {item} other {items}} succesvol verplaatst"
"success": "{count} {count, plural, =1 {item} other {items}} succesvol verplaatst",
"errors": {
"moveFailed": "Verplaatsen van items mislukt"
}
},
"navbar": {
"logoAlt": "Applicatie Logo",
@@ -1151,8 +1171,6 @@
},
"fileActions": {
"edit": "Bewerken",
"save": "Opslaan",
"cancel": "Annuleren",
"preview": "Voorvertoning",
"download": "Downloaden",
"delete": "Verwijderen",
@@ -1375,16 +1393,6 @@
"deleteTitle": "Delen Verwijderen",
"deleteConfirmation": "Weet je zeker dat je dit delen wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"editTitle": "Delen Bewerken",
"nameLabel": "Delen Naam",
"descriptionLabel": "Beschrijving",
"descriptionPlaceholder": "Voer een beschrijving in (optioneel)",
"expirationLabel": "Vervaldatum",
"expirationPlaceholder": "DD/MM/JJJJ UU:MM",
"maxViewsLabel": "Maximale Weergaven",
"maxViewsPlaceholder": "Laat leeg voor onbeperkt",
"passwordProtection": "Wachtwoord Beveiligd",
"passwordLabel": "Wachtwoord",
"passwordPlaceholder": "Voer wachtwoord in",
"newPasswordLabel": "Nieuw Wachtwoord (laat leeg om huidig te behouden)",
"newPasswordPlaceholder": "Voer nieuw wachtwoord in",
"manageFilesTitle": "Bestanden Beheren",
@@ -1403,7 +1411,9 @@
"linkDescriptionFile": "Genereer een aangepaste link om het bestand te delen",
"linkDescriptionFolder": "Genereer een aangepaste link om de map te delen",
"linkReady": "Uw deel-link is klaar:",
"linkTitle": "Link genereren"
"linkTitle": "Link genereren",
"itemsSelected": "{count} items geselecteerd",
"manageFilesDescription": "Selecteer bestanden en mappen om in dit deel op te nemen"
},
"shareDetails": {
"title": "Delen Details",
@@ -1419,7 +1429,6 @@
"noLink": "Nog geen link gegenereerd",
"copyLink": "Link kopiëren",
"openLink": "Openen in nieuw tabblad",
"linkCopied": "Link gekopieerd naar klembord",
"views": "Weergaven",
"dates": "Data",
"created": "Aangemaakt",
@@ -1467,28 +1476,6 @@
"expires": "Verloopt:",
"expirationDate": "Vervaldatum"
},
"shareFile": {
"title": "Bestand Delen",
"linkTitle": "Link Genereren",
"nameLabel": "Delen Naam",
"namePlaceholder": "Voer delen naam in",
"descriptionLabel": "Beschrijving",
"descriptionPlaceholder": "Voer een beschrijving in (optioneel)",
"expirationLabel": "Vervaldatum",
"expirationPlaceholder": "DD/MM/JJJJ UU:MM",
"maxViewsLabel": "Maximale Weergaven",
"maxViewsPlaceholder": "Laat leeg voor onbeperkt",
"passwordProtection": "Wachtwoord Beveiligd",
"passwordLabel": "Wachtwoord",
"passwordPlaceholder": "Voer wachtwoord in",
"linkDescription": "Genereer een aangepaste link om het bestand te delen",
"aliasLabel": "Link Alias",
"aliasPlaceholder": "Voer aangepaste alias in",
"linkReady": "Jouw delen link is klaar:",
"createShare": "Delen Aanmaken",
"generateLink": "Link Genereren",
"copyLink": "Link Kopiëren"
},
"shareManager": {
"deleteSuccess": "Delen succesvol verwijderd",
"deleteError": "Fout bij het verwijderen van delen",
@@ -1518,7 +1505,10 @@
"noFilesToDownload": "Geen bestanden beschikbaar om te downloaden",
"singleShareZipName": "{Sharename} _files.zip",
"zipDownloadError": "Kan zip -bestand niet maken",
"zipDownloadSuccess": "Zipbestand met succes gedownload"
"zipDownloadSuccess": "Zipbestand met succes gedownload",
"errors": {
"multipleDownloadNotSupported": "Download van meerdere delen wordt nog niet ondersteund - download delen afzonderlijk"
}
},
"shareMultipleFiles": {
"title": "Meerdere Bestanden Delen",
@@ -1931,5 +1921,21 @@
"passwordMinLength": "Wachtwoord moet minimaal 6 tekens bevatten",
"nameRequired": "Naam is verplicht",
"required": "Dit veld is verplicht"
},
"embedCode": {
"title": "Media insluiten",
"description": "Gebruik deze codes om deze media in te sluiten in forums, websites of andere platforms",
"tabs": {
"directLink": "Directe link",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "Directe URL naar het mediabestand",
"htmlDescription": "Gebruik deze code om de media in te sluiten in HTML-pagina's",
"bbcodeDescription": "Gebruik deze code om de media in te sluiten in forums die BBCode ondersteunen"
},
"contextMenu": {
"newFolder": "Nieuwe map",
"uploadFile": "Bestand uploaden"
}
}
}

View File

@@ -6,7 +6,9 @@
"token_expired": "Token wygasł. Spróbuj ponownie.",
"config_error": "Błąd konfiguracji. Skontaktuj się z pomocą techniczną.",
"auth_failed": "Uwierzytelnienie nie powiodło się. Spróbuj ponownie."
}
},
"authenticationFailed": "Uwierzytelnienie nie powiodło się",
"successfullyAuthenticated": "Pomyślnie uwierzytelniono!"
},
"authProviders": {
"title": "Dostawcy uwierzytelniania",
@@ -150,7 +152,9 @@
"move": "Przenieś",
"rename": "Zmień nazwę",
"search": "Szukaj",
"share": "Udostępnij"
"share": "Udostępnij",
"copied": "Skopiowano",
"copy": "Kopiuj"
},
"createShare": {
"title": "Utwórz Udostępnienie",
@@ -172,7 +176,14 @@
"tabs": {
"shareDetails": "Szczegóły udostępniania",
"selectFiles": "Wybierz pliki"
}
},
"errors": {
"nameRequired": "Nazwa udostępnienia jest wymagana",
"selectItems": "Wybierz co najmniej jeden plik lub folder"
},
"itemsSelected": "Wybrano {count} elementów",
"passwordPlaceholder": "Wprowadź hasło",
"selectItemsPrompt": "Wybierz pliki i foldery do udostępnienia"
},
"customization": {
"breadcrumb": "Personalizacja",
@@ -338,7 +349,8 @@
"addToShare": "Dodaj do udostępnienia",
"removeFromShare": "Usuń z udostępnienia",
"saveChanges": "Zapisz zmiany",
"editFolder": "Edytuj folder"
"editFolder": "Edytuj folder",
"itemsSelected": "Wybrano {count} elementów"
},
"files": {
"title": "Wszystkie pliki",
@@ -374,7 +386,12 @@
"description": "Prześlij swój pierwszy plik lub utwórz folder, aby rozpocząć"
},
"files": "pliki",
"folders": "foldery"
"folders": "foldery",
"errors": {
"moveItemsFailed": "Nie udało się przenieść elementów. Spróbuj ponownie.",
"cannotMoveHere": "Nie można przenieść elementów do tej lokalizacji"
},
"openFolder": "Otwórz folder"
},
"filesTable": {
"ariaLabel": "Tabela plików",
@@ -537,7 +554,10 @@
"movingTo": "Przenoszenie do:",
"title": "Przenieś {count, plural, =1 {element} other {elementy}}",
"description": "Przenieś {count, plural, =1 {element} other {elementy}} do nowej lokalizacji",
"success": "Pomyślnie przeniesiono {count} {count, plural, =1 {element} other {elementów}}"
"success": "Pomyślnie przeniesiono {count} {count, plural, =1 {element} other {elementów}}",
"errors": {
"moveFailed": "Nie udało się przenieść elementów"
}
},
"navbar": {
"logoAlt": "Logo aplikacji",
@@ -1151,8 +1171,6 @@
},
"fileActions": {
"edit": "Edytuj",
"save": "Zapisz",
"cancel": "Anuluj",
"preview": "Podgląd",
"download": "Pobierz",
"delete": "Usuń",
@@ -1375,16 +1393,6 @@
"deleteTitle": "Usuń udostępnienie",
"deleteConfirmation": "Czy na pewno chcesz usunąć to udostępnienie? Tej operacji nie można cofnąć.",
"editTitle": "Edytuj udostępnienie",
"nameLabel": "Nazwa udostępnienia",
"descriptionLabel": "Opis",
"descriptionPlaceholder": "Wpisz opis (opcjonalnie)",
"expirationLabel": "Data wygaśnięcia",
"expirationPlaceholder": "MM/DD/RRRR GG:MM",
"maxViewsLabel": "Maksymalna liczba wyświetleń",
"maxViewsPlaceholder": "Pozostaw puste dla nieograniczonej liczby",
"passwordProtection": "Chronione hasłem",
"passwordLabel": "Hasło",
"passwordPlaceholder": "Wprowadź hasło",
"newPasswordLabel": "Nowe hasło (pozostaw puste, aby zachować bieżące)",
"newPasswordPlaceholder": "Wprowadź nowe hasło",
"manageFilesTitle": "Zarządzaj plikami",
@@ -1403,7 +1411,9 @@
"linkDescriptionFile": "Wygeneruj niestandardowy link do udostępnienia pliku",
"linkDescriptionFolder": "Wygeneruj niestandardowy link do udostępnienia folderu",
"linkReady": "Twój link udostępniania jest gotowy:",
"linkTitle": "Generuj link"
"linkTitle": "Generuj link",
"itemsSelected": "Wybrano {count} elementów",
"manageFilesDescription": "Wybierz pliki i foldery do uwzględnienia w tym udostępnieniu"
},
"shareDetails": {
"title": "Szczegóły udostępnienia",
@@ -1419,7 +1429,6 @@
"noLink": "Brak wygenerowanego linku",
"copyLink": "Skopiuj link",
"openLink": "Otwórz w nowej karcie",
"linkCopied": "Link skopiowany do schowka",
"views": "Wyświetlenia",
"dates": "Daty",
"created": "Utworzono",
@@ -1467,28 +1476,6 @@
"noExpiration": "To udostępnienie nigdy nie wygaśnie i pozostanie dostępne bezterminowo."
}
},
"shareFile": {
"title": "Udostępnij plik",
"linkTitle": "Generuj link",
"nameLabel": "Nazwa udostępnienia",
"namePlaceholder": "Wprowadź nazwę udostępnienia",
"descriptionLabel": "Opis",
"descriptionPlaceholder": "Wprowadź opis (opcjonalnie)",
"expirationLabel": "Data wygaśnięcia",
"expirationPlaceholder": "MM/DD/RRRR GG:MM",
"maxViewsLabel": "Maksymalna liczba wyświetleń",
"maxViewsPlaceholder": "Pozostaw puste dla nieograniczonej liczby",
"passwordProtection": "Chronione hasłem",
"passwordLabel": "Hasło",
"passwordPlaceholder": "Wprowadź hasło",
"linkDescription": "Wygeneruj niestandardowy link do udostępniania pliku",
"aliasLabel": "Alias linku",
"aliasPlaceholder": "Wprowadź własny alias",
"linkReady": "Twój link do udostępniania jest gotowy:",
"createShare": "Utwórz udostępnienie",
"generateLink": "Generuj link",
"copyLink": "Skopiuj link"
},
"shareManager": {
"deleteSuccess": "Udostępnienie usunięte pomyślnie",
"deleteError": "Nie udało się usunąć udostępnienia",
@@ -1518,7 +1505,10 @@
"noFilesToDownload": "Brak plików do pobrania",
"singleShareZipName": "{ShaRename} _files.zip",
"zipDownloadError": "Nie udało się utworzyć pliku zip",
"zipDownloadSuccess": "Plik zip pobrany pomyślnie"
"zipDownloadSuccess": "Plik zip pobrany pomyślnie",
"errors": {
"multipleDownloadNotSupported": "Pobieranie wielu udostępnień nie jest jeszcze obsługiwane - pobierz udostępnienia pojedynczo"
}
},
"shareMultipleFiles": {
"title": "Udostępnij wiele plików",
@@ -1931,5 +1921,21 @@
"passwordMinLength": "Hasło musi mieć co najmniej 6 znaków",
"nameRequired": "Nazwa jest wymagana",
"required": "To pole jest wymagane"
},
"embedCode": {
"title": "Osadź multimedia",
"description": "Użyj tych kodów, aby osadzić te multimedia na forach, stronach internetowych lub innych platformach",
"tabs": {
"directLink": "Link bezpośredni",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "Bezpośredni adres URL pliku multimedialnego",
"htmlDescription": "Użyj tego kodu, aby osadzić multimedia na stronach HTML",
"bbcodeDescription": "Użyj tego kodu, aby osadzić multimedia na forach obsługujących BBCode"
},
"contextMenu": {
"newFolder": "Nowy folder",
"uploadFile": "Prześlij plik"
}
}
}

View File

@@ -6,7 +6,13 @@
"token_expired": "Token expirado. Tente novamente.",
"config_error": "Erro de configuração. Contate o suporte.",
"auth_failed": "Falha na autenticação. Tente novamente."
}
},
"authenticationFailed": "Falha na autenticação",
"successfullyAuthenticated": "Autenticado com sucesso!"
},
"contextMenu": {
"newFolder": "Nova pasta",
"uploadFile": "Enviar arquivo"
},
"authProviders": {
"title": "Provedores de autenticação",
@@ -150,7 +156,9 @@
"move": "Mover",
"rename": "Renomear",
"search": "Pesquisar",
"share": "Compartilhar"
"share": "Compartilhar",
"copied": "Copiado",
"copy": "Copiar"
},
"createShare": {
"title": "Criar compartilhamento",
@@ -172,7 +180,14 @@
"tabs": {
"shareDetails": "Detalhes do compartilhamento",
"selectFiles": "Selecionar arquivos"
}
},
"errors": {
"nameRequired": "O nome do compartilhamento é obrigatório",
"selectItems": "Por favor, selecione pelo menos um arquivo ou pasta"
},
"itemsSelected": "{count} itens selecionados",
"passwordPlaceholder": "Digite a senha",
"selectItemsPrompt": "Selecione arquivos e pastas para compartilhar"
},
"customization": {
"breadcrumb": "Personalização",
@@ -338,7 +353,8 @@
"addToShare": "Adicionar ao compartilhamento",
"removeFromShare": "Remover do compartilhamento",
"saveChanges": "Salvar Alterações",
"editFolder": "Editar pasta"
"editFolder": "Editar pasta",
"itemsSelected": "{count} itens selecionados"
},
"files": {
"title": "Todos os Arquivos",
@@ -374,7 +390,12 @@
"description": "Carregue seu primeiro arquivo ou crie uma pasta para começar"
},
"files": "arquivos",
"folders": "pastas"
"folders": "pastas",
"errors": {
"moveItemsFailed": "Falha ao mover itens. Por favor, tente novamente.",
"cannotMoveHere": "Não é possível mover itens para este local"
},
"openFolder": "Abrir pasta"
},
"filesTable": {
"ariaLabel": "Tabela de arquivos",
@@ -537,7 +558,10 @@
"movingTo": "Movendo para:",
"title": "Mover {count, plural, =1 {item} other {itens}}",
"description": "Mover {count, plural, =1 {item} other {itens}} para um novo local",
"success": "Movidos com sucesso {count} {count, plural, =1 {item} other {itens}}"
"success": "Movidos com sucesso {count} {count, plural, =1 {item} other {itens}}",
"errors": {
"moveFailed": "Falha ao mover itens"
}
},
"navbar": {
"logoAlt": "Logo do aplicativo",
@@ -912,7 +936,6 @@
"size": "Tamanho",
"sender": "Enviado por",
"date": "Data",
"invalidDate": "Data inválida",
"actions": "Ações"
},
"actions": {
@@ -1152,8 +1175,6 @@
},
"fileActions": {
"edit": "Editar",
"save": "Salvar",
"cancel": "Cancelar",
"preview": "Visualizar",
"download": "Baixar",
"delete": "Excluir",
@@ -1379,16 +1400,6 @@
"bulkDeleteTitle": "Excluir Compartilhamentos Selecionados",
"bulkDeleteConfirmation": "Tem certeza que deseja excluir {count, plural, =1 {1 compartilhamento} other {# compartilhamentos}} selecionado(s)? Esta ação não pode ser desfeita.",
"editTitle": "Editar Compartilhamento",
"nameLabel": "Nome do Compartilhamento",
"descriptionLabel": "Descrição",
"descriptionPlaceholder": "Digite uma descrição (opcional)",
"expirationLabel": "Data de Expiração",
"expirationPlaceholder": "DD/MM/AAAA HH:MM",
"maxViewsLabel": "Máximo de Visualizações",
"maxViewsPlaceholder": "Deixe vazio para ilimitado",
"passwordProtection": "Protegido por Senha",
"passwordLabel": "Senha",
"passwordPlaceholder": "Digite a senha",
"newPasswordLabel": "Nova Senha (deixe vazio para manter a atual)",
"newPasswordPlaceholder": "Digite a nova senha",
"manageFilesTitle": "Gerenciar Arquivos",
@@ -1404,7 +1415,9 @@
"linkDescriptionFile": "Gere um link personalizado para compartilhar o arquivo",
"linkDescriptionFolder": "Gere um link personalizado para compartilhar a pasta",
"linkReady": "Seu link de compartilhamento está pronto:",
"linkTitle": "Gerar link"
"linkTitle": "Gerar link",
"itemsSelected": "{count} itens selecionados",
"manageFilesDescription": "Selecione arquivos e pastas para incluir neste compartilhamento"
},
"shareDetails": {
"title": "Detalhes do Compartilhamento",
@@ -1420,7 +1433,6 @@
"noLink": "Nenhum link gerado ainda",
"copyLink": "Copiar link",
"openLink": "Abrir em nova guia",
"linkCopied": "Link copiado para a área de transferência",
"views": "Visualizações",
"dates": "Datas",
"created": "Criado",
@@ -1468,28 +1480,6 @@
"expires": "Expira:",
"expirationDate": "Data de expiração"
},
"shareFile": {
"title": "Compartilhar arquivo",
"linkTitle": "Gerar link",
"nameLabel": "Nome do compartilhamento",
"namePlaceholder": "Digite o nome do compartilhamento",
"descriptionLabel": "Descrição",
"descriptionPlaceholder": "Digite uma descrição (opcional)",
"expirationLabel": "Data de Expiração",
"expirationPlaceholder": "DD/MM/AAAA HH:MM",
"maxViewsLabel": "Máximo de Visualizações",
"maxViewsPlaceholder": "Deixe vazio para ilimitado",
"passwordProtection": "Protegido por senha",
"passwordLabel": "Senha",
"passwordPlaceholder": "Digite a senha",
"linkDescription": "Gere um link personalizado para compartilhar o arquivo",
"aliasLabel": "Alias do link",
"aliasPlaceholder": "Digite um alias personalizado",
"linkReady": "Seu link de compartilhamento está pronto:",
"createShare": "Criar compartilhamento",
"generateLink": "Gerar link",
"copyLink": "Copiar link"
},
"shareManager": {
"deleteSuccess": "Compartilhamento excluído com sucesso",
"deleteError": "Falha ao excluir compartilhamento",
@@ -1519,7 +1509,10 @@
"noFilesToDownload": "Nenhum arquivo disponível para download",
"singleShareZipName": "{sharename}.zip",
"zipDownloadError": "Falha ao criar o arquivo zip",
"zipDownloadSuccess": "FILE DE ZIP FILHADO COMBONHADO com sucesso"
"zipDownloadSuccess": "Arquivo ZIP baixado com sucesso",
"errors": {
"multipleDownloadNotSupported": "Download de múltiplos compartilhamentos ainda não é suportado - por favor, baixe os compartilhamentos individualmente"
}
},
"shareMultipleFiles": {
"title": "Compartilhar Múltiplos Arquivos",
@@ -1932,5 +1925,17 @@
"lastNameRequired": "O sobrenome é necessário",
"usernameLength": "O nome de usuário deve ter pelo menos 3 caracteres",
"usernameSpaces": "O nome de usuário não pode conter espaços"
},
"embedCode": {
"title": "Incorporar mídia",
"description": "Use estes códigos para incorporar esta mídia em fóruns, sites ou outras plataformas",
"tabs": {
"directLink": "Link direto",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "URL direto para o arquivo de mídia",
"htmlDescription": "Use este código para incorporar a mídia em páginas HTML",
"bbcodeDescription": "Use este código para incorporar a mídia em fóruns que suportam BBCode"
}
}
}

View File

@@ -6,7 +6,9 @@
"token_expired": "Token expirado. Tente novamente.",
"config_error": "Erro de configuração. Contate o suporte.",
"auth_failed": "Falha na autenticação. Tente novamente."
}
},
"authenticationFailed": "Ошибка аутентификации",
"successfullyAuthenticated": "Успешно аутентифицирован!"
},
"authProviders": {
"title": "Провайдеры аутентификации",
@@ -150,7 +152,9 @@
"move": "Переместить",
"rename": "Переименовать",
"search": "Поиск",
"share": "Поделиться"
"share": "Поделиться",
"copied": "Скопировано",
"copy": "Копировать"
},
"createShare": {
"title": "Создать общий доступ",
@@ -172,7 +176,14 @@
"tabs": {
"shareDetails": "Детали общего доступа",
"selectFiles": "Выбрать файлы"
}
},
"errors": {
"nameRequired": "Требуется имя общего ресурса",
"selectItems": "Выберите хотя бы один файл или папку"
},
"itemsSelected": "Выбрано элементов: {count}",
"passwordPlaceholder": "Введите пароль",
"selectItemsPrompt": "Выберите файлы и папки для общего доступа"
},
"customization": {
"breadcrumb": "Настройка",
@@ -338,7 +349,8 @@
"addToShare": "Добавить в общий доступ",
"removeFromShare": "Удалить из общего доступа",
"saveChanges": "Сохранить Изменения",
"editFolder": "Редактировать папку"
"editFolder": "Редактировать папку",
"itemsSelected": "Выбрано элементов: {count}"
},
"files": {
"title": "Все файлы",
@@ -374,7 +386,12 @@
"description": "Загрузите свой первый файл или создайте папку для начала работы"
},
"files": "файлы",
"folders": "папки"
"folders": "папки",
"errors": {
"moveItemsFailed": "Не удалось переместить элементы. Попробуйте еще раз.",
"cannotMoveHere": "Невозможно переместить элементы в это место"
},
"openFolder": "Открыть папку"
},
"filesTable": {
"ariaLabel": "Таблица файлов",
@@ -537,7 +554,10 @@
"movingTo": "Перемещение в:",
"title": "Переместить {count, plural, =1 {элемент} other {элементы}}",
"description": "Переместить {count, plural, =1 {элемент} other {элементы}} в новое место",
"success": "Успешно перемещено {count} {count, plural, =1 {элемент} other {элементов}}"
"success": "Успешно перемещено {count} {count, plural, =1 {элемент} other {элементов}}",
"errors": {
"moveFailed": "Не удалось переместить элементы"
}
},
"navbar": {
"logoAlt": "Логотип приложения",
@@ -1151,8 +1171,6 @@
},
"fileActions": {
"edit": "Редактировать",
"save": "Сохранить",
"cancel": "Отмена",
"preview": "Предпросмотр",
"download": "Скачать",
"delete": "Удалить",
@@ -1375,16 +1393,6 @@
"deleteTitle": "Удалить Общий Доступ",
"deleteConfirmation": "Вы уверены, что хотите удалить этот общий доступ? Это действие нельзя отменить.",
"editTitle": "Редактировать Общий Доступ",
"nameLabel": "Название Общего Доступа",
"descriptionLabel": "Описание",
"descriptionPlaceholder": "Введите описание (опционально)",
"expirationLabel": "Дата Истечения",
"expirationPlaceholder": "ДД.ММ.ГГГГ ЧЧ:ММ",
"maxViewsLabel": "Максимальные Просмотры",
"maxViewsPlaceholder": "Оставьте пустым для неограниченного",
"passwordProtection": "Защищено Паролем",
"passwordLabel": "Пароль",
"passwordPlaceholder": "Введите пароль",
"newPasswordLabel": "Новый Пароль (оставьте пустым, чтобы сохранить текущий)",
"newPasswordPlaceholder": "Введите новый пароль",
"manageFilesTitle": "Управление Файлами",
@@ -1403,7 +1411,9 @@
"linkDescriptionFile": "Создайте пользовательскую ссылку для обмена файлом",
"linkDescriptionFolder": "Создайте пользовательскую ссылку для обмена папкой",
"linkReady": "Ваша ссылка для обмена готова:",
"linkTitle": "Создать ссылку"
"linkTitle": "Создать ссылку",
"itemsSelected": "Выбрано элементов: {count}",
"manageFilesDescription": "Выберите файлы и папки для включения в этот общий доступ"
},
"shareDetails": {
"title": "Детали Общего Доступа",
@@ -1419,7 +1429,6 @@
"noLink": "Ссылка еще не сгенерирована",
"copyLink": "Скопировать ссылку",
"openLink": "Открыть в новой вкладке",
"linkCopied": "Ссылка скопирована в буфер обмена",
"views": "Просмотры",
"dates": "Даты",
"created": "Создано",
@@ -1467,28 +1476,6 @@
"expires": "Истекает:",
"expirationDate": "Дата истечения"
},
"shareFile": {
"title": "Поделиться Файлом",
"linkTitle": "Сгенерировать Ссылку",
"nameLabel": "Название Общего Доступа",
"namePlaceholder": "Введите название общего доступа",
"descriptionLabel": "Описание",
"descriptionPlaceholder": "Введите описание (опционально)",
"expirationLabel": "Дата Истечения",
"expirationPlaceholder": "ДД.ММ.ГГГГ ЧЧ:ММ",
"maxViewsLabel": "Максимальные Просмотры",
"maxViewsPlaceholder": "Оставьте пустым для неограниченного",
"passwordProtection": "Защищено Паролем",
"passwordLabel": "Пароль",
"passwordPlaceholder": "Введите пароль",
"linkDescription": "Сгенерируйте пользовательскую ссылку для отправки файла",
"aliasLabel": "Псевдоним Ссылки",
"aliasPlaceholder": "Введите пользовательский псевдоним",
"linkReady": "Ваша ссылка для общего доступа готова:",
"createShare": "Создать Общий Доступ",
"generateLink": "Сгенерировать Ссылку",
"copyLink": "Скопировать Ссылку"
},
"shareManager": {
"deleteSuccess": "Общий доступ успешно удален",
"deleteError": "Ошибка при удалении общего доступа",
@@ -1518,7 +1505,10 @@
"noFilesToDownload": "Нет файлов для скачивания",
"singleShareZipName": "{shareme} _files.zip",
"zipDownloadError": "Не удалось создать zip -файл",
"zipDownloadSuccess": "Zip -файл успешно загружен"
"zipDownloadSuccess": "Zip -файл успешно загружен",
"errors": {
"multipleDownloadNotSupported": "Множественная загрузка общих ресурсов пока не поддерживается - загружайте общие ресурсы по отдельности"
}
},
"shareMultipleFiles": {
"title": "Поделиться Несколькими Файлами",
@@ -1931,5 +1921,21 @@
"passwordRequired": "Требуется пароль",
"nameRequired": "Требуется имя",
"required": "Это поле обязательно"
},
"embedCode": {
"title": "Встроить медиа",
"description": "Используйте эти коды для встраивания этого медиа на форумах, веб-сайтах или других платформах",
"tabs": {
"directLink": "Прямая ссылка",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "Прямой URL-адрес медиафайла",
"htmlDescription": "Используйте этот код для встраивания медиа в HTML-страницы",
"bbcodeDescription": "Используйте этот код для встраивания медиа на форумах, поддерживающих BBCode"
},
"contextMenu": {
"newFolder": "Новая папка",
"uploadFile": "Загрузить файл"
}
}
}

1941
apps/web/messages/sv-SE.json Normal file

File diff suppressed because it is too large Load Diff

1941
apps/web/messages/th-TH.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,9 @@
"token_expired": "Token süresi doldu. Lütfen tekrar deneyin.",
"config_error": "Yapılandırma hatası. Lütfen destek ile iletişime geçin.",
"auth_failed": "Kimlik doğrulama başarısız. Lütfen tekrar deneyin."
}
},
"authenticationFailed": "Kimlik doğrulama başarısız",
"successfullyAuthenticated": "Başarıyla kimlik doğrulandı!"
},
"authProviders": {
"title": "Kimlik Doğrulama Sağlayıcıları",
@@ -150,7 +152,9 @@
"move": "Taşı",
"rename": "Yeniden Adlandır",
"search": "Ara",
"share": "Paylaş"
"share": "Paylaş",
"copied": "Kopyalandı",
"copy": "Kopyala"
},
"createShare": {
"title": "Paylaşım Oluştur",
@@ -172,7 +176,14 @@
"tabs": {
"shareDetails": "Paylaşım Detayları",
"selectFiles": "Dosyaları Seç"
}
},
"errors": {
"nameRequired": "Paylaşım adı gerekli",
"selectItems": "Lütfen en az bir dosya veya klasör seçin"
},
"itemsSelected": "{count} öğe seçildi",
"passwordPlaceholder": "Şifre girin",
"selectItemsPrompt": "Paylaşmak için dosya ve klasörleri seçin"
},
"customization": {
"breadcrumb": "Özelleştirme",
@@ -338,7 +349,8 @@
"addToShare": "Paylaşıma ekle",
"removeFromShare": "Paylaşımdan kaldır",
"saveChanges": "Değişiklikleri Kaydet",
"editFolder": "Klasörü düzenle"
"editFolder": "Klasörü düzenle",
"itemsSelected": "{count} öğe seçildi"
},
"files": {
"title": "Tüm Dosyalar",
@@ -374,7 +386,12 @@
"description": "Başlamak için ilk dosyanızı yükleyin veya bir klasör oluşturun"
},
"files": "dosyalar",
"folders": "klasörler"
"folders": "klasörler",
"errors": {
"moveItemsFailed": "Öğeler taşınamadı. Lütfen tekrar deneyin.",
"cannotMoveHere": "Öğeler bu konuma taşınamaz"
},
"openFolder": "Klasörü aç"
},
"filesTable": {
"ariaLabel": "Dosya Tablosu",
@@ -537,7 +554,10 @@
"movingTo": "Taşınıyor:",
"title": "{count, plural, =1 {Öğe} other {Öğeler}} Taşı",
"description": "{count, plural, =1 {Öğeyi} other {Öğeleri}} yeni konuma taşı",
"success": "{count} {count, plural, =1 {öğe} other {öğe}} başarıyla taşındı"
"success": "{count} {count, plural, =1 {öğe} other {öğe}} başarıyla taşındı",
"errors": {
"moveFailed": "Öğeler taşınamadı"
}
},
"navbar": {
"logoAlt": "Uygulama Logosu",
@@ -1151,8 +1171,6 @@
},
"fileActions": {
"edit": "Düzenle",
"save": "Kaydet",
"cancel": "İptal",
"preview": "Önizle",
"download": "İndir",
"delete": "Sil",
@@ -1375,16 +1393,6 @@
"deleteTitle": "Paylaşımı Sil",
"deleteConfirmation": "Bu paylaşımı silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
"editTitle": "Paylaşımı Düzenle",
"nameLabel": "Paylaşım Adı",
"descriptionLabel": "Açıklama",
"descriptionPlaceholder": "Açıklama girin (isteğe bağlı)",
"expirationLabel": "Son Kullanma Tarihi",
"expirationPlaceholder": "DD/MM/YYYY HH:MM",
"maxViewsLabel": "Maksimum Görüntüleme",
"maxViewsPlaceholder": "Sınırsız için boş bırakın",
"passwordProtection": "Şifre Korumalı",
"passwordLabel": "Şifre",
"passwordPlaceholder": "Şifre girin",
"newPasswordLabel": "Yeni Şifre (mevcut şifreyi korumak için boş bırakın)",
"newPasswordPlaceholder": "Yeni şifre girin",
"manageFilesTitle": "Dosyaları Yönet",
@@ -1403,7 +1411,9 @@
"linkDescriptionFile": "Dosyayı paylaşmak için özel bağlantı oluşturun",
"linkDescriptionFolder": "Klasörü paylaşmak için özel bağlantı oluşturun",
"linkReady": "Paylaşım bağlantınız hazır:",
"linkTitle": "Bağlantı Oluştur"
"linkTitle": "Bağlantı Oluştur",
"itemsSelected": "{count} öğe seçildi",
"manageFilesDescription": "Bu paylaşıma dahil edilecek dosya ve klasörleri seçin"
},
"shareDetails": {
"title": "Paylaşım Detayları",
@@ -1419,7 +1429,6 @@
"noLink": "Henüz bağlantı oluşturulmadı",
"copyLink": "Bağlantıyı kopyala",
"openLink": "Yeni sekmede aç",
"linkCopied": "Bağlantı panoya kopyalandı",
"views": "Görüntüleme",
"dates": "Tarihler",
"created": "Oluşturuldu",
@@ -1467,28 +1476,6 @@
"expires": "Sona erer:",
"expirationDate": "Son Kullanma Tarihi"
},
"shareFile": {
"title": "Dosya Paylaş",
"linkTitle": "Bağlantı Oluştur",
"nameLabel": "Paylaşım Adı",
"namePlaceholder": "Paylaşım adı girin",
"descriptionLabel": "Açıklama",
"descriptionPlaceholder": "Açıklama girin (isteğe bağlı)",
"expirationLabel": "Son Kullanma Tarihi",
"expirationPlaceholder": "DD/MM/YYYY HH:MM",
"maxViewsLabel": "Maksimum Görüntüleme",
"maxViewsPlaceholder": "Sınırsız için boş bırakın",
"passwordProtection": "Şifre Korumalı",
"passwordLabel": "Şifre",
"passwordPlaceholder": "Şifre girin",
"linkDescription": "Dosyayı paylaşmak için özel bağlantı oluşturun",
"aliasLabel": "Bağlantı Takma Adı",
"aliasPlaceholder": "Özel takma ad girin",
"linkReady": "Paylaşım bağlantınız hazır:",
"createShare": "Paylaşım Oluştur",
"generateLink": "Bağlantı Oluştur",
"copyLink": "Bağlantıyı Kopyala"
},
"shareManager": {
"deleteSuccess": "Paylaşım başarıyla silindi",
"deleteError": "Paylaşım silinemedi",
@@ -1518,7 +1505,10 @@
"noFilesToDownload": "İndirilebilecek dosya yok",
"singleShareZipName": "{Sharename} _files.zip",
"zipDownloadError": "Zip dosyası oluşturulamadı",
"zipDownloadSuccess": "Zip dosyası başarıyla indirildi"
"zipDownloadSuccess": "Zip dosyası başarıyla indirildi",
"errors": {
"multipleDownloadNotSupported": "Çoklu paylaşım indirme henüz desteklenmiyor - lütfen paylaşımları ayrı ayrı indirin"
}
},
"shareMultipleFiles": {
"title": "Çoklu Dosya Paylaş",
@@ -1931,5 +1921,21 @@
"passwordRequired": "Şifre gerekli",
"nameRequired": "İsim gereklidir",
"required": "Bu alan zorunludur"
},
"embedCode": {
"title": "Medyayı Yerleştir",
"description": "Bu medyayı forumlara, web sitelerine veya diğer platformlara yerleştirmek için bu kodları kullanın",
"tabs": {
"directLink": "Doğrudan Bağlantı",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "Medya dosyasının doğrudan URL'si",
"htmlDescription": "Medyayı HTML sayfalarına yerleştirmek için bu kodu kullanın",
"bbcodeDescription": "BBCode destekleyen forumlara medyayı yerleştirmek için bu kodu kullanın"
},
"contextMenu": {
"newFolder": "Yeni klasör",
"uploadFile": "Dosya yükle"
}
}
}

1941
apps/web/messages/uk-UA.json Normal file

File diff suppressed because it is too large Load Diff

1941
apps/web/messages/vi-VN.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,14 @@
{
"auth": {
"errors": {
"account_inactive": "Conta inativa. Entre em contato com o administrador.",
"registration_disabled": "Registro via SSO está desabilitado.",
"token_expired": "Token expirado. Tente novamente.",
"config_error": "Erro de configuração. Contate o suporte.",
"auth_failed": "Falha na autenticação. Tente novamente."
}
"account_inactive": "账户未激活。请联系管理员。",
"registration_disabled": "通过SSO注册已被禁用。",
"token_expired": "令牌已过期。请重试。",
"config_error": "配置错误。请联系支持人员。",
"auth_failed": "认证失败。请重试。"
},
"authenticationFailed": "身份验证失败",
"successfullyAuthenticated": "身份验证成功!"
},
"authProviders": {
"title": "身份验证提供商",
@@ -150,7 +152,9 @@
"move": "移动",
"rename": "重命名",
"search": "搜索",
"share": "分享"
"share": "分享",
"copied": "已复制",
"copy": "复制"
},
"createShare": {
"title": "创建分享",
@@ -172,7 +176,14 @@
"tabs": {
"shareDetails": "分享详情",
"selectFiles": "选择文件"
}
},
"errors": {
"nameRequired": "分享名称为必填项",
"selectItems": "请至少选择一个文件或文件夹"
},
"itemsSelected": "已选择 {count} 项",
"passwordPlaceholder": "输入密码",
"selectItemsPrompt": "选择要分享的文件和文件夹"
},
"customization": {
"breadcrumb": "自定义",
@@ -338,7 +349,8 @@
"addToShare": "添加到共享",
"removeFromShare": "从共享中移除",
"saveChanges": "保存更改",
"editFolder": "编辑文件夹"
"editFolder": "编辑文件夹",
"itemsSelected": "已选择 {count} 项"
},
"files": {
"title": "所有文件",
@@ -374,7 +386,12 @@
"description": "上传您的第一个文件或创建文件夹以开始使用"
},
"files": "文件",
"folders": "文件夹"
"folders": "文件夹",
"errors": {
"moveItemsFailed": "移动项目失败,请重试。",
"cannotMoveHere": "无法将项目移动到此位置"
},
"openFolder": "打开文件夹"
},
"filesTable": {
"ariaLabel": "文件表格",
@@ -537,7 +554,10 @@
"movingTo": "移动到:",
"title": "移动 {count, plural, =1 {项目} other {项目}}",
"description": "将 {count, plural, =1 {项目} other {项目}} 移动到新位置",
"success": "成功移动了 {count} 个项目"
"success": "成功移动了 {count} 个项目",
"errors": {
"moveFailed": "移动项目失败"
}
},
"navbar": {
"logoAlt": "应用Logo",
@@ -1151,8 +1171,6 @@
},
"fileActions": {
"edit": "编辑",
"save": "保存",
"cancel": "取消",
"preview": "预览",
"download": "下载",
"delete": "删除",
@@ -1375,22 +1393,12 @@
"deleteTitle": "删除共享",
"deleteConfirmation": "您确定要删除此共享吗?此操作不可撤销。",
"editTitle": "编辑共享",
"nameLabel": "共享名称",
"expirationLabel": "过期日期",
"expirationPlaceholder": "MM/DD/YYYY HH:MM",
"maxViewsLabel": "最大查看次数",
"maxViewsPlaceholder": "留空表示无限",
"passwordProtection": "密码保护",
"passwordLabel": "密码",
"passwordPlaceholder": "请输入密码",
"newPasswordLabel": "新密码(留空表示保持当前密码)",
"newPasswordPlaceholder": "请输入新密码",
"manageFilesTitle": "管理文件",
"manageRecipientsTitle": "管理收件人",
"editSuccess": "共享更新成功",
"editError": "更新共享失败",
"descriptionPlaceholder": "输入描述(可选)",
"descriptionLabel": "描述",
"bulkDeleteConfirmation": "您确定要删除{count, plural, =1 {1个共享} other {#个共享}}吗?此操作无法撤销。",
"bulkDeleteTitle": "删除选中的共享",
"addDescriptionPlaceholder": "添加描述...",
@@ -1403,7 +1411,9 @@
"linkDescriptionFile": "生成自定义链接以分享文件",
"linkDescriptionFolder": "生成自定义链接以分享文件夹",
"linkReady": "您的分享链接已准备好:",
"linkTitle": "生成链接"
"linkTitle": "生成链接",
"itemsSelected": "已选择 {count} 项",
"manageFilesDescription": "选择要包含在此分享中的文件和文件夹"
},
"shareDetails": {
"title": "共享详情",
@@ -1433,7 +1443,6 @@
"generateLink": "生成链接",
"noLink": "尚未生成链接",
"description": "描述",
"linkCopied": "链接已复制到剪贴板",
"editSecurity": "编辑安全",
"editExpiration": "编辑过期",
"clickToEnlargeQrCode": "点击放大QR Code",
@@ -1467,28 +1476,6 @@
"expires": "过期:",
"expirationDate": "过期日期"
},
"shareFile": {
"title": "分享文件",
"linkTitle": "生成链接",
"nameLabel": "分享名称",
"namePlaceholder": "输入分享名称",
"expirationLabel": "过期日期",
"expirationPlaceholder": "MM/DD/YYYY HH:MM",
"maxViewsLabel": "最大查看次数",
"maxViewsPlaceholder": "留空为无限制",
"passwordProtection": "密码保护",
"passwordLabel": "密码",
"passwordPlaceholder": "输入密码",
"linkDescription": "生成自定义链接来分享文件",
"aliasLabel": "链接别名",
"aliasPlaceholder": "输入自定义别名",
"linkReady": "您的分享链接已准备就绪:",
"createShare": "创建分享",
"generateLink": "生成链接",
"copyLink": "复制链接",
"descriptionLabel": "描述",
"descriptionPlaceholder": "输入描述(可选)"
},
"shareManager": {
"deleteSuccess": "共享删除成功",
"deleteError": "共享删除失败",
@@ -1518,7 +1505,10 @@
"noFilesToDownload": "无需下载文件",
"singleShareZipName": "{sharename} _files.zip",
"zipDownloadError": "无法创建zip文件",
"zipDownloadSuccess": "zip文件成功下载了"
"zipDownloadSuccess": "zip文件成功下载了",
"errors": {
"multipleDownloadNotSupported": "暂不支持多个分享下载 - 请分别下载各个分享"
}
},
"shareMultipleFiles": {
"title": "分享多个文件",
@@ -1931,5 +1921,21 @@
"passwordRequired": "密码为必填项",
"nameRequired": "名称为必填项",
"required": "此字段为必填项"
},
"embedCode": {
"title": "嵌入媒体",
"description": "使用这些代码将此媒体嵌入到论坛、网站或其他平台中",
"tabs": {
"directLink": "直接链接",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "媒体文件的直接URL",
"htmlDescription": "使用此代码将媒体嵌入HTML页面",
"bbcodeDescription": "使用此代码将媒体嵌入支持BBCode的论坛"
},
"contextMenu": {
"newFolder": "新建文件夹",
"uploadFile": "上传文件"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "palmr-web",
"version": "3.2.4-beta",
"version": "3.2.5-beta",
"description": "Frontend for Palmr",
"private": true,
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
@@ -27,7 +27,8 @@
"translations:check": "python3 scripts/run_translations.py check",
"translations:sync": "python3 scripts/run_translations.py sync",
"translations:dry-run": "python3 scripts/run_translations.py all --dry-run",
"translations:help": "python3 scripts/run_translations.py help"
"translations:help": "python3 scripts/run_translations.py help",
"translations:prune": "python3 scripts/prune_translations.py"
},
"dependencies": {
"@hello-pangea/dnd": "^18.0.1",
@@ -36,6 +37,7 @@
"@radix-ui/react-avatar": "^1.1.4",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
@@ -49,6 +51,8 @@
"@radix-ui/react-tabs": "^1.1.12",
"@tabler/icons-react": "^3.34.0",
"@types/react-dropzone": "^5.1.0",
"@uppy/aws-s3": "^4.3.2",
"@uppy/core": "^4.5.2",
"axios": "^1.10.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

5111
apps/web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""
Prune extra translation keys using en-US.json as the reference.
Removes keys that exist in target language files but not in the reference.
Usage examples:
python3 prune_translations.py --dry-run
python3 prune_translations.py --messages-dir ../messages
python3 prune_translations.py --reference en-US.json
"""
import json
from pathlib import Path
from typing import Dict, Any, Set, List, Tuple
import argparse
def load_json_file(file_path: Path) -> Dict[str, Any]:
try:
with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"Error loading {file_path}: {e}")
return {}
def save_json_file(file_path: Path, data: Dict[str, Any], indent: int = 2) -> bool:
try:
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=indent, separators=(',', ': '))
f.write('\n')
return True
except Exception as e:
print(f"Error saving {file_path}: {e}")
return False
def get_all_keys(data: Dict[str, Any], prefix: str = '') -> Set[str]:
keys: Set[str] = set()
for key, value in data.items():
current_key = f"{prefix}.{key}" if prefix else key
keys.add(current_key)
if isinstance(value, dict):
keys.update(get_all_keys(value, current_key))
return keys
def delete_nested_key(data: Dict[str, Any], key_path: str) -> bool:
"""Delete a nested key using a dotted path. Returns True if deleted."""
keys = key_path.split('.')
current: Any = data
for key in keys[:-1]:
if not isinstance(current, dict) or key not in current:
return False
current = current[key]
last = keys[-1]
if isinstance(current, dict) and last in current:
del current[last]
return True
return False
def prune_file(reference: Dict[str, Any], target: Dict[str, Any]) -> Tuple[Dict[str, Any], List[str]]:
ref_keys = get_all_keys(reference)
tgt_keys = get_all_keys(target)
extras = sorted(list(tgt_keys - ref_keys))
if not extras:
return target, []
# Work on a copy
pruned = json.loads(json.dumps(target))
for key in extras:
delete_nested_key(pruned, key)
return pruned, extras
def prune_translations(messages_dir: Path, reference_file: str = 'en-US.json', dry_run: bool = False) -> int:
reference_path = messages_dir / reference_file
if not reference_path.exists():
print(f"Reference file not found: {reference_path}")
return 1
print(f"Loading reference: {reference_file}")
ref = load_json_file(reference_path)
if not ref:
print("Error loading reference file")
return 1
files = [p for p in messages_dir.glob('*.json') if p.name != reference_file]
if not files:
print("No translation files found")
return 0
ref_count = len(get_all_keys(ref))
print(f"Reference keys: {ref_count}")
print(f"Processing {len(files)} files...\n")
total_removed = 0
changed_files = 0
for p in sorted(files):
data = load_json_file(p)
if not data:
print(f"{p.name}: ❌ load error")
continue
before = len(get_all_keys(data))
pruned, extras = prune_file(ref, data)
after = len(get_all_keys(pruned))
if not extras:
print(f"{p.name}: ✅ no extras ({before}/{ref_count})")
continue
print(f"{p.name}: 🧹 removing {len(extras)} extra key(s)")
# Optionally show first few extras
for k in extras[:5]:
print(f" - {k}")
if len(extras) > 5:
print(f" ... and {len(extras) - 5} more")
total_removed += len(extras)
changed_files += 1
if not dry_run:
if save_json_file(p, pruned):
print(f" ✅ saved ({after}/{ref_count})")
else:
print(f" ❌ save error")
else:
print(f" 📝 [DRY RUN] not saved")
print()
print("=" * 60)
print("SUMMARY")
print("=" * 60)
print(f"Files changed: {changed_files}")
print(f"Extra keys removed: {total_removed}")
if dry_run:
print("Mode: DRY RUN")
return 0
def main():
parser = argparse.ArgumentParser(description='Prune extra translation keys using reference file')
parser.add_argument('--messages-dir', type=Path, default=Path(__file__).parent.parent / 'messages', help='Messages directory')
parser.add_argument('--reference', default='en-US.json', help='Reference filename')
parser.add_argument('--dry-run', action='store_true', help='Only show what would be removed')
args = parser.parse_args()
if not args.messages_dir.exists():
print(f"Directory not found: {args.messages_dir}")
return 1
return prune_translations(args.messages_dir, args.reference, args.dry_run)
if __name__ == '__main__':
exit(main())

View File

@@ -1,8 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { IconCheck, IconFile, IconMail, IconUpload, IconUser, IconX } from "@tabler/icons-react";
import axios from "axios";
import { useTranslations } from "next-intl";
import { useDropzone } from "react-dropzone";
import { toast } from "sonner";
@@ -13,118 +12,103 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { Textarea } from "@/components/ui/textarea";
import { useUppyUpload } from "@/hooks/useUppyUpload";
import { getPresignedUrlForUploadByAlias, registerFileUploadByAlias } from "@/http/endpoints";
import { getSystemInfo } from "@/http/endpoints/app";
import { ChunkedUploader } from "@/utils/chunked-upload";
import { formatFileSize } from "@/utils/format-file-size";
import { FILE_STATUS, UPLOAD_CONFIG, UPLOAD_PROGRESS } from "../constants";
import { FileUploadSectionProps, FileWithProgress } from "../types";
import { UPLOAD_CONFIG } from "../constants";
import { FileUploadSectionProps } from "../types";
export function FileUploadSection({ reverseShare, password, alias, onUploadSuccess }: FileUploadSectionProps) {
const [files, setFiles] = useState<FileWithProgress[]>([]);
const [uploaderName, setUploaderName] = useState("");
const [uploaderEmail, setUploaderEmail] = useState("");
const [description, setDescription] = useState("");
const [isUploading, setIsUploading] = useState(false);
const [isS3Enabled, setIsS3Enabled] = useState<boolean | null>(null);
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(
(file: File): string | null => {
if (!reverseShare.maxFileSize) return null;
if (file.size > reverseShare.maxFileSize) {
return t("reverseShares.upload.errors.fileTooLarge", {
const { addFiles, startUpload, removeFile, retryUpload, fileUploads, isUploading } = useUppyUpload({
onValidate: async (file) => {
// Client-side validations
if (reverseShare.maxFileSize && file.size > reverseShare.maxFileSize) {
const error = t("reverseShares.upload.errors.fileTooLarge", {
maxSize: formatFileSize(reverseShare.maxFileSize),
});
toast.error(error);
throw new Error(error);
}
return null;
},
[reverseShare.maxFileSize, t]
);
const validateFileType = useCallback(
(file: File): string | null => {
if (!reverseShare.allowedFileTypes) return null;
const allowedTypes = reverseShare.allowedFileTypes.split(",").map((type) => type.trim().toLowerCase());
const fileExtension = file.name.split(".").pop()?.toLowerCase();
if (fileExtension && !allowedTypes.includes(fileExtension)) {
return t("reverseShares.upload.errors.fileTypeNotAllowed", {
allowedTypes: reverseShare.allowedFileTypes,
});
}
return null;
},
[reverseShare.allowedFileTypes, t]
);
const validateFileCount = useCallback((): string | null => {
if (!reverseShare.maxFiles) return null;
const totalFiles = files.length + 1 + reverseShare.currentFileCount;
if (totalFiles > reverseShare.maxFiles) {
return t("reverseShares.upload.errors.maxFilesExceeded", {
maxFiles: reverseShare.maxFiles,
});
}
return null;
}, [reverseShare.maxFiles, reverseShare.currentFileCount, files.length, t]);
const validateFile = useCallback(
(file: File): string | null => {
return validateFileSize(file) || validateFileType(file) || validateFileCount();
},
[validateFileSize, validateFileType, validateFileCount]
);
const createFileWithProgress = (file: File): FileWithProgress => ({
file,
progress: UPLOAD_PROGRESS.INITIAL,
status: FILE_STATUS.PENDING,
});
const processAcceptedFiles = useCallback(
(acceptedFiles: File[]): FileWithProgress[] => {
const validFiles: FileWithProgress[] = [];
for (const file of acceptedFiles) {
const validationError = validateFile(file);
if (validationError) {
toast.error(validationError);
continue;
if (reverseShare.allowedFileTypes) {
const extension = file.name.split(".").pop()?.toLowerCase();
const allowed = reverseShare.allowedFileTypes.split(",").map((t) => t.trim().toLowerCase());
if (extension && !allowed.includes(extension)) {
const error = t("reverseShares.upload.errors.fileTypeNotAllowed", {
allowedTypes: reverseShare.allowedFileTypes,
});
toast.error(error);
throw new Error(error);
}
validFiles.push(createFileWithProgress(file));
}
return validFiles;
if (reverseShare.maxFiles) {
const totalFiles = fileUploads.length + 1 + reverseShare.currentFileCount;
if (totalFiles > reverseShare.maxFiles) {
const error = t("reverseShares.upload.errors.maxFilesExceeded", {
maxFiles: reverseShare.maxFiles,
});
toast.error(error);
throw new Error(error);
}
}
},
[validateFile]
);
onBeforeUpload: async (file) => {
const timestamp = Date.now();
const sanitizedFileName = file.name.replace(/[^a-zA-Z0-9.-]/g, "_");
return `reverse-shares/${alias}/${timestamp}-${sanitizedFileName}`;
},
getPresignedUrl: async (objectName) => {
const response = await getPresignedUrlForUploadByAlias(
alias,
{ objectName },
password ? { password } : undefined
);
return { url: response.data.url, method: "PUT" };
},
onAfterUpload: async (fileId, file, objectName) => {
const fileExtension = file.name.split(".").pop() || "";
await registerFileUploadByAlias(
alias,
{
name: file.name,
description: description || undefined,
extension: fileExtension,
size: file.size,
objectName,
uploaderEmail: uploaderEmail || undefined,
uploaderName: uploaderName || undefined,
},
password ? { password } : undefined
);
},
onSuccess: () => {
const successCount = fileUploads.filter((u) => u.status === "success").length;
if (successCount > 0) {
toast.success(
t("reverseShares.upload.success.countMessage", {
count: successCount,
})
);
onUploadSuccess?.();
}
},
});
const onDrop = useCallback(
(acceptedFiles: File[]) => {
const newFiles = processAcceptedFiles(acceptedFiles);
setFiles((previousFiles) => [...previousFiles, ...newFiles]);
addFiles(acceptedFiles);
},
[processAcceptedFiles]
[addFiles]
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
@@ -133,132 +117,8 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
disabled: isUploading,
});
const removeFile = (index: number) => {
setFiles((previousFiles) => previousFiles.filter((_, i) => i !== index));
};
const updateFileStatus = (index: number, updates: Partial<FileWithProgress>) => {
setFiles((previousFiles) => previousFiles.map((file, i) => (i === index ? { ...file, ...updates } : file)));
};
const generateObjectName = (fileName: string): string => {
const timestamp = Date.now();
return `reverse-shares/${alias}/${timestamp}-${fileName}`;
};
const getFileExtension = (fileName: string): string => {
return fileName.split(".").pop() || "";
};
const calculateUploadTimeout = (fileSize: number): number => {
const baseTimeout = 300000;
const fileSizeMB = fileSize / (1024 * 1024);
if (fileSizeMB > 500) {
const extraMB = fileSizeMB - 500;
const extraMinutes = Math.ceil(extraMB / 100);
return baseTimeout + extraMinutes * 60000;
}
return baseTimeout;
};
const uploadFileToStorage = async (
file: File,
presignedUrl: string,
onProgress?: (progress: number) => void
): Promise<void> => {
const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size, isS3Enabled ?? undefined);
if (shouldUseChunked) {
const chunkSize = ChunkedUploader.calculateOptimalChunkSize(file.size);
const result = await ChunkedUploader.uploadFile({
file,
url: presignedUrl,
chunkSize,
isS3Enabled: isS3Enabled ?? undefined,
onProgress,
});
if (!result.success) {
throw new Error(result.error || "Chunked upload failed");
}
} else {
const uploadTimeout = calculateUploadTimeout(file.size);
await axios.put(presignedUrl, file, {
headers: {
"Content-Type": file.type,
},
timeout: uploadTimeout,
maxContentLength: Infinity,
maxBodyLength: Infinity,
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const progress = (progressEvent.loaded / progressEvent.total) * 100;
onProgress(Math.round(progress));
}
},
});
}
};
const registerUploadedFile = async (file: File, objectName: string): Promise<void> => {
const fileExtension = getFileExtension(file.name);
await registerFileUploadByAlias(
alias,
{
name: file.name,
description: description || undefined,
extension: fileExtension,
size: file.size,
objectName,
uploaderEmail: uploaderEmail || undefined,
uploaderName: uploaderName || undefined,
},
password ? { password } : undefined
);
};
const uploadFile = async (fileWithProgress: FileWithProgress, index: number): Promise<void> => {
const { file } = fileWithProgress;
try {
updateFileStatus(index, {
status: FILE_STATUS.UPLOADING,
progress: UPLOAD_PROGRESS.INITIAL,
});
const objectName = generateObjectName(file.name);
const presignedResponse = await getPresignedUrlForUploadByAlias(
alias,
{ objectName },
password ? { password } : undefined
);
await uploadFileToStorage(file, presignedResponse.data.url, (progress) => {
updateFileStatus(index, { progress });
});
updateFileStatus(index, { progress: UPLOAD_PROGRESS.COMPLETE });
await registerUploadedFile(file, objectName);
updateFileStatus(index, { status: FILE_STATUS.SUCCESS });
} catch (error: any) {
const errorMessage = error.response?.data?.error || t("reverseShares.upload.errors.uploadFailed");
updateFileStatus(index, {
status: FILE_STATUS.ERROR,
error: errorMessage,
});
toast.error(errorMessage);
}
};
const validateUploadRequirements = (): boolean => {
if (files.length === 0) {
if (fileUploads.length === 0) {
toast.error(t("reverseShares.upload.errors.selectAtLeastOneFile"));
return false;
}
@@ -279,36 +139,13 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
return true;
};
const processAllUploads = async (): Promise<void> => {
const uploadPromises = files.map((fileWithProgress, index) => uploadFile(fileWithProgress, index));
await Promise.all(uploadPromises);
const successfulUploads = files.filter((file) => file.status === FILE_STATUS.SUCCESS);
if (successfulUploads.length > 0) {
toast.success(
t("reverseShares.upload.success.countMessage", {
count: successfulUploads.length,
})
);
}
};
const handleUpload = async () => {
if (!validateUploadRequirements()) return;
setIsUploading(true);
try {
await processAllUploads();
} catch {
} finally {
setIsUploading(false);
}
startUpload();
};
const getCanUpload = (): boolean => {
if (files.length === 0 || isUploading) return false;
if (fileUploads.length === 0 || isUploading) return false;
const nameRequired = reverseShare.nameFieldRequired === "REQUIRED";
const emailRequired = reverseShare.emailFieldRequired === "REQUIRED";
@@ -325,16 +162,8 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
};
const canUpload = getCanUpload();
const allFilesProcessed = files.every(
(file) => file.status === FILE_STATUS.SUCCESS || file.status === FILE_STATUS.ERROR
);
const hasSuccessfulUploads = files.some((file) => file.status === FILE_STATUS.SUCCESS);
useEffect(() => {
if (allFilesProcessed && hasSuccessfulUploads && files.length > 0) {
onUploadSuccess?.();
}
}, [allFilesProcessed, hasSuccessfulUploads, files.length, onUploadSuccess]);
const allFilesProcessed = fileUploads.every((file) => file.status === "success" || file.status === "error");
const hasSuccessfulUploads = fileUploads.some((file) => file.status === "success");
const getDragActiveStyles = () => {
if (isDragActive) {
@@ -354,7 +183,7 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
const renderFileRestrictions = () => {
const calculateRemainingFiles = (): number => {
if (!reverseShare.maxFiles) return 0;
const currentTotal = reverseShare.currentFileCount + files.length;
const currentTotal = reverseShare.currentFileCount + fileUploads.length;
const remaining = reverseShare.maxFiles - currentTotal;
return Math.max(0, remaining);
};
@@ -387,8 +216,8 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
);
};
const renderFileStatusBadge = (fileWithProgress: FileWithProgress) => {
if (fileWithProgress.status === FILE_STATUS.SUCCESS) {
const renderFileStatusBadge = (fileStatus: string) => {
if (fileStatus === "success") {
return (
<Badge variant="default" className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
<IconCheck className="h-3 w-3 mr-1" />
@@ -397,51 +226,41 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
);
}
if (fileWithProgress.status === FILE_STATUS.ERROR) {
if (fileStatus === "error") {
return <Badge variant="destructive">{t("reverseShares.upload.fileList.statusError")}</Badge>;
}
return null;
};
const renderFileItem = (fileWithProgress: FileWithProgress, index: number) => (
<div key={index} className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
const renderFileItem = (upload: any) => (
<div key={upload.id} className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<IconFile className="h-5 w-5 text-gray-500 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">{fileWithProgress.file.name}</p>
<p className="text-xs text-gray-500">{formatFileSize(fileWithProgress.file.size)}</p>
{fileWithProgress.status === FILE_STATUS.UPLOADING && (
<Progress value={fileWithProgress.progress} className="mt-2 h-2" />
)}
{fileWithProgress.status === FILE_STATUS.ERROR && (
<p className="text-xs text-red-500 mt-1">{fileWithProgress.error}</p>
)}
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">{upload.file.name}</p>
<p className="text-xs text-gray-500">{formatFileSize(upload.file.size)}</p>
{upload.status === "uploading" && <Progress value={upload.progress} className="mt-2 h-2" />}
{upload.status === "error" && upload.error && <p className="text-xs text-red-500 mt-1">{upload.error}</p>}
</div>
<div className="flex items-center gap-2">
{renderFileStatusBadge(fileWithProgress)}
{fileWithProgress.status === FILE_STATUS.PENDING && (
<Button size="sm" variant="ghost" onClick={() => removeFile(index)} disabled={isUploading}>
{renderFileStatusBadge(upload.status)}
{upload.status === "pending" && (
<Button size="sm" variant="ghost" onClick={() => removeFile(upload.id)} disabled={isUploading}>
<IconX className="h-4 w-4" />
</Button>
)}
{fileWithProgress.status === FILE_STATUS.ERROR && (
{upload.status === "error" && (
<div className="flex gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => {
setFiles((prev) =>
prev.map((file, i) =>
i === index ? { ...file, status: FILE_STATUS.PENDING, error: undefined } : file
)
);
}}
onClick={() => retryUpload(upload.id)}
disabled={isUploading}
title={t("reverseShares.upload.errors.retry")}
>
<IconUpload className="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" onClick={() => removeFile(index)} disabled={isUploading}>
<Button size="sm" variant="ghost" onClick={() => removeFile(upload.id)} disabled={isUploading}>
<IconX className="h-4 w-4" />
</Button>
</div>
@@ -463,10 +282,10 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
{renderFileRestrictions()}
</div>
{files.length > 0 && (
{fileUploads.length > 0 && (
<div className="space-y-2">
<h4 className="font-medium text-gray-900 dark:text-white">{t("reverseShares.upload.fileList.title")}</h4>
{files.map(renderFileItem)}
{fileUploads.map(renderFileItem)}
</div>
)}
@@ -528,7 +347,7 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
<Button onClick={handleUpload} disabled={!canUpload} className="w-full text-white" size="lg" variant="default">
{isUploading
? t("reverseShares.upload.form.uploading")
: t("reverseShares.upload.form.uploadButton", { count: files.length })}
: t("reverseShares.upload.form.uploadButton", { count: fileUploads.length })}
</Button>
{allFilesProcessed && hasSuccessfulUploads && (

View File

@@ -41,10 +41,10 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import {
copyReverseShareFileToUserFiles,
deleteReverseShareFile,
downloadReverseShareFile,
updateReverseShareFile,
} from "@/http/endpoints/reverse-shares";
import type { ReverseShareFile } from "@/http/endpoints/reverse-shares/types";
import { bulkDownloadWithQueue, downloadReverseShareWithQueue } from "@/utils/download-queue-utils";
import { getFileIcon } from "@/utils/file-icons";
import { truncateFileName } from "@/utils/file-utils";
import { ReverseShare } from "../hooks/use-reverse-shares";
@@ -471,13 +471,21 @@ export function ReceivedFilesModal({
const handleDownload = async (file: ReverseShareFile) => {
try {
await downloadReverseShareWithQueue(file.id, file.name, {
onComplete: () => toast.success(t("reverseShares.modals.receivedFiles.downloadSuccess")),
onFail: () => toast.error(t("reverseShares.modals.receivedFiles.downloadError")),
});
const loadingToast = toast.loading(t("reverseShares.modals.receivedFiles.downloading") || "Downloading...");
const response = await downloadReverseShareFile(file.id);
const link = document.createElement("a");
link.href = response.data.url;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.dismiss(loadingToast);
toast.success(t("reverseShares.modals.receivedFiles.downloadSuccess"));
} catch (error) {
console.error("Download error:", error);
// Error already handled in downloadReverseShareWithQueue
toast.error(t("reverseShares.modals.receivedFiles.downloadError"));
}
};
@@ -600,25 +608,28 @@ export function ReceivedFilesModal({
if (selectedFileObjects.length === 0) return;
try {
const zipName = `${reverseShare.name || t("reverseShares.defaultLinkName")}_files.zip`;
const loadingToast = toast.loading(t("shareManager.creatingZip"));
toast.promise(
bulkDownloadWithQueue(
selectedFileObjects.map((file) => ({
name: file.name,
id: file.id,
isReverseShare: true,
})),
zipName
).then(() => {
setSelectedFiles(new Set());
}),
{
loading: t("shareManager.creatingZip"),
success: t("shareManager.zipDownloadSuccess"),
error: t("shareManager.zipDownloadError"),
try {
// Download files individually
for (const file of selectedFileObjects) {
const response = await downloadReverseShareFile(file.id);
const link = document.createElement("a");
link.href = response.data.url;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
);
toast.dismiss(loadingToast);
toast.success(t("shareManager.zipDownloadSuccess"));
setSelectedFiles(new Set());
} catch (error) {
toast.dismiss(loadingToast);
toast.error(t("shareManager.zipDownloadError"));
throw error;
}
} catch (error) {
console.error("Error creating ZIP:", error);
}

View File

@@ -6,9 +6,8 @@ import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { deleteReverseShareFile } from "@/http/endpoints/reverse-shares";
import { deleteReverseShareFile, downloadReverseShareFile } from "@/http/endpoints/reverse-shares";
import type { ReverseShareFile } from "@/http/endpoints/reverse-shares/types";
import { downloadReverseShareWithQueue } from "@/utils/download-queue-utils";
import { getFileIcon } from "@/utils/file-icons";
import { ReverseShareFilePreviewModal } from "./reverse-share-file-preview-modal";
@@ -56,13 +55,21 @@ export function ReceivedFilesSection({ files, onFileDeleted }: ReceivedFilesSect
const handleDownload = async (file: ReverseShareFile) => {
try {
await downloadReverseShareWithQueue(file.id, file.name, {
onComplete: () => toast.success(t("reverseShares.modals.details.downloadSuccess")),
onFail: () => toast.error(t("reverseShares.modals.details.downloadError")),
});
const loadingToast = toast.loading(t("reverseShares.modals.details.downloading") || "Downloading...");
const response = await downloadReverseShareFile(file.id);
const link = document.createElement("a");
link.href = response.data.url;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.dismiss(loadingToast);
toast.success(t("reverseShares.modals.details.downloadSuccess"));
} catch (error) {
console.error("Download error:", error);
// Error already handled in downloadReverseShareWithQueue
toast.error(t("reverseShares.modals.details.downloadError"));
}
};

View File

@@ -152,10 +152,10 @@ export function ShareFilesTable({
variant="ghost"
className="h-8 w-8 hover:bg-muted"
onClick={() => handleFolderClick(item.id)}
title="Open folder"
title={t("files.openFolder")}
>
<IconFolder className="h-4 w-4" />
<span className="sr-only">Open folder</span>
<span className="sr-only">{t("files.openFolder")}</span>
</Button>
)}
<Button

View File

@@ -7,11 +7,7 @@ import { toast } from "sonner";
import { getShareByAlias } from "@/http/endpoints/index";
import type { Share } from "@/http/endpoints/shares/types";
import {
bulkDownloadShareWithQueue,
downloadFileWithQueue,
downloadShareFolderWithQueue,
} from "@/utils/download-queue-utils";
import { getCachedDownloadUrl } from "@/lib/download-url-cache";
const createSlug = (name: string): string => {
return name
@@ -229,11 +225,65 @@ export function usePublicShare() {
throw new Error("Share data not available");
}
await downloadShareFolderWithQueue(folderId, folderName, share.files || [], share.folders || [], {
silent: true,
showToasts: false,
sharePassword: password,
});
// Get all files in this folder and subfolders with their paths
const getFolderFilesWithPath = (
targetFolderId: string,
currentPath: string = ""
): Array<{ file: any; path: string }> => {
const filesWithPath: Array<{ file: any; path: string }> = [];
// Get direct files in this folder
const directFiles = share.files?.filter((f) => f.folderId === targetFolderId) || [];
directFiles.forEach((file) => {
filesWithPath.push({ file, path: currentPath });
});
// Get subfolders and process them recursively
const subfolders = share.folders?.filter((f) => f.parentId === targetFolderId) || [];
for (const subfolder of subfolders) {
const subfolderPath = currentPath ? `${currentPath}/${subfolder.name}` : subfolder.name;
filesWithPath.push(...getFolderFilesWithPath(subfolder.id, subfolderPath));
}
return filesWithPath;
};
const folderFilesWithPath = getFolderFilesWithPath(folderId);
if (folderFilesWithPath.length === 0) {
toast.error(t("shareManager.noFilesToDownload"));
return;
}
const loadingToast = toast.loading(t("shareManager.creatingZip"));
try {
// Get presigned URLs for all files with their relative paths
const downloadItems = await Promise.all(
folderFilesWithPath.map(async ({ file, path }) => {
const url = await getCachedDownloadUrl(
file.objectName,
password ? { headers: { "x-share-password": password } } : undefined
);
return {
url,
name: path ? `${path}/${file.name}` : file.name,
};
})
);
// Create ZIP with all files
const { downloadFilesAsZip } = await import("@/utils/zip-download");
const zipName = `${folderName}.zip`;
await downloadFilesAsZip(downloadItems, zipName);
toast.dismiss(loadingToast);
toast.success(t("shareManager.zipDownloadSuccess"));
} catch (error) {
toast.dismiss(loadingToast);
toast.error(t("shareManager.zipDownloadError"));
throw error;
}
} catch (error) {
console.error("Error downloading folder:", error);
throw error;
@@ -244,26 +294,30 @@ export function usePublicShare() {
try {
if (objectName.startsWith("folder:")) {
const folderId = objectName.replace("folder:", "");
await toast.promise(handleFolderDownload(folderId, fileName), {
loading: t("shareManager.creatingZip"),
success: t("shareManager.zipDownloadSuccess"),
error: t("share.errors.downloadFailed"),
});
} else {
await toast.promise(
downloadFileWithQueue(objectName, fileName, {
silent: true,
showToasts: false,
sharePassword: password,
}),
{
loading: t("share.messages.downloadStarted"),
success: t("shareManager.downloadSuccess"),
error: t("share.errors.downloadFailed"),
}
);
await handleFolderDownload(folderId, fileName);
return;
}
} catch {}
const loadingToast = toast.loading(t("share.messages.downloadStarted"));
const url = await getCachedDownloadUrl(
objectName,
password ? { headers: { "x-share-password": password } } : undefined
);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.dismiss(loadingToast);
toast.success(t("shareManager.downloadSuccess"));
} catch (error) {
console.error("Error downloading file:", error);
toast.error(t("share.errors.downloadFailed"));
}
};
const handleBulkDownload = async () => {
@@ -281,8 +335,6 @@ export function usePublicShare() {
}
try {
const zipName = `${share.name || t("shareManager.defaultShareName")}.zip`;
// Prepare all items for the share-specific bulk download
const allItems: Array<{
objectName?: string;
@@ -321,22 +373,43 @@ export function usePublicShare() {
return;
}
toast.promise(
bulkDownloadShareWithQueue(
allItems,
share.files || [],
share.folders || [],
zipName,
undefined,
true,
password
).then(() => {}),
{
loading: t("shareManager.creatingZip"),
success: t("shareManager.zipDownloadSuccess"),
error: t("shareManager.zipDownloadError"),
const loadingToast = toast.loading(t("shareManager.creatingZip"));
try {
// Get presigned URLs for all files
const downloadItems = await Promise.all(
allItems
.filter((item) => item.type === "file" && item.objectName)
.map(async (item) => {
const url = await getCachedDownloadUrl(
item.objectName!,
password ? { headers: { "x-share-password": password } } : undefined
);
return {
url,
name: item.name,
};
})
);
if (downloadItems.length === 0) {
toast.dismiss(loadingToast);
toast.error(t("shareManager.noFilesToDownload"));
return;
}
);
// Create ZIP with all files
const { downloadFilesAsZip } = await import("@/utils/zip-download");
const zipName = `${share.name || t("shareManager.defaultShareName")}.zip`;
await downloadFilesAsZip(downloadItems, zipName);
toast.dismiss(loadingToast);
toast.success(t("shareManager.zipDownloadSuccess"));
} catch (error) {
toast.dismiss(loadingToast);
toast.error(t("shareManager.zipDownloadError"));
throw error;
}
} catch (error) {
console.error("Error creating ZIP:", error);
}
@@ -354,62 +427,86 @@ export function usePublicShare() {
}
try {
// Get all file IDs that belong to selected folders
const filesInSelectedFolders = new Set<string>();
for (const folder of folders) {
const folderFiles = share.files?.filter((f) => f.folderId === folder.id) || [];
folderFiles.forEach((f) => filesInSelectedFolders.add(f.id));
const loadingToast = toast.loading(t("shareManager.creatingZip"));
// Also check nested folders recursively
const checkNestedFolders = (parentId: string) => {
const nestedFolders = share.folders?.filter((f) => f.parentId === parentId) || [];
for (const nestedFolder of nestedFolders) {
const nestedFiles = share.files?.filter((f) => f.folderId === nestedFolder.id) || [];
nestedFiles.forEach((f) => filesInSelectedFolders.add(f.id));
checkNestedFolders(nestedFolder.id);
try {
// Helper function to get all files in a folder recursively with paths
const getFolderFilesWithPath = (
targetFolderId: string,
currentPath: string = ""
): Array<{ file: any; path: string }> => {
const filesWithPath: Array<{ file: any; path: string }> = [];
// Get direct files in this folder
const directFiles = share.files?.filter((f) => f.folderId === targetFolderId) || [];
directFiles.forEach((file) => {
filesWithPath.push({ file, path: currentPath });
});
// Get subfolders and process them recursively
const subfolders = share.folders?.filter((f) => f.parentId === targetFolderId) || [];
for (const subfolder of subfolders) {
const subfolderPath = currentPath ? `${currentPath}/${subfolder.name}` : subfolder.name;
filesWithPath.push(...getFolderFilesWithPath(subfolder.id, subfolderPath));
}
return filesWithPath;
};
checkNestedFolders(folder.id);
}
const allItems = [
...files
.filter((file) => !filesInSelectedFolders.has(file.id))
.map((file) => ({
objectName: file.objectName,
name: file.name,
type: "file" as const,
})),
// Add only top-level folders (avoid duplicating nested folders)
...folders
.filter((folder) => {
return !folder.parentId || !folders.some((f) => f.id === folder.parentId);
const allFilesToDownload: Array<{ url: string; name: string }> = [];
// Get presigned URLs for direct files (not in folders)
const directFileItems = await Promise.all(
files.map(async (file) => {
const url = await getCachedDownloadUrl(
file.objectName,
password ? { headers: { "x-share-password": password } } : undefined
);
return {
url,
name: file.name,
};
})
.map((folder) => ({
id: folder.id,
name: folder.name,
type: "folder" as const,
})),
];
);
allFilesToDownload.push(...directFileItems);
const zipName = `${share.name || t("shareManager.defaultShareName")}-selected.zip`;
// Get presigned URLs for files in selected folders
for (const folder of folders) {
const folderFilesWithPath = getFolderFilesWithPath(folder.id, folder.name);
toast.promise(
bulkDownloadShareWithQueue(
allItems,
share.files || [],
share.folders || [],
zipName,
undefined,
false,
password
).then(() => {}),
{
loading: t("shareManager.creatingZip"),
success: t("shareManager.zipDownloadSuccess"),
error: t("shareManager.zipDownloadError"),
const folderFileItems = await Promise.all(
folderFilesWithPath.map(async ({ file, path }) => {
const url = await getCachedDownloadUrl(
file.objectName,
password ? { headers: { "x-share-password": password } } : undefined
);
return {
url,
name: path ? `${path}/${file.name}` : file.name,
};
})
);
allFilesToDownload.push(...folderFileItems);
}
);
if (allFilesToDownload.length === 0) {
toast.dismiss(loadingToast);
toast.error(t("shareManager.noFilesToDownload"));
return;
}
// Create ZIP with all files
const { downloadFilesAsZip } = await import("@/utils/zip-download");
const finalZipName = `${share.name || t("shareManager.defaultShareName")}-selected.zip`;
await downloadFilesAsZip(allFilesToDownload, finalZipName);
toast.dismiss(loadingToast);
toast.success(t("shareManager.zipDownloadSuccess"));
} catch (error) {
toast.dismiss(loadingToast);
toast.error(t("shareManager.zipDownloadError"));
throw error;
}
} catch (error) {
console.error("Error creating ZIP:", error);
toast.error(t("shareManager.zipDownloadError"));

View File

@@ -1,66 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
import { detectMimeTypeWithFallback } from "@/utils/mime-types";
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 searchParams = req.nextUrl.searchParams;
const objectName = searchParams.get("objectName");
try {
const { searchParams } = new URL(req.url);
const queryString = searchParams.toString();
const url = `${API_BASE_URL}/files/download${queryString ? `?${queryString}` : ""}`;
if (!objectName) {
return new NextResponse(JSON.stringify({ error: "objectName parameter is required" }), {
status: 400,
const apiRes = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
cookie: req.headers.get("cookie") || "",
...Object.fromEntries(
Array.from(req.headers.entries()).filter(
([key]) =>
key.startsWith("authorization") ||
key.startsWith("x-forwarded") ||
key === "user-agent" ||
key === "accept"
)
),
},
redirect: "manual",
});
}
const queryString = searchParams.toString();
const url = `${API_BASE_URL}/files/download?${queryString}`;
if (!apiRes.ok) {
const errorText = await apiRes.text();
return new NextResponse(errorText, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
}
const apiRes = await fetch(url, {
method: "GET",
headers: {
cookie: cookieHeader || "",
},
redirect: "manual",
});
// Stream the file content
const contentType = apiRes.headers.get("content-type") || "application/octet-stream";
const contentDisposition = apiRes.headers.get("content-disposition");
const contentLength = apiRes.headers.get("content-length");
const cacheControl = apiRes.headers.get("cache-control");
if (!apiRes.ok) {
const resBody = await apiRes.text();
return new NextResponse(resBody, {
const res = new NextResponse(apiRes.body, {
status: apiRes.status,
headers: {
"Content-Type": contentType,
},
});
if (contentDisposition) {
res.headers.set("Content-Disposition", contentDisposition);
}
if (contentLength) {
res.headers.set("Content-Length", contentLength);
}
if (cacheControl) {
res.headers.set("Cache-Control", cacheControl);
}
return res;
} catch (error) {
console.error("Error proxying download request:", error);
return new NextResponse(JSON.stringify({ error: "Failed to download file" }), {
status: 500,
headers: {
"Content-Type": "application/json",
},
});
}
const serverContentType = apiRes.headers.get("Content-Type");
const contentDisposition = apiRes.headers.get("Content-Disposition");
const contentLength = apiRes.headers.get("Content-Length");
const acceptRanges = apiRes.headers.get("Accept-Ranges");
const contentRange = apiRes.headers.get("Content-Range");
const contentType = detectMimeTypeWithFallback(serverContentType, contentDisposition, objectName);
const res = new NextResponse(apiRes.body, {
status: apiRes.status,
headers: {
"Content-Type": contentType,
...(contentLength && { "Content-Length": contentLength }),
...(acceptRanges && { "Accept-Ranges": acceptRanges }),
...(contentRange && { "Content-Range": contentRange }),
...(contentDisposition && { "Content-Disposition": contentDisposition }),
},
});
const setCookie = apiRes.headers.getSetCookie?.() || [];
if (setCookie.length > 0) {
res.headers.set("Set-Cookie", setCookie.join(","));
}
return res;
}

View File

@@ -2,25 +2,27 @@ import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ fileId: string }> }) {
const { fileId } = await params;
export async function POST(req: NextRequest) {
const cookieHeader = req.headers.get("cookie");
const url = `${API_BASE_URL}/filesystem/cancel-upload/${fileId}`;
const body = await req.text();
const url = `${API_BASE_URL}/files/multipart/abort`;
const apiRes = await fetch(url, {
method: "DELETE",
method: "POST",
headers: {
"Content-Type": "application/json",
cookie: cookieHeader || "",
},
body,
redirect: "manual",
});
const contentType = apiRes.headers.get("Content-Type") || "application/json";
const resBody = await apiRes.text();
const res = new NextResponse(resBody, {
status: apiRes.status,
headers: {
"Content-Type": contentType,
"Content-Type": "application/json",
},
});

View File

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

View File

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

View File

@@ -2,25 +2,25 @@ import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function GET(req: NextRequest, { params }: { params: Promise<{ fileId: string }> }) {
const { fileId } = await params;
export async function GET(req: NextRequest) {
const cookieHeader = req.headers.get("cookie");
const url = `${API_BASE_URL}/filesystem/upload-progress/${fileId}`;
const searchParams = req.nextUrl.searchParams.toString();
const url = `${API_BASE_URL}/files/multipart/part-url${searchParams ? `?${searchParams}` : ""}`;
const apiRes = await fetch(url, {
method: "GET",
headers: {
cookie: cookieHeader || "",
},
redirect: "manual",
});
const contentType = apiRes.headers.get("Content-Type") || "application/json";
const resBody = await apiRes.text();
const res = new NextResponse(resBody, {
status: apiRes.status,
headers: {
"Content-Type": contentType,
"Content-Type": "application/json",
},
});

View File

@@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
/**
* Upload proxy for internal storage system
*
* This proxy is ONLY used when ENABLE_S3=false (internal storage mode).
* External S3 uploads use presigned URLs directly from the client.
*
* Why we need this proxy:
* 1. Security: Internal storage is not exposed to the internet
* 2. Simplicity: No need to configure CORS on storage system
* 3. Compatibility: Works in any network setup
*
* Performance note: Node.js streams the upload efficiently with minimal memory overhead
*/
async function handleUpload(req: NextRequest, method: "POST" | "PUT") {
try {
const { searchParams } = new URL(req.url);
const queryString = searchParams.toString();
const url = `${API_BASE_URL}/files/upload${queryString ? `?${queryString}` : ""}`;
const body = req.body;
const apiRes = await fetch(url, {
method: method,
headers: {
"Content-Type": req.headers.get("content-type") || "application/octet-stream",
cookie: req.headers.get("cookie") || "",
...Object.fromEntries(
Array.from(req.headers.entries()).filter(
([key]) =>
key.startsWith("authorization") ||
key.startsWith("x-forwarded") ||
key === "user-agent" ||
key === "accept"
)
),
},
body: body,
// Required for streaming request bodies in Node.js 18+ / Next.js 15
// See: https://nodejs.org/docs/latest-v18.x/api/fetch.html#request-duplex
// @ts-expect-error - duplex not yet in TypeScript types but required at runtime
duplex: "half",
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;
} catch (error) {
console.error("Error proxying upload request:", error);
return new NextResponse(JSON.stringify({ error: "Failed to upload file" }), {
status: 500,
headers: {
"Content-Type": "application/json",
},
});
}
}
export async function POST(req: NextRequest) {
return handleUpload(req, "POST");
}
export async function PUT(req: NextRequest) {
return handleUpload(req, "PUT");
}

View File

@@ -1,38 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ downloadId: string }> }) {
const { downloadId } = await params;
const cookieHeader = req.headers.get("cookie");
const url = `${API_BASE_URL}/filesystem/download-queue/${downloadId}`;
try {
const apiRes = await fetch(url, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
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;
} catch (error) {
console.error("Error proxying cancel download request:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -1,37 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function DELETE(req: NextRequest) {
const cookieHeader = req.headers.get("cookie");
const url = `${API_BASE_URL}/filesystem/download-queue`;
try {
const apiRes = await fetch(url, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
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;
} catch (error) {
console.error("Error proxying clear download queue request:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -1,37 +0,0 @@
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}/filesystem/download-queue/status`;
try {
const apiRes = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
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;
} catch (error) {
console.error("Error proxying download queue status request:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -1,39 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { detectMimeTypeWithFallback } from "@/utils/mime-types";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function GET(req: NextRequest, { params }: { params: Promise<{ token: string }> }) {
const { token } = await params;
const cookieHeader = req.headers.get("cookie");
const url = `${API_BASE_URL}/filesystem/download/${token}`;
const apiRes = await fetch(url, {
method: "GET",
headers: {
cookie: cookieHeader || "",
},
});
const serverContentType = apiRes.headers.get("Content-Type");
const contentDisposition = apiRes.headers.get("Content-Disposition");
const contentLength = apiRes.headers.get("Content-Length");
const contentType = detectMimeTypeWithFallback(serverContentType, contentDisposition);
const res = new NextResponse(apiRes.body, {
status: apiRes.status,
headers: {
"Content-Type": contentType,
...(contentDisposition && { "Content-Disposition": contentDisposition }),
...(contentLength && { "Content-Length": contentLength }),
},
});
const setCookie = apiRes.headers.getSetCookie?.() || [];
if (setCookie.length > 0) {
res.headers.set("Set-Cookie", setCookie.join(","));
}
return res;
}

View File

@@ -1,69 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
export const maxDuration = 120000; // 2 minutes to handle large files
export const dynamic = "force-dynamic";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function PUT(req: NextRequest, { params }: { params: Promise<{ token: string }> }) {
const { token } = await params;
const cookieHeader = req.headers.get("cookie");
const url = `${API_BASE_URL}/filesystem/upload/${token}`;
const headers: Record<string, string> = {
cookie: cookieHeader || "",
"Content-Type": req.headers.get("Content-Type") || "application/octet-stream",
"Content-Length": req.headers.get("Content-Length") || "0",
};
req.headers.forEach((value, key) => {
if (key.startsWith("x-") || key.startsWith("X-")) {
headers[key] = value;
}
});
try {
const apiRes = await fetch(url, {
method: "PUT",
headers,
body: req.body,
duplex: "half",
} as RequestInit);
const contentType = apiRes.headers.get("Content-Type") || "application/json";
let resBody;
if (contentType.includes("application/json")) {
resBody = await apiRes.text();
} else {
resBody = await apiRes.arrayBuffer();
}
const res = new NextResponse(resBody, {
status: apiRes.status,
headers: {
"Content-Type": contentType,
},
});
const setCookie = apiRes.headers.getSetCookie?.() || [];
if (setCookie.length > 0) {
res.headers.set("Set-Cookie", setCookie.join(","));
}
return res;
} catch (error) {
return new NextResponse(
JSON.stringify({
error: "Proxy request failed",
details: error instanceof Error ? error.message : "Unknown error",
}),
{
status: 500,
headers: {
"Content-Type": "application/json",
},
}
);
}
}

View File

@@ -2,6 +2,7 @@
import { useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { useAuth } from "@/contexts/auth-context";
@@ -11,6 +12,7 @@ export default function AuthCallbackPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { setUser, setIsAuthenticated, setIsAdmin } = useAuth();
const t = useTranslations();
useEffect(() => {
const token = searchParams.get("token");
@@ -59,14 +61,14 @@ export default function AuthCallbackPage() {
setUser(userData);
setIsAdmin(isAdmin);
setIsAuthenticated(true);
toast.success("Successfully authenticated!");
toast.success(t("auth.successfullyAuthenticated"));
router.push("/dashboard");
} else {
throw new Error("No user data received");
}
} catch (error) {
console.error("Error fetching user data:", error);
toast.error("Authentication failed");
toast.error(t("auth.authenticationFailed"));
router.push("/login");
}
};

View File

@@ -34,8 +34,6 @@ export function BackgroundPickerForm() {
const applyBackground = useCallback((backgroundValues: { light: string; dark: string }) => {
document.documentElement.style.setProperty("--custom-background-light", backgroundValues.light);
document.documentElement.style.setProperty("--custom-background-dark", backgroundValues.dark);
console.log("Applied background:", backgroundValues);
}, []);
useEffect(() => {

View File

@@ -36,8 +36,6 @@ export function FontPickerForm() {
document.documentElement.style.setProperty("--font-serif", fontValue);
document.body.style.fontFamily = fontValue;
console.log("Applied font:", fontValue);
}, []);
useEffect(() => {

View File

@@ -26,7 +26,6 @@ export function RadiusPickerForm() {
const [isCollapsed, setIsCollapsed] = useState(true);
const applyRadius = useCallback((radiusValue: string) => {
document.documentElement.style.setProperty("--radius", radiusValue);
console.log("Applied radius:", radiusValue);
}, []);
useEffect(() => {

View File

@@ -46,8 +46,6 @@ export default function DashboardPage() {
icon={<IconLayoutDashboardFilled className="text-xl" />}
showBreadcrumb={false}
title={t("dashboard.pageTitle")}
pendingDownloads={fileManager.pendingDownloads}
onCancelDownload={fileManager.cancelPendingDownload}
>
<StorageUsage diskSpace={diskSpace} diskSpaceError={diskSpaceError} onRetry={handleRetryDiskSpace} />
<QuickAccessCards />

View File

@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
/**
* Short public embed endpoint: /e/{id}
* No authentication required
* Only works for media files (images, videos, audio)
*/
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
if (!id) {
return new NextResponse(JSON.stringify({ error: "File ID is required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const url = `${API_BASE_URL}/embed/${id}`;
try {
const apiRes = await fetch(url, {
method: "GET",
redirect: "manual",
});
if (!apiRes.ok) {
const errorText = await apiRes.text();
return new NextResponse(errorText, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
}
const blob = await apiRes.blob();
const contentType = apiRes.headers.get("content-type") || "application/octet-stream";
const contentDisposition = apiRes.headers.get("content-disposition");
const cacheControl = apiRes.headers.get("cache-control");
const res = new NextResponse(blob, {
status: apiRes.status,
headers: {
"Content-Type": contentType,
},
});
if (contentDisposition) {
res.headers.set("Content-Disposition", contentDisposition);
}
if (cacheControl) {
res.headers.set("Cache-Control", cacheControl);
} else {
res.headers.set("Cache-Control", "public, max-age=31536000");
}
return res;
} catch (error) {
console.error("Error proxying embed request:", error);
return new NextResponse(JSON.stringify({ error: "Failed to fetch file" }), {
status: 500,
headers: {
"Content-Type": "application/json",
},
});
}
}

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { IconLayoutGrid, IconSearch, IconTable } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { FilesGridSkeleton, FilesTableSkeleton } from "@/components/skeletons";
import { FilesGrid } from "@/components/tables/files-grid";
import { FilesTable } from "@/components/tables/files-table";
import { Button } from "@/components/ui/button";
@@ -41,13 +42,17 @@ interface FilesViewManagerProps {
folders?: Folder[];
searchQuery: string;
onSearch: (query: string) => void;
onNavigateToFolder?: (folderId?: string) => void;
onNavigateToFolder?: (folderId: string) => void;
onDownload: (objectName: string, fileName: string) => void;
breadcrumbs?: React.ReactNode;
isLoading?: boolean;
emptyStateComponent?: React.ComponentType;
isShareMode?: boolean;
onCreateFolder?: () => void;
onUpload?: () => void;
onDeleteFolder?: (folder: Folder) => void;
onImmediateUpdate?: (itemId: string, itemType: "file" | "folder", newParentId: string | null) => void;
onRefresh?: () => Promise<void>;
onRenameFolder?: (folder: Folder) => void;
onMoveFolder?: (folder: Folder) => void;
onMoveFile?: (file: File) => void;
@@ -83,9 +88,13 @@ export function FilesViewManager({
isLoading = false,
emptyStateComponent: EmptyStateComponent,
isShareMode = false,
onCreateFolder,
onUpload,
onDeleteFolder,
onRenameFolder,
onMoveFolder,
onImmediateUpdate,
onRefresh,
onMoveFile,
onShareFolder,
onDownloadFolder,
@@ -124,6 +133,8 @@ export function FilesViewManager({
files,
folders: folders || [],
onNavigateToFolder,
onCreateFolder: isShareMode ? undefined : onCreateFolder,
onUpload: isShareMode ? undefined : onUpload,
onDeleteFolder: isShareMode ? undefined : onDeleteFolder,
onRenameFolder: isShareMode ? undefined : onRenameFolder,
onMoveFolder: isShareMode ? undefined : onMoveFolder,
@@ -131,6 +142,8 @@ export function FilesViewManager({
onShareFolder: isShareMode ? undefined : onShareFolder,
onDownloadFolder,
onPreview,
onImmediateUpdate,
onRefresh,
onRename: isShareMode ? undefined : onRename,
onDownload,
onShare: isShareMode ? undefined : onShare,
@@ -194,10 +207,11 @@ export function FilesViewManager({
</div>
{isLoading ? (
<div className="text-center py-8">
<div className="animate-spin h-8 w-8 border-2 border-current border-t-transparent rounded-full mx-auto mb-4" />
<p className="text-muted-foreground">Loading...</p>
</div>
viewMode === "table" ? (
<FilesTableSkeleton rowCount={10} />
) : (
<FilesGridSkeleton itemCount={12} />
)
) : showEmptyState ? (
EmptyStateComponent ? (
<EmptyStateComponent />

View File

@@ -69,7 +69,9 @@ export function useFileBrowser() {
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
const [clearSelectionCallback, setClearSelectionCallbackState] = useState<(() => void) | undefined>();
const [dataLoaded, setDataLoaded] = useState(false);
const [forceUpdate] = useState(0);
const isNavigatingRef = useRef(false);
const loadFilesRef = useRef<(() => Promise<void>) | null>(null);
const urlFolderSlug = searchParams.get("folder") || null;
const [currentFolderId, setCurrentFolderId] = useState<string | null>(null);
@@ -171,7 +173,11 @@ export function useFileBrowser() {
if (dataLoaded && allFiles.length > 0) {
isNavigatingRef.current = true;
navigateToFolderDirect(targetFolderId);
setTimeout(() => {
// Refresh data when navigating to ensure we have latest state
setTimeout(async () => {
if (loadFilesRef.current) {
await loadFilesRef.current();
}
isNavigatingRef.current = false;
}, 0);
} else {
@@ -239,7 +245,52 @@ export function useFileBrowser() {
}
}, [urlFolderSlug, buildBreadcrumbPath, t, getFolderIdFromPathSlug]);
const fileManager = useEnhancedFileManager(loadFiles, clearSelectionCallback);
const handleImmediateUpdate = useCallback(
(itemId: string, itemType: "file" | "folder", newParentId: string | null) => {
// Use requestAnimationFrame for smoother updates
requestAnimationFrame(() => {
// Check if this is a delete operation
const isDelete = newParentId === ("__DELETE__" as any);
if (itemType === "file") {
setFiles((prevFiles) => {
return prevFiles.filter((file) => file.id !== itemId);
});
// Update allFiles to keep state consistent
setAllFiles((prevAllFiles) => {
if (isDelete) {
return prevAllFiles.filter((file) => file.id !== itemId);
}
return prevAllFiles.map((file) => (file.id === itemId ? { ...file, folderId: newParentId } : file));
});
} else if (itemType === "folder") {
setFolders((prevFolders) => {
return prevFolders.filter((folder) => folder.id !== itemId);
});
// Update allFolders to keep state consistent
setAllFolders((prevAllFolders) => {
if (isDelete) {
return prevAllFolders.filter((folder) => folder.id !== itemId);
}
return prevAllFolders.map((folder) =>
folder.id === itemId ? { ...folder, parentId: newParentId } : folder
);
});
}
});
},
[]
);
const fileManager = useEnhancedFileManager(
loadFiles,
clearSelectionCallback,
handleImmediateUpdate,
allFiles,
allFolders
);
const getImmediateChildFoldersWithMatches = useCallback(() => {
if (!searchQuery) return [];
@@ -300,12 +351,17 @@ export function useFileBrowser() {
const filteredFolders = searchQuery ? getImmediateChildFoldersWithMatches() : folders;
// Update loadFilesRef whenever loadFiles changes
useEffect(() => {
if (!isNavigatingRef.current) {
loadFiles();
}
loadFilesRef.current = loadFiles;
}, [loadFiles]);
// Load files only on mount or when explicitly called
useEffect(() => {
loadFiles();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Empty dependency array - load only on mount
return {
isLoading,
files,
@@ -333,6 +389,8 @@ export function useFileBrowser() {
handleSearch: setSearchQuery,
loadFiles,
handleImmediateUpdate,
forceUpdate,
allFiles,
allFolders,

View File

@@ -20,6 +20,7 @@ import {
import { Card, CardContent } from "@/components/ui/card";
import { moveFile } from "@/http/endpoints/files";
import { listFolders, moveFolder } from "@/http/endpoints/folders";
import { getCachedDownloadUrl } from "@/lib/download-url-cache";
import { FilesViewManager } from "./components/files-view-manager";
import { Header } from "./components/header";
import { useFileBrowser } from "./hooks/use-file-browser";
@@ -69,7 +70,10 @@ export default function FilesPage() {
navigateToRoot,
handleSearch,
loadFiles,
handleImmediateUpdate,
modals,
allFiles,
allFolders,
} = useFileBrowser();
const handleMoveFile = (file: any) => {
@@ -103,13 +107,77 @@ export default function FilesPage() {
setItemsToMove(null);
} catch (error) {
console.error("Error moving items:", error);
toast.error("Failed to move items. Please try again.");
toast.error(t("files.errors.moveItemsFailed"));
}
};
const handleUploadSuccess = async () => {
await loadFiles();
toast.success("Files uploaded successfully");
// Toast is already shown by the upload modal
};
const handleFolderDownload = async (folderId: string, folderName: string) => {
try {
// Get all files in this folder and subfolders recursively with their paths
const getFolderFilesWithPath = (
targetFolderId: string,
currentPath: string = ""
): Array<{ file: File; path: string }> => {
const filesWithPath: Array<{ file: File; path: string }> = [];
// Get direct files in this folder
const directFiles = allFiles.filter((f) => f.folderId === targetFolderId);
directFiles.forEach((file) => {
filesWithPath.push({ file, path: currentPath });
});
// Get subfolders and process them recursively
const subfolders = allFolders.filter((f) => f.parentId === targetFolderId);
for (const subfolder of subfolders) {
const subfolderPath = currentPath ? `${currentPath}/${subfolder.name}` : subfolder.name;
filesWithPath.push(...getFolderFilesWithPath(subfolder.id, subfolderPath));
}
return filesWithPath;
};
const folderFilesWithPath = getFolderFilesWithPath(folderId);
if (folderFilesWithPath.length === 0) {
toast.error(t("shareManager.noFilesToDownload"));
return;
}
const loadingToast = toast.loading(t("shareManager.creatingZip"));
try {
// Get presigned URLs for all files with their relative paths
const downloadItems = await Promise.all(
folderFilesWithPath.map(async ({ file, path }) => {
const url = await getCachedDownloadUrl(file.objectName);
return {
url,
name: path ? `${path}/${file.name}` : file.name,
};
})
);
// Create ZIP with all files
const { downloadFilesAsZip } = await import("@/utils/zip-download");
const zipName = `${folderName}.zip`;
await downloadFilesAsZip(downloadItems, zipName);
toast.dismiss(loadingToast);
toast.success(t("shareManager.zipDownloadSuccess"));
} catch (error) {
toast.dismiss(loadingToast);
toast.error(t("shareManager.zipDownloadError"));
throw error;
}
} catch (error) {
console.error("Error downloading folder:", error);
toast.error(t("share.errors.downloadFailed"));
}
};
return (
@@ -122,8 +190,6 @@ export default function FilesPage() {
breadcrumbLabel={t("files.breadcrumb")}
icon={<IconFolderOpen size={20} />}
title={t("files.pageTitle")}
pendingDownloads={fileManager.pendingDownloads}
onCancelDownload={fileManager.cancelPendingDownload}
>
<Card>
<CardContent>
@@ -140,11 +206,63 @@ export default function FilesPage() {
onSearch={handleSearch}
onDownload={fileManager.handleDownload}
isLoading={isLoading}
onCreateFolder={() => fileManager.setCreateFolderModalOpen(true)}
onUpload={modals.onOpenUploadModal}
breadcrumbs={
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink className="flex items-center gap-1 cursor-pointer" onClick={navigateToRoot}>
<BreadcrumbLink
className="flex items-center gap-1.5 cursor-pointer transition-colors p-0.5 rounded-md"
onClick={navigateToRoot}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
e.currentTarget.classList.add("bg-primary/10", "text-primary");
}}
onDragLeave={(e) => {
e.currentTarget.classList.remove("bg-primary/10", "text-primary");
}}
onDrop={async (e) => {
e.preventDefault();
e.stopPropagation();
e.currentTarget.classList.remove("bg-primary/10", "text-primary");
try {
const itemData = e.dataTransfer.getData("text/plain");
const items = JSON.parse(itemData);
// Update UI immediately
items.forEach((item: any) => {
handleImmediateUpdate(item.id, item.type, null);
});
// Move all items in parallel
const movePromises = items.map((item: any) => {
if (item.type === "file") {
return moveFile(item.id, { folderId: null });
} else if (item.type === "folder") {
return moveFolder(item.id, { parentId: null });
}
return Promise.resolve();
});
await Promise.all(movePromises);
if (items.length === 1) {
toast.success(
`${items[0].type === "folder" ? "Folder" : "File"} "${items[0].name}" moved to root folder`
);
} else {
toast.success(`${items.length} items moved to root folder`);
}
} catch (error) {
console.error("Error moving items:", error);
toast.error(t("files.errors.moveItemsFailed"));
await loadFiles();
}
}}
>
<IconFolderOpen size={16} />
{t("folderActions.rootFolder")}
</BreadcrumbLink>
@@ -155,9 +273,75 @@ export default function FilesPage() {
<BreadcrumbSeparator />
<BreadcrumbItem>
{index === currentPath.length - 1 ? (
<BreadcrumbPage>{folder.name}</BreadcrumbPage>
<BreadcrumbPage className="flex items-center gap-1.5">
<IconFolderOpen size={16} />
{folder.name}
</BreadcrumbPage>
) : (
<BreadcrumbLink className="cursor-pointer" onClick={() => navigateToFolder(folder.id)}>
<BreadcrumbLink
className="flex items-center gap-1 cursor-pointer transition-colors p-0.5 rounded-md"
onClick={() => navigateToFolder(folder.id)}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
e.currentTarget.classList.add("bg-primary/10", "text-primary");
}}
onDragLeave={(e) => {
e.currentTarget.classList.remove("bg-primary/10", "text-primary");
}}
onDrop={async (e) => {
e.preventDefault();
e.stopPropagation();
e.currentTarget.classList.remove("bg-primary/10", "text-primary");
try {
const itemData = e.dataTransfer.getData("text/plain");
const items = JSON.parse(itemData);
// Filter out invalid moves
const validItems = items.filter((item: any) => {
if (item.id === folder.id) return false;
if (item.type === "folder" && item.id === folder.id) return false;
return true;
});
if (validItems.length === 0) {
toast.error(t("files.errors.cannotMoveHere"));
return;
}
// Update UI immediately
validItems.forEach((item: any) => {
handleImmediateUpdate(item.id, item.type, folder.id);
});
// Move all items in parallel
const movePromises = validItems.map((item: any) => {
if (item.type === "file") {
return moveFile(item.id, { folderId: folder.id });
} else if (item.type === "folder") {
return moveFolder(item.id, { parentId: folder.id });
}
return Promise.resolve();
});
await Promise.all(movePromises);
if (validItems.length === 1) {
toast.success(
`${validItems[0].type === "folder" ? "Folder" : "File"} "${validItems[0].name}" moved to "${folder.name}"`
);
} else {
toast.success(`${validItems.length} items moved to "${folder.name}"`);
}
} catch (error) {
console.error("Error moving items:", error);
toast.error(t("files.errors.moveItemsFailed"));
await loadFiles();
}
}}
>
<IconFolderOpen size={16} />
{folder.name}
</BreadcrumbLink>
)}
@@ -183,8 +367,10 @@ export default function FilesPage() {
}
onMoveFolder={handleMoveFolder}
onMoveFile={handleMoveFile}
onRefresh={loadFiles}
onImmediateUpdate={handleImmediateUpdate}
onShareFolder={fileManager.setFolderToShare}
onDownloadFolder={fileManager.handleSingleFolderDownload}
onDownloadFolder={handleFolderDownload}
onPreview={fileManager.setPreviewFile}
onRename={fileManager.setFileToRename}
onShare={fileManager.setFileToShare}

View File

@@ -1,268 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import {
IconAlertCircle,
IconBell,
IconBellOff,
IconClock,
IconDownload,
IconLoader2,
IconX,
} from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { useDownloadQueue } from "@/hooks/use-download-queue";
import { usePushNotifications } from "@/hooks/use-push-notifications";
import { formatFileSize } from "@/utils/format-file-size";
interface PendingDownload {
downloadId: string;
fileName: string;
objectName: string;
startTime: number;
status: "pending" | "queued" | "downloading" | "completed" | "failed";
}
interface DownloadQueueIndicatorProps {
pendingDownloads?: PendingDownload[];
onCancelDownload?: (downloadId: string) => void;
className?: string;
}
export function DownloadQueueIndicator({
pendingDownloads = [],
onCancelDownload,
className = "",
}: DownloadQueueIndicatorProps) {
const t = useTranslations();
const shouldAutoRefresh = pendingDownloads.length > 0;
const { queueStatus, refreshQueue, cancelDownload, getEstimatedWaitTime } = useDownloadQueue(shouldAutoRefresh);
const notifications = usePushNotifications();
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
if (pendingDownloads.length > 0 || (queueStatus && queueStatus.queueLength > 0)) {
setIsOpen(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pendingDownloads.length, queueStatus?.queueLength]);
const totalDownloads = pendingDownloads.length + (queueStatus?.queueLength || 0);
const activeDownloads = queueStatus?.activeDownloads || 0;
if (totalDownloads === 0 && activeDownloads === 0) {
return null;
}
const getStatusIcon = (status: string) => {
switch (status) {
case "pending":
return <IconLoader2 className="h-4 w-4 animate-spin text-blue-500" />;
case "queued":
return <IconClock className="h-4 w-4 text-yellow-500" />;
case "downloading":
return <IconDownload className="h-4 w-4 text-green-500" />;
case "completed":
return <IconDownload className="h-4 w-4 text-green-600" />;
case "failed":
return <IconAlertCircle className="h-4 w-4 text-red-500" />;
default:
return <IconLoader2 className="h-4 w-4 animate-spin" />;
}
};
const getStatusText = (status: string) => {
switch (status) {
case "pending":
return t("downloadQueue.status.pending");
case "queued":
return t("downloadQueue.status.queued");
case "downloading":
return t("downloadQueue.status.downloading");
case "completed":
return t("downloadQueue.status.completed");
case "failed":
return t("downloadQueue.status.failed");
default:
return status;
}
};
return (
<div className={`fixed bottom-6 right-6 z-50 max-w-sm ${className}`} data-download-indicator>
<div className="flex flex-col gap-3">
<Button
variant="outline"
size="sm"
onClick={() => setIsOpen(!isOpen)}
className="min-w-fit bg-background/80 backdrop-blur-md border-border/50 shadow-lg hover:shadow-xl transition-all duration-200 text-sm font-medium"
>
<IconDownload className="h-4 w-4 mr-2 text-primary" />
Downloads
{totalDownloads > 0 && (
<Badge variant="secondary" className="ml-2 text-xs font-semibold bg-primary/10 text-primary border-0">
{totalDownloads}
</Badge>
)}
</Button>
{isOpen && (
<div className="border border-border/50 rounded-xl bg-background/95 backdrop-blur-md shadow-xl animate-in slide-in-from-bottom-2 duration-200">
<div className="p-4 border-b border-border/50">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-sm text-foreground">Download Manager</h3>
<div className="flex items-center gap-2">
{notifications.isSupported && (
<Button
variant="ghost"
size="sm"
onClick={notifications.requestPermission}
className="h-7 w-7 p-0 rounded-md hover:bg-muted/80"
title={
notifications.hasPermission
? t("notifications.permissionGranted")
: "Enable download notifications"
}
>
{notifications.hasPermission ? (
<IconBell className="h-3.5 w-3.5 text-green-600" />
) : (
<IconBellOff className="h-3.5 w-3.5 text-muted-foreground" />
)}
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setIsOpen(false)}
className="h-7 w-7 p-0 rounded-md hover:bg-muted/80"
>
<IconX className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</div>
</div>
{queueStatus && (
<div className="mt-3 space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Active:</span>
<span className="font-medium text-foreground">
{activeDownloads}/{queueStatus.maxConcurrent}
</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Queued:</span>
<span className="font-medium text-foreground">
{queueStatus.queueLength}/{queueStatus.maxQueueSize}
</span>
</div>
{queueStatus.maxConcurrent > 0 && (
<div className="space-y-1">
<Progress value={(activeDownloads / queueStatus.maxConcurrent) * 100} className="h-1.5" />
<p className="text-xs text-muted-foreground">
{Math.round((activeDownloads / queueStatus.maxConcurrent) * 100)}% capacity
</p>
</div>
)}
</div>
)}
</div>
<div className="p-3 space-y-2">
{pendingDownloads.map((download) => (
<div
key={download.downloadId}
className="group flex items-center justify-between p-2.5 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors border border-transparent hover:border-border/50"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="shrink-0">{getStatusIcon(download.status)}</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate leading-tight">{download.fileName}</p>
<p className="text-xs text-muted-foreground mt-0.5">{getStatusText(download.status)}</p>
</div>
</div>
{(download.status === "pending" || download.status === "queued") && onCancelDownload && (
<Button
variant="ghost"
size="sm"
onClick={() => onCancelDownload(download.downloadId)}
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 transition-opacity shrink-0 hover:bg-destructive/10 hover:text-destructive"
>
<IconX className="h-3.5 w-3.5" />
</Button>
)}
</div>
))}
{(queueStatus?.queuedDownloads || []).map((download) => {
const waitTime = getEstimatedWaitTime(download.downloadId);
return (
<div
key={download.downloadId}
className="group flex items-center justify-between p-2.5 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors border border-transparent hover:border-border/50"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="shrink-0">
<IconClock className="h-4 w-4 text-amber-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate leading-tight">
{download.fileName || t("downloadQueue.indicator.unknownFile")}
</p>
<div className="text-xs text-muted-foreground space-y-0.5">
<div className="flex items-center gap-2">
<span>#{download.position} in queue</span>
{download.fileSize && (
<span className="text-muted-foreground/70"> {formatFileSize(download.fileSize)}</span>
)}
</div>
{waitTime && <p className="text-xs text-muted-foreground/80">~{waitTime} remaining</p>}
</div>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => cancelDownload(download.downloadId)}
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 transition-opacity shrink-0 hover:bg-destructive/10 hover:text-destructive"
>
<IconX className="h-3.5 w-3.5" />
</Button>
</div>
);
})}
{totalDownloads === 0 && (
<div className="text-center py-8">
<IconDownload className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
<p className="text-sm text-muted-foreground">No active downloads</p>
</div>
)}
</div>
{queueStatus && queueStatus.queueLength > 0 && (
<div className="p-3 border-t border-border/50">
<Button
variant="outline"
size="sm"
onClick={refreshQueue}
className="w-full text-xs font-medium hover:bg-muted/80"
>
Refresh Queue
</Button>
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,151 @@
"use client";
import { useEffect, useState } from "react";
import { IconCheck, IconCopy } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
interface EmbedCodeDisplayProps {
imageUrl: string;
fileName: string;
fileId: string;
}
export function EmbedCodeDisplay({ imageUrl, fileName, fileId }: EmbedCodeDisplayProps) {
const t = useTranslations();
const [copiedType, setCopiedType] = useState<string | null>(null);
const [fullUrl, setFullUrl] = useState<string>("");
useEffect(() => {
if (typeof window !== "undefined") {
const origin = window.location.origin;
const embedUrl = `${origin}/e/${fileId}`;
setFullUrl(embedUrl);
}
}, [fileId]);
const directLink = fullUrl || imageUrl;
const htmlCode = `<img src="${directLink}" alt="${fileName}" />`;
const bbCode = `[img]${directLink}[/img]`;
const copyToClipboard = async (text: string, type: string) => {
try {
await navigator.clipboard.writeText(text);
setCopiedType(type);
setTimeout(() => setCopiedType(null), 2000);
} catch (error) {
console.error("Failed to copy:", error);
}
};
return (
<Card>
<CardContent>
<div className="space-y-4">
<div>
<Label className="text-sm font-semibold">{t("embedCode.title")}</Label>
<p className="text-xs text-muted-foreground mt-1">{t("embedCode.description")}</p>
</div>
<Tabs defaultValue="direct" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="direct" className="cursor-pointer">
{t("embedCode.tabs.directLink")}
</TabsTrigger>
<TabsTrigger value="html" className="cursor-pointer">
{t("embedCode.tabs.html")}
</TabsTrigger>
<TabsTrigger value="bbcode" className="cursor-pointer">
{t("embedCode.tabs.bbcode")}
</TabsTrigger>
</TabsList>
<TabsContent value="direct" className="space-y-2">
<div className="flex gap-2">
<input
type="text"
readOnly
value={directLink}
className="flex-1 px-3 py-2 text-sm border rounded-md bg-muted/50 font-mono"
/>
<Button
size="default"
variant="outline"
onClick={() => copyToClipboard(directLink, "direct")}
className="shrink-0 h-full"
>
{copiedType === "direct" ? (
<>
<IconCheck className="h-4 w-4 mr-1" />
{t("common.copied")}
</>
) : (
<>
<IconCopy className="h-4 w-4 mr-1" />
{t("common.copy")}
</>
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t("embedCode.directLinkDescription")}</p>
</TabsContent>
<TabsContent value="html" className="space-y-2">
<div className="flex gap-2">
<input
type="text"
readOnly
value={htmlCode}
className="flex-1 px-3 py-2 text-sm border rounded-md bg-muted/50 font-mono"
/>
<Button variant="outline" onClick={() => copyToClipboard(htmlCode, "html")} className="shrink-0 h-full">
{copiedType === "html" ? (
<>
<IconCheck className="h-4 w-4 mr-1" />
{t("common.copied")}
</>
) : (
<>
<IconCopy className="h-4 w-4 mr-1" />
{t("common.copy")}
</>
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t("embedCode.htmlDescription")}</p>
</TabsContent>
<TabsContent value="bbcode" className="space-y-2">
<div className="flex gap-2">
<input
type="text"
readOnly
value={bbCode}
className="flex-1 px-3 py-2 text-sm border rounded-md bg-muted/50 font-mono"
/>
<Button variant="outline" onClick={() => copyToClipboard(bbCode, "bbcode")} className="shrink-0 h-full">
{copiedType === "bbcode" ? (
<>
<IconCheck className="h-4 w-4 mr-1" />
{t("common.copied")}
</>
) : (
<>
<IconCopy className="h-4 w-4 mr-1" />
{t("common.copy")}
</>
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t("embedCode.bbcodeDescription")}</p>
</TabsContent>
</Tabs>
</div>
</CardContent>
</Card>
);
}

View File

@@ -37,7 +37,8 @@ interface Folder {
interface FilesViewProps {
files: File[];
onPreview: (file: File) => void;
folders?: Folder[];
onPreview?: (file: File) => void;
onRename: (file: File) => void;
onUpdateName: (fileId: string, newName: string) => void;
onUpdateDescription: (fileId: string, newDescription: string) => void;

View File

@@ -0,0 +1,72 @@
"use client";
import { useEffect, useState } from "react";
import { IconCheck, IconCopy } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
interface MediaEmbedLinkProps {
fileId: string;
}
export function MediaEmbedLink({ fileId }: MediaEmbedLinkProps) {
const t = useTranslations();
const [copied, setCopied] = useState(false);
const [embedUrl, setEmbedUrl] = useState<string>("");
useEffect(() => {
if (typeof window !== "undefined") {
const origin = window.location.origin;
const url = `${origin}/e/${fileId}`;
setEmbedUrl(url);
}
}, [fileId]);
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(embedUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error("Failed to copy:", error);
}
};
return (
<Card>
<CardContent>
<div className="space-y-3">
<div>
<Label className="text-sm font-semibold">{t("embedCode.title")}</Label>
<p className="text-xs text-muted-foreground mt-1">{t("embedCode.directLinkDescription")}</p>
</div>
<div className="flex gap-2">
<input
type="text"
readOnly
value={embedUrl}
className="flex-1 px-3 py-2 text-sm border rounded-md bg-muted/50 font-mono"
/>
<Button size="default" variant="outline" onClick={copyToClipboard} className="shrink-0 h-full">
{copied ? (
<>
<IconCheck className="h-4 w-4 mr-1" />
{t("common.copied")}
</>
) : (
<>
<IconCopy className="h-4 w-4 mr-1" />
{t("common.copy")}
</>
)}
</Button>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -395,7 +395,9 @@ export function FileSelector({
</div>
<div className="flex items-center justify-between pt-4 border-t">
<div className="text-sm text-muted-foreground">{shareFiles.length + shareFolders.length} items selected</div>
<div className="text-sm text-muted-foreground">
{t("fileSelector.itemsSelected", { count: shareFiles.length + shareFolders.length })}
</div>
<Button onClick={handleSave} disabled={isLoading} className="gap-2">
{isLoading ? (
<>

View File

@@ -2,15 +2,13 @@
import { useCallback, useEffect, useState } from "react";
import { IconCloudUpload, IconLoader, IconX } from "@tabler/icons-react";
import axios from "axios";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { useUppyUpload } from "@/hooks/useUppyUpload";
import { checkFile, getFilePresignedUrl, registerFile } from "@/http/endpoints";
import { getSystemInfo } from "@/http/endpoints/app";
import { ChunkedUploader } from "@/utils/chunked-upload";
import { getFileIcon } from "@/utils/file-icons";
import { generateSafeFileName } from "@/utils/file-utils";
import { formatFileSize } from "@/utils/format-file-size";
@@ -22,209 +20,133 @@ interface GlobalDropZoneProps {
currentFolderId?: string;
}
enum UploadStatus {
PENDING = "pending",
UPLOADING = "uploading",
SUCCESS = "success",
ERROR = "error",
CANCELLED = "cancelled",
}
interface FileUpload {
id: string;
file: File;
status: UploadStatus;
progress: number;
error?: string;
abortController?: AbortController;
objectName?: string;
}
export function GlobalDropZone({ onSuccess, children, currentFolderId }: GlobalDropZoneProps) {
const t = useTranslations();
const [isDragOver, setIsDragOver] = useState(false);
const [fileUploads, setFileUploads] = useState<FileUpload[]>([]);
const [hasShownSuccessToast, setHasShownSuccessToast] = useState(false);
const [isS3Enabled, setIsS3Enabled] = useState<boolean | null>(null);
const generateFileId = useCallback(() => {
return Date.now().toString() + Math.random().toString(36).substr(2, 9);
}, []);
const { addFiles, startUpload, fileUploads, removeFile, retryUpload } = useUppyUpload({
onValidate: async (file) => {
const fileName = file.name;
const extension = fileName.split(".").pop() || "";
const safeObjectName = generateSafeFileName(fileName);
const createFileUpload = useCallback(
(file: File): FileUpload => {
const id = generateFileId();
return {
id,
file,
status: UploadStatus.PENDING,
progress: 0,
};
try {
await checkFile({
name: fileName,
objectName: safeObjectName,
size: file.size,
extension: extension,
folderId: currentFolderId,
});
} catch (error) {
console.error("File check failed:", error);
const errorData = getErrorData(error);
let errorMessage = t("uploadFile.error");
if (errorData.code === "fileSizeExceeded") {
errorMessage = t(`uploadFile.${errorData.code}`, { maxsizemb: errorData.details || "0" });
} else if (errorData.code === "insufficientStorage") {
errorMessage = t(`uploadFile.${errorData.code}`, { availablespace: errorData.details || "0" });
} else if (errorData.code) {
errorMessage = t(`uploadFile.${errorData.code}`);
}
toast.error(errorMessage);
throw new Error(errorMessage);
}
},
[generateFileId]
);
onBeforeUpload: async (file) => {
const safeObjectName = generateSafeFileName(file.name);
return safeObjectName;
},
getPresignedUrl: async (objectName, extension) => {
const response = await getFilePresignedUrl({
filename: objectName.replace(`.${extension}`, ""),
extension,
});
const calculateUploadTimeout = useCallback((fileSize: number): number => {
const baseTimeout = 300000;
const fileSizeMB = fileSize / (1024 * 1024);
if (fileSizeMB > 500) {
const extraMB = fileSizeMB - 500;
const extraMinutes = Math.ceil(extraMB / 100);
return baseTimeout + extraMinutes * 60000;
// IMPORTANT: Use the objectName returned by backend, not the one we generated!
// The backend generates: userId/timestamp-random-filename.extension
const actualObjectName = response.data.objectName;
return { url: response.data.url, method: "PUT", actualObjectName };
},
onAfterUpload: async (fileId, file, objectName) => {
const fileName = file.name;
const extension = fileName.split(".").pop() || "";
await registerFile({
name: fileName,
objectName,
size: file.size,
extension,
folderId: currentFolderId,
});
},
});
// Monitor upload completion separately from the hook
useEffect(() => {
// Only process if we have uploads completed
if (fileUploads.length === 0) return;
const successCount = fileUploads.filter((u) => u.status === "success").length;
const errorCount = fileUploads.filter((u) => u.status === "error").length;
const pendingCount = fileUploads.filter((u) => u.status === "pending" || u.status === "uploading").length;
// All uploads are done (no pending/uploading)
if (pendingCount === 0 && successCount > 0) {
toast.success(
errorCount > 0
? t("uploadFile.partialSuccess", { success: successCount, error: errorCount })
: t("uploadFile.allSuccess", { count: successCount })
);
onSuccess?.();
// Auto-remove successful uploads after 3 seconds
setTimeout(() => {
fileUploads.forEach((upload) => {
if (upload.status === "success") {
removeFile(upload.id);
}
});
}, 3000);
}
return baseTimeout;
}, []);
}, [fileUploads, onSuccess, removeFile, t]);
const handleDragOver = useCallback((event: DragEvent) => {
// Check if this is a move operation (dragging existing items)
const isMoveOperation = event.dataTransfer?.types.includes("application/x-move-item");
if (isMoveOperation) {
return; // Don't interfere with move operations
}
event.preventDefault();
event.stopPropagation();
setIsDragOver(true);
}, []);
const handleDragLeave = useCallback((event: DragEvent) => {
// Check if this is a move operation (dragging existing items)
const isMoveOperation = event.dataTransfer?.types.includes("application/x-move-item");
if (isMoveOperation) {
return; // Don't interfere with move operations
}
event.preventDefault();
event.stopPropagation();
setIsDragOver(false);
}, []);
const uploadFile = useCallback(
async (fileUpload: FileUpload) => {
const { file, id } = fileUpload;
try {
const fileName = file.name;
const extension = fileName.split(".").pop() || "";
const safeObjectName = generateSafeFileName(fileName);
try {
await checkFile({
name: fileName,
objectName: safeObjectName,
size: file.size,
extension: extension,
folderId: currentFolderId,
});
} catch (error) {
console.error("File check failed:", error);
const errorData = getErrorData(error);
let errorMessage = t("uploadFile.error");
if (errorData.code === "fileSizeExceeded") {
errorMessage = t(`uploadFile.${errorData.code}`, { maxsizemb: errorData.details || "0" });
} else if (errorData.code === "insufficientStorage") {
errorMessage = t(`uploadFile.${errorData.code}`, { availablespace: errorData.details || "0" });
} else if (errorData.code) {
errorMessage = t(`uploadFile.${errorData.code}`);
}
setFileUploads((prev) =>
prev.map((u) => (u.id === id ? { ...u, status: UploadStatus.ERROR, error: errorMessage } : u))
);
return;
}
setFileUploads((prev) =>
prev.map((u) => (u.id === id ? { ...u, status: UploadStatus.UPLOADING, progress: 0 } : u))
);
const presignedResponse = await getFilePresignedUrl({
filename: safeObjectName.replace(`.${extension}`, ""),
extension: extension,
});
const { url, objectName } = presignedResponse.data;
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, objectName } : u)));
const abortController = new AbortController();
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, abortController } : u)));
const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size, isS3Enabled ?? undefined);
if (shouldUseChunked) {
const chunkSize = ChunkedUploader.calculateOptimalChunkSize(file.size);
const result = await ChunkedUploader.uploadFile({
file,
url,
chunkSize,
signal: abortController.signal,
isS3Enabled: isS3Enabled ?? undefined,
onProgress: (progress) => {
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, progress } : u)));
},
});
if (!result.success) {
throw new Error(result.error || "Chunked upload failed");
}
const finalObjectName = result.finalObjectName || objectName;
await registerFile({
name: fileName,
objectName: finalObjectName,
size: file.size,
extension: extension,
folderId: currentFolderId,
});
} else {
const uploadTimeout = calculateUploadTimeout(file.size);
await axios.put(url, file, {
headers: {
"Content-Type": file.type,
},
signal: abortController.signal,
timeout: uploadTimeout, // Dynamic timeout based on file size
maxContentLength: Infinity,
maxBodyLength: Infinity,
onUploadProgress: (progressEvent: any) => {
const progress = (progressEvent.loaded / (progressEvent.total || file.size)) * 100;
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, progress: Math.round(progress) } : u)));
},
});
await registerFile({
name: fileName,
objectName: objectName,
size: file.size,
extension: extension,
folderId: currentFolderId,
});
}
setFileUploads((prev) =>
prev.map((u) =>
u.id === id ? { ...u, status: UploadStatus.SUCCESS, progress: 100, abortController: undefined } : u
)
);
} catch (error: any) {
if (error.name === "AbortError" || error.code === "ERR_CANCELED") {
return;
}
console.error("Upload failed:", error);
const errorData = getErrorData(error);
let errorMessage = t("uploadFile.error");
if (errorData.code && errorData.code !== "error") {
errorMessage = t(`uploadFile.${errorData.code}`);
}
setFileUploads((prev) =>
prev.map((u) =>
u.id === id ? { ...u, status: UploadStatus.ERROR, error: errorMessage, abortController: undefined } : u
)
);
}
},
[t, isS3Enabled, currentFolderId, calculateUploadTimeout]
);
const handleDrop = useCallback(
(event: DragEvent) => {
// Check if this is a move operation (dragging existing items)
const isMoveOperation = event.dataTransfer?.types.includes("application/x-move-item");
if (isMoveOperation) {
return; // Don't interfere with move operations
}
event.preventDefault();
event.stopPropagation();
setIsDragOver(false);
@@ -232,13 +154,11 @@ export function GlobalDropZone({ onSuccess, children, currentFolderId }: GlobalD
const files = event.dataTransfer?.files;
if (!files || files.length === 0) return;
const newUploads = Array.from(files).map(createFileUpload);
setFileUploads((prev) => [...prev, ...newUploads]);
setHasShownSuccessToast(false);
newUploads.forEach((upload) => uploadFile(upload));
const filesArray = Array.from(files);
addFiles(filesArray);
toast.info(t("uploadFile.filesQueued", { count: filesArray.length }));
},
[uploadFile, createFileUpload]
[addFiles, t]
);
const handlePaste = useCallback(
@@ -261,46 +181,39 @@ export function GlobalDropZone({ onSuccess, children, currentFolderId }: GlobalD
event.preventDefault();
event.stopPropagation();
const newUploads: FileUpload[] = [];
const newFiles: File[] = [];
imageItems.forEach((item) => {
const file = item.getAsFile();
if (file) {
const timestamp = Date.now();
const extension = file.type.split("/")[1] || "png";
const fileName = `${timestamp}.${extension}`;
const fileName = `pasted-${timestamp}.${extension}`;
const renamedFile = new File([file], fileName, { type: file.type });
newUploads.push(createFileUpload(renamedFile));
newFiles.push(renamedFile);
}
});
if (newUploads.length > 0) {
setFileUploads((prev) => [...prev, ...newUploads]);
setHasShownSuccessToast(false);
newUploads.forEach((upload) => uploadFile(upload));
toast.success(t("uploadFile.pasteSuccess", { count: newUploads.length }));
if (newFiles.length > 0) {
addFiles(newFiles);
toast.success(t("uploadFile.pasteSuccess", { count: newFiles.length }));
}
},
[uploadFile, t, createFileUpload]
[addFiles, t]
);
// Auto-start uploads when files are added
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);
}
};
if (fileUploads.some((f) => f.status === "pending")) {
// Wait a bit for all validations, then start upload
const timer = setTimeout(() => {
startUpload();
}, 200);
fetchSystemInfo();
}, []);
return () => clearTimeout(timer);
}
}, [fileUploads, startUpload]);
useEffect(() => {
document.addEventListener("dragover", handleDragOver);
@@ -316,72 +229,24 @@ export function GlobalDropZone({ onSuccess, children, currentFolderId }: GlobalD
};
}, [handleDragOver, handleDragLeave, handleDrop, handlePaste]);
const removeFile = (fileId: string) => {
setFileUploads((prev) => {
const upload = prev.find((u) => u.id === fileId);
if (upload?.abortController) {
upload.abortController.abort();
}
return prev.filter((u) => u.id !== fileId);
});
};
const retryUpload = (fileId: string) => {
const upload = fileUploads.find((u) => u.id === fileId);
if (upload) {
setFileUploads((prev) =>
prev.map((u) => (u.id === fileId ? { ...u, status: UploadStatus.PENDING, error: undefined, progress: 0 } : u))
);
uploadFile({ ...upload, status: UploadStatus.PENDING, error: undefined, progress: 0 });
}
};
const renderFileIcon = (fileName: string) => {
const { icon: FileIcon, color } = getFileIcon(fileName);
return <FileIcon size={16} className={color} />;
};
const getStatusIcon = (status: UploadStatus) => {
const getStatusIcon = (status: string) => {
switch (status) {
case UploadStatus.UPLOADING:
case "uploading":
return <IconLoader size={14} className="animate-spin text-blue-500" />;
case UploadStatus.SUCCESS:
case "success":
return <IconCloudUpload size={14} className="text-green-500" />;
case UploadStatus.ERROR:
case "error":
return <IconX size={14} className="text-red-500" />;
default:
return null;
}
};
useEffect(() => {
if (fileUploads.length > 0) {
const allComplete = fileUploads.every(
(u) => u.status === UploadStatus.SUCCESS || u.status === UploadStatus.ERROR
);
if (allComplete && !hasShownSuccessToast) {
const successCount = fileUploads.filter((u) => u.status === UploadStatus.SUCCESS).length;
const errorCount = fileUploads.filter((u) => u.status === UploadStatus.ERROR).length;
if (successCount > 0) {
toast.success(
errorCount > 0
? t("uploadFile.partialSuccess", { success: successCount, error: errorCount })
: t("uploadFile.allSuccess", { count: successCount })
);
setHasShownSuccessToast(true);
onSuccess?.();
}
setTimeout(() => {
setFileUploads([]);
setHasShownSuccessToast(false);
}, 3000);
}
}
}, [fileUploads, hasShownSuccessToast, onSuccess, t]);
return (
<>
{children}
@@ -409,20 +274,20 @@ export function GlobalDropZone({ onSuccess, children, currentFolderId }: GlobalD
</div>
<p className="text-xs text-muted-foreground">{formatFileSize(upload.file.size)}</p>
{upload.status === UploadStatus.UPLOADING && (
{upload.status === "uploading" && (
<div className="mt-1">
<Progress value={upload.progress} className="h-1" />
<p className="text-xs text-muted-foreground mt-1">{upload.progress}%</p>
</div>
)}
{upload.status === UploadStatus.ERROR && upload.error && (
{upload.status === "error" && upload.error && (
<p className="text-xs text-destructive mt-1">{upload.error}</p>
)}
</div>
<div className="flex-shrink-0">
{upload.status === UploadStatus.ERROR ? (
{upload.status === "error" ? (
<div className="flex gap-1">
<Button
variant="ghost"
@@ -437,7 +302,7 @@ export function GlobalDropZone({ onSuccess, children, currentFolderId }: GlobalD
<IconX size={12} />
</Button>
</div>
) : upload.status === UploadStatus.SUCCESS ? null : (
) : upload.status === "success" ? null : (
<Button variant="ghost" size="sm" onClick={() => removeFile(upload.id)} className="h-6 w-6 p-0">
<IconX size={12} />
</Button>

View File

@@ -30,12 +30,20 @@ const languages = {
"zh-CN": "中文 (Chinese)",
"ja-JP": "日本語 (Japanese)",
"ko-KR": "한국어 (Korean)",
"th-TH": "ไทย (Thai)",
"vi-VN": "Tiếng Việt (Vietnamese)",
"uk-UA": "Українська (Ukrainian)",
"fa-IR": "فارسی (Persian)",
"sv-SE": "Svenska (Swedish)",
"id-ID": "Bahasa Indonesia (Indonesian)",
"el-GR": "Ελληνικά (Greek)",
"he-IL": "עברית (Hebrew)",
};
const COOKIE_LANG_KEY = "NEXT_LOCALE";
const COOKIE_MAX_AGE = 365 * 24 * 60 * 60;
const RTL_LANGUAGES = ["ar-SA"];
const RTL_LANGUAGES = ["ar-SA", "fa-IR", "he-IL"];
export function LanguageSwitcher() {
const locale = useLocale();

View File

@@ -3,7 +3,6 @@ import Link from "next/link";
import { IconLayoutDashboard } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { DownloadQueueIndicator } from "@/components/download-queue-indicator";
import { Navbar } from "@/components/layout/navbar";
import {
Breadcrumb,
@@ -21,14 +20,6 @@ interface FileManagerLayoutProps {
icon: ReactNode;
breadcrumbLabel?: string;
showBreadcrumb?: boolean;
pendingDownloads?: Array<{
downloadId: string;
fileName: string;
objectName: string;
startTime: number;
status: "pending" | "queued" | "downloading" | "completed" | "failed";
}>;
onCancelDownload?: (downloadId: string) => void;
}
export function FileManagerLayout({
@@ -37,8 +28,6 @@ export function FileManagerLayout({
icon,
breadcrumbLabel,
showBreadcrumb = true,
pendingDownloads = [],
onCancelDownload,
}: FileManagerLayoutProps) {
const t = useTranslations();
@@ -79,8 +68,6 @@ export function FileManagerLayout({
</div>
</div>
<DefaultFooter />
<DownloadQueueIndicator pendingDownloads={pendingDownloads} onCancelDownload={onCancelDownload} />
</div>
);
}

View File

@@ -91,12 +91,12 @@ export function CreateShareModal({ isOpen, onClose, onSuccess, getAllFilesAndFol
const handleSubmit = async () => {
if (!formData.name.trim()) {
toast.error("Share name is required");
toast.error(t("createShare.errors.nameRequired"));
return;
}
if (selectedItems.length === 0) {
toast.error("Please select at least one file or folder");
toast.error(t("createShare.errors.selectItems"));
return;
}
@@ -216,7 +216,7 @@ export function CreateShareModal({ isOpen, onClose, onSuccess, getAllFilesAndFol
type="password"
value={formData.password}
onChange={(e) => updateFormData("password", e.target.value)}
placeholder="Enter password"
placeholder={t("createShare.passwordPlaceholder")}
/>
</div>
)}
@@ -271,9 +271,9 @@ export function CreateShareModal({ isOpen, onClose, onSuccess, getAllFilesAndFol
<div className="text-sm text-muted-foreground">
{selectedCount > 0 ? (
<span>{selectedCount} items selected</span>
<span>{t("createShare.itemsSelected", { count: selectedCount })}</span>
) : (
<span>Select files and folders to share</span>
<span>{t("createShare.selectItemsPrompt")}</span>
)}
</div>

View File

@@ -3,6 +3,8 @@
import { IconDownload } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { EmbedCodeDisplay } from "@/components/files/embed-code-display";
import { MediaEmbedLink } from "@/components/files/media-embed-link";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -14,6 +16,7 @@ import {
} from "@/components/ui/dialog";
import { useFilePreview } from "@/hooks/use-file-preview";
import { getFileIcon } from "@/utils/file-icons";
import { getFileType } from "@/utils/file-types";
import { FilePreviewRenderer } from "./previews";
interface FilePreviewModalProps {
@@ -39,6 +42,10 @@ export function FilePreviewModal({
}: FilePreviewModalProps) {
const t = useTranslations();
const previewState = useFilePreview({ file, isOpen, isReverseShare, sharePassword });
const fileType = getFileType(file.name);
const isImage = fileType === "image";
const isVideo = fileType === "video";
const isAudio = fileType === "audio";
return (
<Dialog open={isOpen} onOpenChange={onClose}>
@@ -67,6 +74,16 @@ export function FilePreviewModal({
description={file.description}
onDownload={previewState.handleDownload}
/>
{!isReverseShare && isImage && previewState.previewUrl && !previewState.isLoading && file.id && (
<div className="mt-4 mb-2">
<EmbedCodeDisplay imageUrl={previewState.previewUrl} fileName={file.name} fileId={file.id} />
</div>
)}
{!isReverseShare && (isVideo || isAudio) && !previewState.isLoading && file.id && (
<div className="mt-4 mb-2">
<MediaEmbedLink fileId={file.id} />
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>

View File

@@ -1,5 +1,7 @@
import { useTranslations } from "next-intl";
import { AspectRatio } from "@/components/ui/aspect-ratio";
interface VideoPreviewProps {
src: string;
}
@@ -8,13 +10,11 @@ export function VideoPreview({ src }: VideoPreviewProps) {
const t = useTranslations();
return (
<div className="flex flex-col items-center justify-center gap-4 py-6">
<div className="w-full max-w-4xl">
<video controls className="w-full rounded-lg" preload="metadata" style={{ maxHeight: "70vh" }}>
<source src={src} />
{t("filePreview.videoNotSupported")}
</video>
</div>
</div>
<AspectRatio ratio={16 / 9} className="bg-muted">
<video controls className="w-full h-full rounded-lg object-contain" preload="metadata">
<source src={src} />
{t("filePreview.videoNotSupported")}
</video>
</AspectRatio>
);
}

View File

@@ -343,7 +343,7 @@ export function ShareActionsModals({
<DialogContent className="max-w-2xl max-h-[80vh] w-full">
<DialogHeader>
<DialogTitle>{t("shareActions.manageFilesTitle")}</DialogTitle>
<DialogDescription>Select files and folders to include in this share</DialogDescription>
<DialogDescription>{t("shareActions.manageFilesDescription")}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 flex-1 min-h-0 w-full overflow-hidden">
@@ -362,7 +362,9 @@ export function ShareActionsModals({
{/* Selection Count */}
<div className="text-sm text-muted-foreground">
{manageFilesSelectedItems.length > 0 && <span>{manageFilesSelectedItems.length} items selected</span>}
{manageFilesSelectedItems.length > 0 && (
<span>{t("shareActions.itemsSelected", { count: manageFilesSelectedItems.length })}</span>
)}
</div>
{/* File Tree */}

View File

@@ -2,16 +2,14 @@
import { useEffect, useRef, useState } from "react";
import { IconAlertTriangle, IconCheck, IconCloudUpload, IconLoader, IconTrash, IconX } from "@tabler/icons-react";
import axios from "axios";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Progress } from "@/components/ui/progress";
import { useUppyUpload } from "@/hooks/useUppyUpload";
import { checkFile, getFilePresignedUrl, registerFile } from "@/http/endpoints";
import { getSystemInfo } from "@/http/endpoints/app";
import { ChunkedUploader } from "@/utils/chunked-upload";
import { getFileIcon } from "@/utils/file-icons";
import { generateSafeFileName } from "@/utils/file-utils";
import { formatFileSize } from "@/utils/format-file-size";
@@ -24,25 +22,6 @@ interface UploadFileModalProps {
currentFolderId?: string;
}
enum UploadStatus {
PENDING = "pending",
UPLOADING = "uploading",
SUCCESS = "success",
ERROR = "error",
CANCELLED = "cancelled",
}
interface FileUpload {
id: string;
file: File;
status: UploadStatus;
progress: number;
error?: string;
abortController?: AbortController;
objectName?: string;
previewUrl?: string;
}
interface ConfirmationModalProps {
isOpen: boolean;
onConfirm: () => void;
@@ -85,81 +64,125 @@ function ConfirmationModal({ isOpen, onConfirm, onCancel, uploadsInProgress }: C
export function UploadFileModal({ isOpen, onClose, onSuccess, currentFolderId }: UploadFileModalProps) {
const t = useTranslations();
const [fileUploads, setFileUploads] = useState<FileUpload[]>([]);
const [isDragOver, setIsDragOver] = useState(false);
const [showConfirmation, setShowConfirmation] = useState(false);
const [hasShownSuccessToast, setHasShownSuccessToast] = useState(false);
const [isS3Enabled, setIsS3Enabled] = useState<boolean | null>(null);
const hasShownSuccessToastRef = useRef(false);
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);
}
};
const { addFiles, startUpload, cancelUpload, retryUpload, removeFile, clearAll, fileUploads, isUploading } =
useUppyUpload({
onValidate: async (file) => {
const fileName = file.name;
const extension = fileName.split(".").pop() || "";
const safeObjectName = generateSafeFileName(fileName);
fetchSystemInfo();
}, []);
try {
await checkFile({
name: fileName,
objectName: safeObjectName,
size: file.size,
extension: extension,
folderId: currentFolderId,
});
} catch (error) {
console.error("File check failed:", error);
const errorData = getErrorData(error);
let errorMessage = t("uploadFile.error");
useEffect(() => {
return () => {
fileUploads.forEach((upload) => {
if (upload.previewUrl) {
URL.revokeObjectURL(upload.previewUrl);
if (errorData.code === "fileSizeExceeded") {
errorMessage = t(`uploadFile.${errorData.code}`, { maxsizemb: errorData.details || "0" });
} else if (errorData.code === "insufficientStorage") {
errorMessage = t(`uploadFile.${errorData.code}`, { availablespace: errorData.details || "0" });
} else if (errorData.code) {
errorMessage = t(`uploadFile.${errorData.code}`);
}
toast.error(errorMessage);
throw new Error(errorMessage);
}
});
};
}, [fileUploads]);
},
onBeforeUpload: async (file) => {
const safeObjectName = generateSafeFileName(file.name);
return safeObjectName;
},
getPresignedUrl: async (objectName, extension) => {
// Extract filename without extension (backend will add it)
const filenameWithoutExt = objectName.replace(`.${extension}`, "");
const generateFileId = () => {
return Date.now().toString() + Math.random().toString(36).substr(2, 9);
};
const response = await getFilePresignedUrl({
filename: filenameWithoutExt,
extension,
});
const createFileUpload = (file: File): FileUpload => {
const id = generateFileId();
let previewUrl: string | undefined;
// IMPORTANT: Use the objectName returned by backend, not the one we generated!
// The backend generates: userId/timestamp-random-filename.extension
const actualObjectName = response.data.objectName;
if (file.type.startsWith("image/")) {
try {
previewUrl = URL.createObjectURL(file);
} catch (error) {
console.warn("Failed to create preview URL:", error);
return { url: response.data.url, method: "PUT", actualObjectName };
},
onAfterUpload: async (fileId, file, objectName) => {
const fileName = file.name;
const extension = fileName.split(".").pop() || "";
await registerFile({
name: fileName,
objectName,
size: file.size,
extension,
folderId: currentFolderId,
});
},
});
// Monitor upload completion and call onSuccess when all done
useEffect(() => {
// Only process if we have uploads and they're not currently uploading
if (fileUploads.length === 0 || isUploading || hasShownSuccessToastRef.current) {
return;
}
const successCount = fileUploads.filter((u) => u.status === "success").length;
const errorCount = fileUploads.filter((u) => u.status === "error").length;
const pendingCount = fileUploads.filter((u) => u.status === "pending" || u.status === "uploading").length;
// All uploads are done (no pending/uploading)
if (pendingCount === 0 && (successCount > 0 || errorCount > 0)) {
hasShownSuccessToastRef.current = true;
if (successCount > 0) {
if (errorCount > 0) {
toast.error(t("uploadFile.partialSuccess", { success: successCount, error: errorCount }));
} else {
toast.success(t("uploadFile.allSuccess", { count: successCount }));
}
// Call parent's onSuccess to refresh the file list
// Add delay to ensure backend has processed everything
setTimeout(() => {
onSuccess?.();
}, 300);
}
}
}, [fileUploads, isUploading, onSuccess, t]);
return {
id,
file,
status: UploadStatus.PENDING,
progress: 0,
previewUrl,
};
};
const handleFilesSelect = (files: FileList | null) => {
if (!files) return;
const newUploads = Array.from(files).map(createFileUpload);
setFileUploads((prev) => [...prev, ...newUploads]);
setHasShownSuccessToast(false);
if (newUploads.length > 0) {
toast.info(t("uploadFile.filesQueued", { count: newUploads.length }));
// Reset toast flag and clear uploads when modal closes
useEffect(() => {
if (!isOpen) {
hasShownSuccessToastRef.current = false;
clearAll();
}
};
}, [isOpen, clearAll]);
// Handle file input change
const handleFileInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
handleFilesSelect(event.target.files);
if (event.target) {
event.target.value = "";
if (event.target.files) {
addFiles(Array.from(event.target.files));
hasShownSuccessToastRef.current = false; // Reset when adding new files
event.target.value = ""; // Reset input
}
};
// Handle drag and drop
const handleDragOver = (event: React.DragEvent) => {
event.preventDefault();
setIsDragOver(true);
@@ -177,7 +200,8 @@ export function UploadFileModal({ isOpen, onClose, onSuccess, currentFolderId }:
const files = event.dataTransfer.files;
if (files.length > 0) {
handleFilesSelect(files);
addFiles(Array.from(files));
hasShownSuccessToastRef.current = false; // Reset when adding new files
}
};
@@ -186,251 +210,40 @@ export function UploadFileModal({ isOpen, onClose, onSuccess, currentFolderId }:
return <FileIcon size={24} className={color} />;
};
const getStatusIcon = (status: UploadStatus) => {
const getStatusIcon = (status: string) => {
switch (status) {
case UploadStatus.UPLOADING:
case "uploading":
return <IconLoader size={16} className="animate-spin text-blue-500" />;
case UploadStatus.SUCCESS:
case "success":
return <IconCheck size={16} className="text-green-500" />;
case UploadStatus.ERROR:
case "error":
return <IconX size={16} className="text-red-500" />;
case UploadStatus.CANCELLED:
case "cancelled":
return <IconX size={16} className="text-muted-foreground" />;
default:
return null;
}
};
const removeFile = (fileId: string) => {
setFileUploads((prev) => {
const upload = prev.find((u) => u.id === fileId);
if (upload?.previewUrl) {
URL.revokeObjectURL(upload.previewUrl);
}
return prev.filter((u) => u.id !== fileId);
});
};
const cancelUpload = async (fileId: string) => {
const upload = fileUploads.find((u) => u.id === fileId);
if (!upload) return;
if (upload.abortController) {
upload.abortController.abort();
}
if (upload.objectName && upload.status === UploadStatus.UPLOADING) {
try {
} catch (error) {
console.error("Failed to delete uploaded file:", error);
}
}
setFileUploads((prev) =>
prev.map((u) => (u.id === fileId ? { ...u, status: UploadStatus.CANCELLED, abortController: undefined } : u))
);
};
const calculateUploadTimeout = (fileSize: number): number => {
const baseTimeout = 300000;
const fileSizeMB = fileSize / (1024 * 1024);
if (fileSizeMB > 500) {
const extraMB = fileSizeMB - 500;
const extraMinutes = Math.ceil(extraMB / 100);
return baseTimeout + extraMinutes * 60000;
}
return baseTimeout;
};
const uploadFile = async (fileUpload: FileUpload) => {
const { file, id } = fileUpload;
try {
const fileName = file.name;
const extension = fileName.split(".").pop() || "";
const safeObjectName = generateSafeFileName(fileName);
try {
await checkFile({
name: fileName,
objectName: safeObjectName,
size: file.size,
extension: extension,
folderId: currentFolderId,
});
} catch (error) {
console.error("File check failed:", error);
const errorData = getErrorData(error);
let errorMessage = t("uploadFile.error");
if (errorData.code === "fileSizeExceeded") {
errorMessage = t(`uploadFile.${errorData.code}`, { maxsizemb: errorData.details || "0" });
} else if (errorData.code === "insufficientStorage") {
errorMessage = t(`uploadFile.${errorData.code}`, { availablespace: errorData.details || "0" });
} else if (errorData.code) {
errorMessage = t(`uploadFile.${errorData.code}`);
}
setFileUploads((prev) =>
prev.map((u) => (u.id === id ? { ...u, status: UploadStatus.ERROR, error: errorMessage } : u))
);
return;
}
setFileUploads((prev) =>
prev.map((u) => (u.id === id ? { ...u, status: UploadStatus.UPLOADING, progress: 0 } : u))
);
const presignedResponse = await getFilePresignedUrl({
filename: safeObjectName.replace(`.${extension}`, ""),
extension: extension,
});
const { url, objectName } = presignedResponse.data;
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, objectName } : u)));
const abortController = new AbortController();
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, abortController } : u)));
const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size, isS3Enabled ?? undefined);
if (shouldUseChunked) {
const chunkSize = ChunkedUploader.calculateOptimalChunkSize(file.size);
const result = await ChunkedUploader.uploadFile({
file,
url,
chunkSize,
signal: abortController.signal,
isS3Enabled: isS3Enabled ?? undefined,
onProgress: (progress) => {
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, progress } : u)));
},
});
if (!result.success) {
throw new Error(result.error || "Chunked upload failed");
}
const finalObjectName = result.finalObjectName || objectName;
await registerFile({
name: fileName,
objectName: finalObjectName,
size: file.size,
extension: extension,
folderId: currentFolderId,
});
} else {
const uploadTimeout = calculateUploadTimeout(file.size);
await axios.put(url, file, {
headers: {
"Content-Type": file.type,
},
signal: abortController.signal,
timeout: uploadTimeout,
maxContentLength: Infinity,
maxBodyLength: Infinity,
onUploadProgress: (progressEvent) => {
const progress = (progressEvent.loaded / (progressEvent.total || file.size)) * 100;
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, progress: Math.round(progress) } : u)));
},
});
await registerFile({
name: fileName,
objectName: objectName,
size: file.size,
extension: extension,
folderId: currentFolderId,
});
}
setFileUploads((prev) =>
prev.map((u) =>
u.id === id ? { ...u, status: UploadStatus.SUCCESS, progress: 100, abortController: undefined } : u
)
);
} catch (error: any) {
if (error.name === "AbortError" || error.code === "ERR_CANCELED") {
return;
}
console.error("Upload failed:", error);
const errorData = getErrorData(error);
let errorMessage = t("uploadFile.error");
if (errorData.code && errorData.code !== "error") {
errorMessage = t(`uploadFile.${errorData.code}`);
}
setFileUploads((prev) =>
prev.map((u) =>
u.id === id ? { ...u, status: UploadStatus.ERROR, error: errorMessage, abortController: undefined } : u
)
);
}
};
const startUploads = async () => {
const pendingUploads = fileUploads.filter((u) => u.status === UploadStatus.PENDING);
setHasShownSuccessToast(false);
const uploadPromises = pendingUploads.map((upload) => uploadFile(upload));
await Promise.all(uploadPromises);
setTimeout(() => {
setFileUploads((currentUploads) => {
const allComplete = currentUploads.every(
(u) =>
u.status === UploadStatus.SUCCESS || u.status === UploadStatus.ERROR || u.status === UploadStatus.CANCELLED
);
if (allComplete && !hasShownSuccessToast) {
const successCount = currentUploads.filter((u) => u.status === UploadStatus.SUCCESS).length;
const errorCount = currentUploads.filter((u) => u.status === UploadStatus.ERROR).length;
if (successCount > 0) {
if (errorCount > 0) {
toast.error(t("uploadFile.partialSuccess", { success: successCount, error: errorCount }));
}
setHasShownSuccessToast(true);
setTimeout(() => onSuccess?.(), 0);
}
}
return currentUploads;
});
}, 100);
};
const handleConfirmClose = () => {
// Cancel all uploads
fileUploads.forEach((upload) => {
if (upload.status === UploadStatus.UPLOADING && upload.abortController) {
upload.abortController.abort();
if (upload.status === "uploading") {
cancelUpload(upload.id);
}
});
fileUploads.forEach((upload) => {
// Revoke preview URLs
if (upload.previewUrl) {
URL.revokeObjectURL(upload.previewUrl);
}
});
setFileUploads([]);
setShowConfirmation(false);
setHasShownSuccessToast(false);
onClose();
};
// Prevent closing while uploading
const handleClose = () => {
const uploadsInProgress = fileUploads.filter((u) => u.status === UploadStatus.UPLOADING).length;
if (uploadsInProgress > 0) {
if (isUploading) {
setShowConfirmation(true);
} else {
handleConfirmClose();
@@ -443,12 +256,9 @@ export function UploadFileModal({ isOpen, onClose, onSuccess, currentFolderId }:
const allUploadsComplete =
fileUploads.length > 0 &&
fileUploads.every(
(u) => u.status === UploadStatus.SUCCESS || u.status === UploadStatus.ERROR || u.status === UploadStatus.CANCELLED
);
fileUploads.every((u) => u.status === "success" || u.status === "error" || u.status === "cancelled");
const hasUploadsInProgress = fileUploads.some((u) => u.status === UploadStatus.UPLOADING);
const hasPendingUploads = fileUploads.some((u) => u.status === UploadStatus.PENDING);
const hasPendingUploads = fileUploads.some((u) => u.status === "pending");
return (
<>
@@ -504,20 +314,20 @@ export function UploadFileModal({ isOpen, onClose, onSuccess, currentFolderId }:
</div>
<p className="text-xs text-muted-foreground">{formatFileSize(upload.file.size)}</p>
{upload.status === UploadStatus.UPLOADING && (
{upload.status === "uploading" && (
<div className="mt-1">
<Progress value={upload.progress} className="h-1" />
<p className="text-xs text-muted-foreground mt-1">{upload.progress}%</p>
</div>
)}
{upload.status === UploadStatus.ERROR && upload.error && (
{upload.status === "error" && upload.error && (
<p className="text-xs text-destructive mt-1">{upload.error}</p>
)}
</div>
<div className="flex-shrink-0">
{upload.status === UploadStatus.UPLOADING ? (
{upload.status === "uploading" ? (
<Button
variant="ghost"
size="sm"
@@ -526,18 +336,12 @@ export function UploadFileModal({ isOpen, onClose, onSuccess, currentFolderId }:
>
<IconX size={14} />
</Button>
) : upload.status === UploadStatus.SUCCESS ? null : upload.status === UploadStatus.ERROR ? (
) : upload.status === "success" ? null : upload.status === "error" ? (
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
setFileUploads((prev) =>
prev.map((u) =>
u.id === upload.id ? { ...u, status: UploadStatus.PENDING, error: undefined } : u
)
);
}}
onClick={() => retryUpload(upload.id)}
className="h-8 w-8 p-0"
title={t("uploadFile.retry")}
>
@@ -569,12 +373,8 @@ export function UploadFileModal({ isOpen, onClose, onSuccess, currentFolderId }:
{allUploadsComplete ? t("common.close") : t("common.cancel")}
</Button>
{!allUploadsComplete && (
<Button
variant="default"
disabled={fileUploads.length === 0 || hasUploadsInProgress}
onClick={startUploads}
>
{hasUploadsInProgress ? (
<Button variant="default" disabled={fileUploads.length === 0 || isUploading} onClick={startUpload}>
{isUploading ? (
<IconLoader className="h-4 w-4 animate-spin" />
) : (
<IconCloudUpload className="h-4 w-4" />
@@ -595,7 +395,7 @@ export function UploadFileModal({ isOpen, onClose, onSuccess, currentFolderId }:
isOpen={showConfirmation}
onConfirm={handleConfirmClose}
onCancel={handleContinueUploads}
uploadsInProgress={fileUploads.filter((u) => u.status === UploadStatus.UPLOADING).length}
uploadsInProgress={fileUploads.filter((u) => u.status === "uploading").length}
/>
</>
);

View File

@@ -0,0 +1,42 @@
import { Skeleton } from "@/components/ui/skeleton";
interface FilesGridSkeletonProps {
itemCount?: number;
}
export function FilesGridSkeleton({ itemCount = 12 }: FilesGridSkeletonProps) {
return (
<>
{/* Select All Checkbox Skeleton */}
<div className="flex items-center gap-2 px-2 mb-4">
<Skeleton className="h-4 w-4 rounded" />
<Skeleton className="h-4 w-24" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{Array.from({ length: itemCount }).map((_, index) => (
<div key={index} className="border rounded-lg p-3 space-y-3">
{/* Icon/Preview skeleton */}
<div className="flex flex-col items-center space-y-3">
<Skeleton className="w-16 h-16 rounded-lg" />
{/* File name skeleton */}
<div className="w-full space-y-1">
<Skeleton className="h-4 w-full" />
{/* Description skeleton (optional, 50% chance) */}
{index % 2 === 0 && <Skeleton className="h-3 w-3/4" />}
{/* Size and date skeleton */}
<div className="space-y-1">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-24" />
</div>
</div>
</div>
</div>
))}
</div>
</>
);
}

View File

@@ -0,0 +1,58 @@
import { Skeleton } from "@/components/ui/skeleton";
interface FilesTableSkeletonProps {
rowCount?: number;
}
export function FilesTableSkeleton({ rowCount = 10 }: FilesTableSkeletonProps) {
return (
<div className="rounded-md border">
<div className="w-full">
{/* Table Header */}
<div className="border-b bg-muted/50">
<div className="grid grid-cols-[auto_1fr_120px_120px_80px] gap-4 p-4">
<Skeleton className="h-4 w-4" /> {/* Checkbox */}
<Skeleton className="h-4 w-24" /> {/* Name */}
<Skeleton className="h-4 w-16" /> {/* Size */}
<Skeleton className="h-4 w-20" /> {/* Modified */}
<Skeleton className="h-4 w-12" /> {/* Actions */}
</div>
</div>
{/* Table Rows */}
<div className="divide-y">
{Array.from({ length: rowCount }).map((_, index) => (
<div key={index} className="grid grid-cols-[auto_1fr_120px_120px_80px] gap-4 p-4 hover:bg-muted/50">
{/* Checkbox */}
<div className="flex items-center gap-2 min-w-0">
<Skeleton className="h-4 w-4" />
</div>
{/* Name column with icon */}
<div className="flex items-center gap-2 min-w-0">
<Skeleton className="h-6 w-6 rounded flex-shrink-0" />
<Skeleton className="h-4 w-full max-w-[200px]" />
</div>
{/* Size */}
<div className="flex items-center">
<Skeleton className="h-4 w-16" />
</div>
{/* Modified date */}
<div className="flex items-center">
<Skeleton className="h-4 w-24" />
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-2">
<Skeleton className="h-6 w-6 rounded" />
<Skeleton className="h-6 w-6 rounded" />
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { FilesGridSkeleton } from "./files-grid-skeleton";
export { FilesTableSkeleton } from "./files-table-skeleton";

View File

@@ -1,12 +1,14 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
IconArrowsMove,
IconChevronDown,
IconCloudUpload,
IconDotsVertical,
IconDownload,
IconEdit,
IconEye,
IconFolder,
IconFolderPlus,
IconShare,
IconTrash,
} from "@tabler/icons-react";
@@ -14,13 +16,15 @@ import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "@/components/ui/context-menu";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { getDownloadUrl } from "@/http/endpoints";
import { useDragDrop } from "@/hooks/use-drag-drop";
import { getCachedDownloadUrl } from "@/lib/download-url-cache";
import { getFileIcon } from "@/utils/file-icons";
import { formatFileSize } from "@/utils/format-file-size";
@@ -61,7 +65,8 @@ interface FilesGridProps {
folders?: Folder[];
onPreview?: (file: File) => void;
onRename?: (file: File) => void;
onCreateFolder?: () => void;
onUpload?: () => void;
onDownload: (objectName: string, fileName: string) => void;
onShare?: (file: File) => void;
onDelete?: (file: File) => void;
@@ -77,6 +82,8 @@ interface FilesGridProps {
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
onMoveFolder?: (folder: Folder) => void;
onMoveFile?: (file: File) => void;
onRefresh?: () => Promise<void>;
onImmediateUpdate?: (itemId: string, itemType: "file" | "folder", newParentId: string | null) => void;
showBulkActions?: boolean;
isShareMode?: boolean;
}
@@ -86,6 +93,8 @@ export function FilesGrid({
folders = [],
onPreview,
onRename,
onCreateFolder,
onUpload,
onDownload,
onShare,
onDelete,
@@ -101,6 +110,8 @@ export function FilesGrid({
onDownloadFolder,
onMoveFolder,
onMoveFile,
onRefresh,
onImmediateUpdate,
showBulkActions = true,
isShareMode = false,
}: FilesGridProps) {
@@ -109,6 +120,26 @@ export function FilesGrid({
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [selectedFolders, setSelectedFolders] = useState<Set<string>>(new Set());
// Drag and drop functionality
const {
draggedItem,
draggedItems,
dragOverTarget,
isDragging,
handleDragStart,
handleDragEnd,
handleDragOver,
handleDragLeave,
handleDrop,
} = useDragDrop({
onRefresh,
onImmediateUpdate,
selectedFiles,
selectedFolders,
files,
folders,
});
const [filePreviewUrls, setFilePreviewUrls] = useState<Record<string, string>>({});
const loadingUrls = useRef<Set<string>>(new Set());
@@ -163,12 +194,12 @@ export function FilesGrid({
try {
loadingUrls.current.add(file.objectName);
const response = await getDownloadUrl(file.objectName);
const url = await getCachedDownloadUrl(file.objectName);
if (!componentMounted.current) break;
urlCache[file.objectName] = { url: response.data.url, timestamp: now };
setFilePreviewUrls((prev) => ({ ...prev, [file.id]: response.data.url }));
urlCache[file.objectName] = { url, timestamp: now };
setFilePreviewUrls((prev) => ({ ...prev, [file.id]: url }));
} catch (error) {
console.error(`Failed to load preview for ${file.name}:`, error);
} finally {
@@ -225,6 +256,11 @@ export function FilesGrid({
const selectedItems = selectedFiles.size + selectedFolders.size;
const isAllSelected = totalItems > 0 && selectedItems === totalItems;
// Memoize dragged item IDs for performance
const draggedItemIds = useMemo(() => {
return new Set(draggedItems.map((item) => item.id));
}, [draggedItems]);
const handleBulkAction = (action: "delete" | "share" | "download" | "move") => {
const selectedFileObjects = getSelectedFiles();
const selectedFolderObjects = getSelectedFolders();
@@ -335,312 +371,547 @@ export function FilesGrid({
<span className="text-sm text-muted-foreground">{t("filesTable.selectAll")}</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{/* Render folders first */}
{folders.map((folder) => {
const isSelected = selectedFolders.has(folder.id);
<ContextMenu modal={false}>
<ContextMenuTrigger asChild>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 ">
{/* Render folders first */}
{folders.map((folder) => {
const isSelected = selectedFolders.has(folder.id);
const isDragOver = dragOverTarget?.id === folder.id;
const isDraggedOver = draggedItem?.id === folder.id;
return (
<div
key={`folder-${folder.id}`}
className={`relative group border rounded-lg p-3 hover:bg-muted/50 transition-colors cursor-pointer ${
isSelected ? "ring-2 ring-primary bg-muted/50" : ""
}`}
onClick={() => onNavigateToFolder?.(folder.id)}
>
<div className="absolute top-2 left-2 z-10 checkbox-wrapper">
<Checkbox
checked={isSelected}
onCheckedChange={(checked: boolean) => {
const newSelected = new Set(selectedFolders);
if (checked) {
newSelected.add(folder.id);
} else {
newSelected.delete(folder.id);
}
setSelectedFolders(newSelected);
}}
aria-label={`Select folder ${folder.name}`}
className="bg-background border-2"
onClick={(e) => e.stopPropagation()}
/>
</div>
// Check if this folder is part of the dragged items (optimized with memoized Set)
const isBeingDragged = draggedItemIds.has(folder.id);
const isAnySelectedItemDragged = isDragging && isSelected && draggedItems.length > 1;
<div className="absolute top-2 right-2 z-10">
{isShareMode ? (
onDownloadFolder && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 hover:bg-background/80"
const folderContextMenu = !isShareMode && (
<ContextMenuContent className="w-[200px]">
{onRenameFolder && (
<ContextMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onRenameFolder(folder);
}}
>
<IconEdit className="h-4 w-4" />
{t("filesTable.actions.edit")}
</ContextMenuItem>
)}
{onMoveFolder && (
<ContextMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onMoveFolder(folder);
}}
>
<IconArrowsMove className="h-4 w-4" />
{t("common.move")}
</ContextMenuItem>
)}
{onShareFolder && (
<ContextMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onShareFolder(folder);
}}
>
<IconShare className="h-4 w-4" />
{t("filesTable.actions.share")}
</ContextMenuItem>
)}
{onDownloadFolder && (
<ContextMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onDownloadFolder(folder.id, folder.name);
}}
>
<IconDownload className="h-4 w-4" />
<span className="sr-only">{t("filesTable.actions.download")}</span>
</Button>
)
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={(e) => e.stopPropagation()}>
<IconDotsVertical className="h-4 w-4" />
<span className="sr-only">{t("filesTable.actions.menu")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
{onRenameFolder && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onRenameFolder(folder);
}}
>
<IconEdit className="h-4 w-4" />
{t("filesTable.actions.edit")}
</DropdownMenuItem>
)}
{onMoveFolder && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onMoveFolder(folder);
}}
>
<IconArrowsMove className="h-4 w-4" />
{t("common.move")}
</DropdownMenuItem>
)}
{onShareFolder && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onShareFolder(folder);
}}
>
<IconShare className="h-4 w-4" />
{t("filesTable.actions.share")}
</DropdownMenuItem>
)}
{onDownloadFolder && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onDownloadFolder(folder.id, folder.name);
}}
>
<IconDownload className="h-4 w-4" />
{t("filesTable.actions.download")}
</DropdownMenuItem>
)}
{onDeleteFolder && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDeleteFolder(folder);
}}
className="cursor-pointer py-2 text-destructive focus:text-destructive"
>
<IconTrash className="h-4 w-4" />
{t("filesTable.actions.delete")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="flex flex-col items-center space-y-3">
<div className="w-16 h-16 flex items-center justify-center bg-muted/30 rounded-lg overflow-hidden">
<IconFolder className="h-10 w-10 text-primary" />
</div>
<div className="w-full space-y-1">
<p className="text-sm font-medium truncate text-left" title={folder.name}>
{folder.name}
</p>
{folder.description && (
<p className="text-xs text-muted-foreground truncate text-left" title={folder.description}>
{folder.description}
</p>
{t("filesTable.actions.download")}
</ContextMenuItem>
)}
<div className="text-xs text-muted-foreground space-y-1 text-left">
<p>{folder.totalSize ? formatFileSize(Number(folder.totalSize)) : "—"}</p>
<p>{formatDateTime(folder.createdAt)}</p>
</div>
</div>
</div>
</div>
);
})}
{onDeleteFolder && (
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onDeleteFolder(folder);
}}
className="cursor-pointer py-2 text-destructive focus:text-destructive"
variant="destructive"
>
<IconTrash className="h-4 w-4" />
{t("filesTable.actions.delete")}
</ContextMenuItem>
)}
</ContextMenuContent>
);
{/* Render files */}
{files.map((file) => {
const { icon: FileIcon, color } = getFileIcon(file.name);
const isSelected = selectedFiles.has(file.id);
const isImage = isImageFile(file.name);
const previewUrl = filePreviewUrls[file.id];
return (
<ContextMenu key={`folder-${folder.id}`} modal={false}>
<ContextMenuTrigger asChild>
<div
data-card="true"
className={`relative group border rounded-lg p-3 hover:bg-muted/50 transition-all duration-200 cursor-pointer ${
isSelected ? "ring-2 ring-primary bg-muted/50" : ""
} ${isDragOver && !isBeingDragged ? "ring-2 ring-primary bg-primary/10 scale-105" : ""} ${
isDraggedOver ? "opacity-50" : ""
} ${
isBeingDragged || isAnySelectedItemDragged
? "opacity-40 scale-95 transform rotate-2 border-2 border-primary/50 shadow-lg"
: ""
}`}
style={{
transition: "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
willChange: isDragging ? "transform, opacity" : "auto",
}}
onClick={() => onNavigateToFolder?.(folder.id)}
draggable
onDragStart={(e) => {
e.stopPropagation();
handleDragStart(e, { id: folder.id, type: "folder", name: folder.name });
}}
onDragEnd={handleDragEnd}
onDragOver={(e) => {
e.stopPropagation();
handleDragOver(e, { id: folder.id, type: "folder", name: folder.name });
}}
onDragLeave={handleDragLeave}
onDrop={(e) => {
e.stopPropagation();
handleDrop(e, { id: folder.id, type: "folder", name: folder.name });
}}
onContextMenu={(e) => {
e.stopPropagation();
}}
>
<div className="absolute top-2 left-2 z-10 checkbox-wrapper">
<Checkbox
checked={isSelected}
onCheckedChange={(checked: boolean) => {
const newSelected = new Set(selectedFolders);
if (checked) {
newSelected.add(folder.id);
} else {
newSelected.delete(folder.id);
}
setSelectedFolders(newSelected);
}}
aria-label={`Select folder ${folder.name}`}
className="bg-background border-2"
onClick={(e) => e.stopPropagation()}
/>
</div>
return (
<div
key={file.id}
className={`relative group border rounded-lg p-3 hover:bg-muted/50 transition-colors cursor-pointer ${
isSelected ? "ring-2 ring-primary bg-muted/50" : ""
}`}
onClick={(e) => {
if (
(e.target as HTMLElement).closest(".checkbox-wrapper") ||
(e.target as HTMLElement).closest("button") ||
(e.target as HTMLElement).closest('[role="menuitem"]')
) {
return;
}
if (onPreview) {
onPreview(file);
}
}}
>
<div className="absolute top-2 left-2 z-10 checkbox-wrapper">
<Checkbox
checked={isSelected}
onCheckedChange={(checked: boolean) => {
handleSelectFile({ stopPropagation: () => {} } as React.MouseEvent, file.id, checked);
}}
aria-label={t("filesTable.selectFile", { fileName: file.name })}
className="bg-background border-2"
/>
</div>
<div className="absolute top-2 right-2 z-10">
{isShareMode ? (
onDownloadFolder && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 hover:bg-background/80"
onClick={(e) => {
e.stopPropagation();
onDownloadFolder(folder.id, folder.name);
}}
>
<IconDownload className="h-4 w-4" />
<span className="sr-only">{t("filesTable.actions.download")}</span>
</Button>
)
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => e.stopPropagation()}
>
<IconDotsVertical className="h-4 w-4" />
<span className="sr-only">{t("filesTable.actions.menu")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
{onRenameFolder && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onRenameFolder(folder);
}}
>
<IconEdit className="h-4 w-4" />
{t("filesTable.actions.edit")}
</DropdownMenuItem>
)}
{onMoveFolder && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onMoveFolder(folder);
}}
>
<IconArrowsMove className="h-4 w-4" />
{t("common.move")}
</DropdownMenuItem>
)}
{onShareFolder && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onShareFolder(folder);
}}
>
<IconShare className="h-4 w-4" />
{t("filesTable.actions.share")}
</DropdownMenuItem>
)}
{onDownloadFolder && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onDownloadFolder(folder.id, folder.name);
}}
>
<IconDownload className="h-4 w-4" />
{t("filesTable.actions.download")}
</DropdownMenuItem>
)}
{onDeleteFolder && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDeleteFolder(folder);
}}
className="cursor-pointer py-2 text-destructive focus:text-destructive"
>
<IconTrash className="h-4 w-4" />
{t("filesTable.actions.delete")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="absolute top-2 right-2 z-10">
{isShareMode ? (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 hover:bg-background/80"
<div className="flex flex-col items-center space-y-3">
<div className="w-16 h-16 flex items-center justify-center bg-muted/30 rounded-lg overflow-hidden">
<IconFolder className="h-10 w-10 text-primary" />
</div>
<div className="w-full space-y-1">
<p className="text-sm font-medium truncate text-left" title={folder.name}>
{folder.name}
</p>
{folder.description && (
<p className="text-xs text-muted-foreground truncate text-left" title={folder.description}>
{folder.description}
</p>
)}
<div className="text-xs text-muted-foreground space-y-1 text-left">
<p>{folder.totalSize ? formatFileSize(Number(folder.totalSize)) : "—"}</p>
<p>{formatDateTime(folder.createdAt)}</p>
</div>
</div>
</div>
</div>
</ContextMenuTrigger>
{folderContextMenu}
</ContextMenu>
);
})}
{/* Render files */}
{files.map((file) => {
const { icon: FileIcon, color } = getFileIcon(file.name);
const isSelected = selectedFiles.has(file.id);
const isImage = isImageFile(file.name);
const previewUrl = filePreviewUrls[file.id];
const isDraggedOver = draggedItem?.id === file.id;
// Check if this file is part of the dragged items (optimized with memoized Set)
const isBeingDragged = draggedItemIds.has(file.id);
const isAnySelectedItemDragged = isDragging && isSelected && draggedItems.length > 1;
const fileContextMenu = !isShareMode && (
<ContextMenuContent className="w-[200px]">
<ContextMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onPreview?.(file);
}}
>
<IconEye className="h-4 w-4" />
{t("filesTable.actions.preview")}
</ContextMenuItem>
{onRename && (
<ContextMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onRename?.(file);
}}
>
<IconEdit className="h-4 w-4" />
{t("filesTable.actions.edit")}
</ContextMenuItem>
)}
<ContextMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onDownload(file.objectName, file.name);
}}
>
<IconDownload className="h-4 w-4" />
<span className="sr-only">{t("filesTable.actions.download")}</span>
</Button>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={(e) => e.stopPropagation()}>
<IconDotsVertical className="h-4 w-4" />
<span className="sr-only">{t("filesTable.actions.menu")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onPreview?.(file);
}}
>
<IconEye className="h-4 w-4" />
{t("filesTable.actions.preview")}
</DropdownMenuItem>
{onRename && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onRename?.(file);
}}
>
<IconEdit className="h-4 w-4" />
{t("filesTable.actions.edit")}
</DropdownMenuItem>
)}
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onDownload(file.objectName, file.name);
}}
>
<IconDownload className="h-4 w-4" />
{t("filesTable.actions.download")}
</DropdownMenuItem>
{onShare && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onShare?.(file);
}}
>
<IconShare className="h-4 w-4" />
{t("filesTable.actions.share")}
</DropdownMenuItem>
)}
{onMoveFile && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onMoveFile?.(file);
}}
>
<IconArrowsMove className="h-4 w-4" />
{t("common.move")}
</DropdownMenuItem>
)}
{onDelete && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete?.(file);
}}
className="cursor-pointer py-2 text-destructive focus:text-destructive"
>
<IconTrash className="h-4 w-4" />
{t("filesTable.actions.delete")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="flex flex-col items-center space-y-3">
<div className="w-16 h-16 flex items-center justify-center bg-muted/30 rounded-lg overflow-hidden">
{isImage && previewUrl ? (
<img src={previewUrl} alt={file.name} className="object-cover w-full h-full" />
) : (
<FileIcon className={`h-10 w-10 ${color}`} />
{t("filesTable.actions.download")}
</ContextMenuItem>
{onShare && (
<ContextMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onShare?.(file);
}}
>
<IconShare className="h-4 w-4" />
{t("filesTable.actions.share")}
</ContextMenuItem>
)}
</div>
<div className="w-full space-y-1">
<p className="text-sm font-medium truncate text-left" title={file.name}>
{file.name}
</p>
{file.description && (
<p className="text-xs text-muted-foreground truncate text-left" title={file.description}>
{file.description}
</p>
{onMoveFile && (
<ContextMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onMoveFile?.(file);
}}
>
<IconArrowsMove className="h-4 w-4" />
{t("common.move")}
</ContextMenuItem>
)}
<div className="text-xs text-muted-foreground space-y-1 text-left">
<p>{formatFileSize(file.size)}</p>
<p>{formatDateTime(file.createdAt)}</p>
</div>
</div>
</div>
</div>
);
})}
</div>
{onDelete && (
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete?.(file);
}}
className="cursor-pointer py-2 text-destructive focus:text-destructive"
variant="destructive"
>
<IconTrash className="h-4 w-4" />
{t("filesTable.actions.delete")}
</ContextMenuItem>
)}
</ContextMenuContent>
);
return (
<ContextMenu key={file.id} modal={false}>
<ContextMenuTrigger asChild>
<div
data-card="true"
className={`relative group border rounded-lg p-3 hover:bg-muted/50 transition-all duration-200 cursor-pointer ${
isSelected ? "ring-2 ring-primary bg-muted/50" : ""
} ${isDraggedOver ? "opacity-50 scale-95" : ""} ${
isBeingDragged || isAnySelectedItemDragged
? "opacity-40 scale-95 transform rotate-2 border-2 border-primary/50 shadow-lg"
: ""
}`}
style={{
transition: "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
willChange: isDragging ? "transform, opacity" : "auto",
}}
onClick={(e) => {
if (
(e.target as HTMLElement).closest(".checkbox-wrapper") ||
(e.target as HTMLElement).closest("button") ||
(e.target as HTMLElement).closest('[role="menuitem"]')
) {
return;
}
if (onPreview) {
onPreview(file);
}
}}
draggable
onDragStart={(e) => {
e.stopPropagation();
handleDragStart(e, { id: file.id, type: "file", name: file.name });
}}
onDragEnd={handleDragEnd}
onContextMenu={(e) => {
e.stopPropagation();
}}
>
<div className="absolute top-2 left-2 z-10 checkbox-wrapper">
<Checkbox
checked={isSelected}
onCheckedChange={(checked: boolean) => {
handleSelectFile({ stopPropagation: () => {} } as React.MouseEvent, file.id, checked);
}}
aria-label={t("filesTable.selectFile", { fileName: file.name })}
className="bg-background border-2"
/>
</div>
<div className="absolute top-2 right-2 z-10">
{isShareMode ? (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 hover:bg-background/80"
onClick={(e) => {
e.stopPropagation();
onDownload(file.objectName, file.name);
}}
>
<IconDownload className="h-4 w-4" />
<span className="sr-only">{t("filesTable.actions.download")}</span>
</Button>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => e.stopPropagation()}
>
<IconDotsVertical className="h-4 w-4" />
<span className="sr-only">{t("filesTable.actions.menu")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onPreview?.(file);
}}
>
<IconEye className="h-4 w-4" />
{t("filesTable.actions.preview")}
</DropdownMenuItem>
{onRename && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onRename?.(file);
}}
>
<IconEdit className="h-4 w-4" />
{t("filesTable.actions.edit")}
</DropdownMenuItem>
)}
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onDownload(file.objectName, file.name);
}}
>
<IconDownload className="h-4 w-4" />
{t("filesTable.actions.download")}
</DropdownMenuItem>
{onShare && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onShare?.(file);
}}
>
<IconShare className="h-4 w-4" />
{t("filesTable.actions.share")}
</DropdownMenuItem>
)}
{onMoveFile && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onMoveFile?.(file);
}}
>
<IconArrowsMove className="h-4 w-4" />
{t("common.move")}
</DropdownMenuItem>
)}
{onDelete && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete?.(file);
}}
className="cursor-pointer py-2 text-destructive focus:text-destructive"
>
<IconTrash className="h-4 w-4" />
{t("filesTable.actions.delete")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="flex flex-col items-center space-y-3">
<div className="w-16 h-16 flex items-center justify-center bg-muted/30 rounded-lg overflow-hidden">
{isImage && previewUrl ? (
<img src={previewUrl} alt={file.name} className="object-cover w-full h-full" />
) : (
<FileIcon className={`h-10 w-10 ${color}`} />
)}
</div>
<div className="w-full space-y-1">
<p className="text-sm font-medium truncate text-left" title={file.name}>
{file.name}
</p>
{file.description && (
<p className="text-xs text-muted-foreground truncate text-left" title={file.description}>
{file.description}
</p>
)}
<div className="text-xs text-muted-foreground space-y-1 text-left">
<p>{formatFileSize(file.size)}</p>
<p>{formatDateTime(file.createdAt)}</p>
</div>
</div>
</div>
</div>
</ContextMenuTrigger>
{fileContextMenu}
</ContextMenu>
);
})}
</div>
</ContextMenuTrigger>
{!isShareMode && (onCreateFolder || onUpload) && (
<ContextMenuContent className="w-[200px]">
{onCreateFolder && (
<ContextMenuItem onClick={onCreateFolder} className="cursor-pointer py-2">
<IconFolderPlus className="h-4 w-4" />
{t("contextMenu.newFolder")}
</ContextMenuItem>
)}
{onUpload && (
<ContextMenuItem onClick={onUpload} className="cursor-pointer py-2">
<IconCloudUpload className="h-4 w-4" />
{t("contextMenu.uploadFile")}
</ContextMenuItem>
)}
</ContextMenuContent>
)}
</ContextMenu>
</div>
);
}

View File

@@ -13,7 +13,7 @@ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
"text-muted-foreground flex flex-wrap items-center gap-1 text-sm break-words sm:gap-2.5",
className
)}
{...props}
@@ -22,7 +22,7 @@ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return <li data-slot="breadcrumb-item" className={cn("inline-flex items-center gap-1.5", className)} {...props} />;
return <li data-slot="breadcrumb-item" className={cn("inline-flex items-center gap-1", className)} {...props} />;
}
function BreadcrumbLink({

View File

@@ -0,0 +1,252 @@
"use client"
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
)
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

View File

@@ -0,0 +1,62 @@
/**
* Upload configuration for the application
* Centralizes all upload-related settings
*/
export const UPLOAD_CONFIG = {
/**
* Size threshold for multipart upload (100MB)
* Files >= this size will use multipart upload
* Files < this size will use simple PUT upload
*/
MULTIPART_THRESHOLD: 50 * 1024 * 1024,
/**
* No file size limit (managed by backend/user quota)
*/
MAX_FILE_SIZE: null,
/**
* No file count limit (configurable per context)
*/
MAX_FILES: null,
/**
* Allow all file types (restrictions are context-specific)
*/
ALLOWED_TYPES: null,
/**
* Retry configuration
*/
MAX_RETRIES: 5,
RETRY_DELAYS: [1000, 3000, 5000, 10000, 15000], // ms
/**
* Concurrent uploads (unlimited/maximum possible)
* 0 = unlimited
*/
MAX_CONCURRENT: 0,
/**
* Progress update debounce (100ms for performance)
*/
PROGRESS_DEBOUNCE: 100,
/**
* "Many files" threshold for UI optimizations
*/
MANY_FILES_THRESHOLD: 100,
/**
* Toast auto-dismiss duration
*/
TOAST_DURATION: 3000, // ms
/**
* Preview generation
*/
PREVIEW_MAX_SIZE: null, // No limit
PREVIEW_TYPES: ["image/*"],
} as const;
export type UploadConfig = typeof UPLOAD_CONFIG;

View File

@@ -1,144 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import {
cancelQueuedDownload,
getDownloadQueueStatus,
type DownloadQueueStatus,
} from "@/http/endpoints/download-queue";
export interface DownloadQueueHook {
queueStatus: DownloadQueueStatus | null;
isLoading: boolean;
error: string | null;
refreshQueue: () => Promise<void>;
cancelDownload: (downloadId: string) => Promise<void>;
getQueuePosition: (downloadId: string) => number | null;
isDownloadQueued: (downloadId: string) => boolean;
getEstimatedWaitTime: (downloadId: string) => string | null;
}
export function useDownloadQueue(autoRefresh = true, initialIntervalMs = 3000) {
const t = useTranslations();
const [queueStatus, setQueueStatus] = useState<DownloadQueueStatus | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [currentInterval, setCurrentInterval] = useState(initialIntervalMs);
const [noActivityCount, setNoActivityCount] = useState(0);
const refreshQueue = useCallback(async () => {
try {
setIsLoading(true);
setError(null);
const response = await getDownloadQueueStatus();
const newStatus = response.data;
const hasActivity = newStatus.activeDownloads > 0 || newStatus.queueLength > 0;
const previousActivity = (queueStatus?.activeDownloads || 0) > 0 || (queueStatus?.queueLength || 0) > 0;
const statusChanged = JSON.stringify(queueStatus) !== JSON.stringify(newStatus);
if (!hasActivity && !previousActivity && !statusChanged) {
setNoActivityCount((prev) => prev + 1);
} else {
setNoActivityCount(0);
setCurrentInterval(initialIntervalMs);
}
setQueueStatus(newStatus);
} catch (err: any) {
const errorMessage = err?.response?.data?.error || err?.message || "Failed to fetch queue status";
setError(errorMessage);
console.error("Error fetching download queue status:", err);
} finally {
setIsLoading(false);
}
}, [queueStatus, initialIntervalMs]);
const cancelDownload = useCallback(
async (downloadId: string) => {
try {
await cancelQueuedDownload(downloadId);
toast.success(t("downloadQueue.cancelSuccess"));
await refreshQueue();
} catch (err: any) {
const errorMessage = err?.response?.data?.error || err?.message || "Failed to cancel download";
toast.error(t("downloadQueue.cancelError", { error: errorMessage }));
console.error("Error cancelling download:", err);
}
},
[refreshQueue, t]
);
const getQueuePosition = useCallback(
(downloadId: string): number | null => {
if (!queueStatus) return null;
const download = queueStatus.queuedDownloads.find((d) => d.downloadId === downloadId);
return download?.position || null;
},
[queueStatus]
);
const isDownloadQueued = useCallback(
(downloadId: string): boolean => {
if (!queueStatus) return false;
return queueStatus.queuedDownloads.some((d) => d.downloadId === downloadId);
},
[queueStatus]
);
const getEstimatedWaitTime = useCallback(
(downloadId: string): string | null => {
if (!queueStatus) return null;
const download = queueStatus.queuedDownloads.find((d) => d.downloadId === downloadId);
if (!download) return null;
const waitTimeMs = download.waitTime;
const waitTimeSeconds = Math.floor(waitTimeMs / 1000);
if (waitTimeSeconds < 60) {
return t("downloadQueue.waitTime.seconds", { seconds: waitTimeSeconds });
} else if (waitTimeSeconds < 3600) {
const minutes = Math.floor(waitTimeSeconds / 60);
return t("downloadQueue.waitTime.minutes", { minutes });
} else {
const hours = Math.floor(waitTimeSeconds / 3600);
const minutes = Math.floor((waitTimeSeconds % 3600) / 60);
return t("downloadQueue.waitTime.hoursMinutes", { hours, minutes });
}
},
[queueStatus, t]
);
useEffect(() => {
if (!autoRefresh) return;
let actualInterval = currentInterval;
if (noActivityCount > 5) {
console.log("[DOWNLOAD QUEUE] No activity detected, stopping polling");
return;
} else if (noActivityCount > 2) {
actualInterval = 10000;
setCurrentInterval(10000);
}
refreshQueue();
const interval = setInterval(refreshQueue, actualInterval);
return () => clearInterval(interval);
}, [autoRefresh, refreshQueue, currentInterval, noActivityCount]);
return {
queueStatus,
isLoading,
error,
refreshQueue,
cancelDownload,
getQueuePosition,
isDownloadQueued,
getEstimatedWaitTime,
};
}

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