mirror of
https://github.com/kyantech/Palmr.git
synced 2025-11-02 13:03:15 +00:00
Compare commits
14 Commits
59fccd9a93
...
feat--chan
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dbd5b81ae | ||
|
|
1806fbee39 | ||
|
|
5d8c80812b | ||
|
|
7118d87e47 | ||
|
|
6742ca314e | ||
|
|
c0a7970330 | ||
|
|
d0d5d012f0 | ||
|
|
965ef244f3 | ||
|
|
18700d7e72 | ||
|
|
25b1a62d5f | ||
|
|
7617a14f1b | ||
|
|
cb4ed3f581 | ||
|
|
148676513d | ||
|
|
42a5b7a796 |
56
Dockerfile
56
Dockerfile
@@ -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 \
|
||||
|
||||
@@ -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>",
|
||||
|
||||
@@ -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>",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
174
apps/server/src/modules/s3-storage/controller.ts
Normal file
174
apps/server/src/modules/s3-storage/controller.ts
Normal 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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
112
apps/server/src/modules/s3-storage/routes.ts
Normal file
112
apps/server/src/modules/s3-storage/routes.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
305
apps/server/src/scripts/migrate-filesystem-to-s3.ts
Normal file
305
apps/server/src/scripts/migrate-filesystem-to-s3.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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": "رفع ملف"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
1941
apps/web/messages/el-GR.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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
1941
apps/web/messages/fa-IR.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
1941
apps/web/messages/he-IL.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
1941
apps/web/messages/id-ID.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "ファイルをアップロード"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "파일 업로드"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
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
1941
apps/web/messages/th-TH.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
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
1941
apps/web/messages/vi-VN.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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": "上传文件"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
5111
apps/web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
157
apps/web/scripts/prune_translations.py
Normal file
157
apps/web/scripts/prune_translations.py
Normal 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())
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
35
apps/web/src/app/api/(proxy)/files/multipart/create/route.ts
Normal file
35
apps/web/src/app/api/(proxy)/files/multipart/create/route.ts
Normal 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;
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
81
apps/web/src/app/api/(proxy)/files/upload/route.ts
Normal file
81
apps/web/src/app/api/(proxy)/files/upload/route.ts
Normal 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");
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
71
apps/web/src/app/e/[id]/route.ts
Normal file
71
apps/web/src/app/e/[id]/route.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
151
apps/web/src/components/files/embed-code-display.tsx
Normal file
151
apps/web/src/components/files/embed-code-display.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
72
apps/web/src/components/files/media-embed-link.tsx
Normal file
72
apps/web/src/components/files/media-embed-link.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
42
apps/web/src/components/skeletons/files-grid-skeleton.tsx
Normal file
42
apps/web/src/components/skeletons/files-grid-skeleton.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
58
apps/web/src/components/skeletons/files-table-skeleton.tsx
Normal file
58
apps/web/src/components/skeletons/files-table-skeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
apps/web/src/components/skeletons/index.ts
Normal file
2
apps/web/src/components/skeletons/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { FilesGridSkeleton } from "./files-grid-skeleton";
|
||||
export { FilesTableSkeleton } from "./files-table-skeleton";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
252
apps/web/src/components/ui/context-menu.tsx
Normal file
252
apps/web/src/components/ui/context-menu.tsx
Normal 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,
|
||||
}
|
||||
62
apps/web/src/config/upload-config.ts
Normal file
62
apps/web/src/config/upload-config.ts
Normal 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;
|
||||
@@ -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
Reference in New Issue
Block a user