v3.0.0-beta.9 (#90)

This commit is contained in:
Daniel Luiz Alves
2025-06-20 16:33:04 -03:00
committed by GitHub
69 changed files with 2033 additions and 1017 deletions

View File

@@ -137,6 +137,22 @@ The setup process varies depending on your chosen identity provider. Here are ex
![Identity Provider Setup](/assets/v3/oidc/provider-setup.png) ![Identity Provider Setup](/assets/v3/oidc/provider-setup.png)
### Zitadel
1. **Create New ProjectApp**: In your desired Zitadel project, create a new application
2. **Name and Type**: Give your application a name and choose **WEB** as the application type
3. **Authentication Method**: Choose Code
4. **Set Redirect URI**: Add your Palmr callback URL to valid redirect URIs
5. **Finish**: After reviewing the configuration create the application
6. **Copy the client ID and client Secrat**: Copy the client id paste it into the **Client ID** of your Palmr OIDC condiguration Form, repeat for the client secret and paste it into the **Client Secret** field
7. **Obtain your Provider URL**: In your Zitadel application go to **URLs** and copy the **Authorization Endpoint (remove the /authorize from that url)** e.g. https://auth.example.com/oauth/v2
**Configuration values:**
- **Issuer URL**: Depends on your Zitadel installation and project. Example: `https://auth.example.com/oauth/v2`
- **Scope**: `openid profile email`
![Zitadel Identity Provider Setup](/assets/v3/oidc/zitadel-provider-setup.png)
--- ---
## Testing OIDC configuration ## Testing OIDC configuration

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

View File

@@ -6,40 +6,24 @@
*/ */
export const timeoutConfig = { export const timeoutConfig = {
// Connection timeouts
connection: { connection: {
// How long to wait for initial connection (0 = disabled)
timeout: 0, timeout: 0,
// Keep-alive timeout for long-running uploads/downloads
// 20 hours should be enough for most large file operations
keepAlive: 20 * 60 * 60 * 1000, // 20 hours in milliseconds keepAlive: 20 * 60 * 60 * 1000, // 20 hours in milliseconds
}, },
// Request timeouts
request: { request: {
// Global request timeout (0 = disabled, let requests run indefinitely)
timeout: 0, timeout: 0,
// Body parsing timeout for large files
bodyTimeout: 0, // Disabled for large files bodyTimeout: 0, // Disabled for large files
}, },
// File operation timeouts
file: { file: {
// Maximum time to wait for file upload (0 = no limit)
uploadTimeout: 0, uploadTimeout: 0,
// Maximum time to wait for file download (0 = no limit)
downloadTimeout: 0, downloadTimeout: 0,
// Streaming chunk timeout (time between chunks)
streamTimeout: 30 * 1000, // 30 seconds between chunks streamTimeout: 30 * 1000, // 30 seconds between chunks
}, },
// Token expiration (for filesystem storage)
token: { token: {
// How long upload/download tokens remain valid
expiration: 60 * 60 * 1000, // 1 hour in milliseconds expiration: 60 * 60 * 1000, // 1 hour in milliseconds
}, },
}; };
@@ -52,7 +36,6 @@ export function getTimeoutForFileSize(fileSizeBytes: number) {
const fileSizeGB = fileSizeBytes / (1024 * 1024 * 1024); const fileSizeGB = fileSizeBytes / (1024 * 1024 * 1024);
if (fileSizeGB > 100) { if (fileSizeGB > 100) {
// For files larger than 100GB, extend token expiration
return { return {
...timeoutConfig, ...timeoutConfig,
token: { token: {
@@ -62,7 +45,6 @@ export function getTimeoutForFileSize(fileSizeBytes: number) {
} }
if (fileSizeGB > 10) { if (fileSizeGB > 10) {
// For files larger than 10GB, extend token expiration
return { return {
...timeoutConfig, ...timeoutConfig,
token: { token: {
@@ -79,15 +61,12 @@ export function getTimeoutForFileSize(fileSizeBytes: number) {
* You can set these in your .env file to override defaults * You can set these in your .env file to override defaults
*/ */
export const envTimeoutOverrides = { export const envTimeoutOverrides = {
// Override connection keep-alive if set in environment
keepAliveTimeout: process.env.KEEP_ALIVE_TIMEOUT keepAliveTimeout: process.env.KEEP_ALIVE_TIMEOUT
? parseInt(process.env.KEEP_ALIVE_TIMEOUT) ? parseInt(process.env.KEEP_ALIVE_TIMEOUT)
: timeoutConfig.connection.keepAlive, : timeoutConfig.connection.keepAlive,
// Override request timeout if set in environment
requestTimeout: process.env.REQUEST_TIMEOUT ? parseInt(process.env.REQUEST_TIMEOUT) : timeoutConfig.request.timeout, requestTimeout: process.env.REQUEST_TIMEOUT ? parseInt(process.env.REQUEST_TIMEOUT) : timeoutConfig.request.timeout,
// Override token expiration if set in environment
tokenExpiration: process.env.TOKEN_EXPIRATION tokenExpiration: process.env.TOKEN_EXPIRATION
? parseInt(process.env.TOKEN_EXPIRATION) ? parseInt(process.env.TOKEN_EXPIRATION)
: timeoutConfig.token.expiration, : timeoutConfig.token.expiration,

View File

@@ -9,7 +9,7 @@ export const createPasswordSchema = async () => {
}; };
export const LoginSchema = z.object({ export const LoginSchema = z.object({
email: z.string().email("Invalid email").describe("User email"), emailOrUsername: z.string().min(1, "Email or username is required").describe("User email or username"),
password: z.string().min(6, "Password must be at least 6 characters").describe("User password"), password: z.string().min(6, "Password must be at least 6 characters").describe("User password"),
}); });
export type LoginInput = z.infer<typeof LoginSchema>; export type LoginInput = z.infer<typeof LoginSchema>;

View File

@@ -17,7 +17,7 @@ export async function authRoutes(app: FastifyInstance) {
const passwordSchema = await createPasswordSchema(); const passwordSchema = await createPasswordSchema();
const loginSchema = z.object({ const loginSchema = z.object({
email: z.string().email("Invalid email").describe("User email"), emailOrUsername: z.string().min(1, "Email or username is required").describe("User email or username"),
password: passwordSchema, password: passwordSchema,
}); });

View File

@@ -13,7 +13,7 @@ export class AuthService {
private emailService = new EmailService(); private emailService = new EmailService();
async login(data: LoginInput) { async login(data: LoginInput) {
const user = await this.userRepository.findUserByEmail(data.email); const user = await this.userRepository.findUserByEmailOrUsername(data.emailOrUsername);
if (!user) { if (!user) {
throw new Error("Invalid credentials"); throw new Error("Invalid credentials");
} }

View File

@@ -98,43 +98,82 @@ export class FilesystemController {
} catch (error) { } catch (error) {
try { try {
await fs.promises.unlink(tempPath); await fs.promises.unlink(tempPath);
} catch (error) { } catch (cleanupError) {
console.error("Error deleting temp file:", error); console.error("Error deleting temp file:", cleanupError);
} }
throw error; throw error;
} }
} }
private async uploadSmallFile(request: FastifyRequest, provider: FilesystemStorageProvider, objectName: string) { private async uploadSmallFile(request: FastifyRequest, provider: FilesystemStorageProvider, objectName: string) {
const stream = request.body as any; const body = request.body as any;
const chunks: Buffer[] = [];
return new Promise<void>((resolve, reject) => { if (Buffer.isBuffer(body)) {
stream.on("data", (chunk: Buffer) => { if (body.length === 0) {
chunks.push(chunk); throw new Error("No file data received");
}); }
await provider.uploadFile(objectName, body);
return;
}
stream.on("end", async () => { if (typeof body === "string") {
try { const buffer = Buffer.from(body, "utf8");
const buffer = Buffer.concat(chunks); if (buffer.length === 0) {
throw new Error("No file data received");
}
await provider.uploadFile(objectName, buffer);
return;
}
if (buffer.length === 0) { if (typeof body === "object" && body !== null && !body.on) {
throw new Error("No file data received"); const buffer = Buffer.from(JSON.stringify(body), "utf8");
if (buffer.length === 0) {
throw new Error("No file data received");
}
await provider.uploadFile(objectName, buffer);
return;
}
if (body && typeof body.on === "function") {
const chunks: Buffer[] = [];
return new Promise<void>((resolve, reject) => {
body.on("data", (chunk: Buffer) => {
chunks.push(chunk);
});
body.on("end", async () => {
try {
const buffer = Buffer.concat(chunks);
if (buffer.length === 0) {
throw new Error("No file data received");
}
await provider.uploadFile(objectName, buffer);
resolve();
} catch (error) {
console.error("Error uploading small file:", error);
reject(error);
} }
});
await provider.uploadFile(objectName, buffer); body.on("error", (error: Error) => {
resolve(); console.error("Error reading upload stream:", error);
} catch (error) {
console.error("Error uploading small file:", error);
reject(error); reject(error);
} });
}); });
}
stream.on("error", (error: Error) => { try {
console.error("Error reading upload stream:", error); const buffer = Buffer.from(body);
reject(error); if (buffer.length === 0) {
}); throw new Error("No file data received");
}); }
await provider.uploadFile(objectName, buffer);
} catch (error) {
throw new Error(`Unsupported request body type: ${typeof body}. Expected stream, buffer, string, or object.`);
}
} }
async download(request: FastifyRequest, reply: FastifyReply) { async download(request: FastifyRequest, reply: FastifyReply) {

View File

@@ -9,6 +9,10 @@ export async function filesystemRoutes(app: FastifyInstance) {
return payload; return payload;
}); });
app.addContentTypeParser("application/json", async (request: FastifyRequest, payload: any) => {
return payload;
});
app.put( app.put(
"/filesystem/upload/:token", "/filesystem/upload/:token",
{ {

View File

@@ -6,7 +6,6 @@ import { z } from "zod";
export async function oidcRoutes(fastify: FastifyInstance) { export async function oidcRoutes(fastify: FastifyInstance) {
const oidcController = new OIDCController(); const oidcController = new OIDCController();
// Get OIDC configuration
fastify.get( fastify.get(
"/config", "/config",
{ {
@@ -27,7 +26,6 @@ export async function oidcRoutes(fastify: FastifyInstance) {
oidcController.getConfig.bind(oidcController) oidcController.getConfig.bind(oidcController)
); );
// Initiate OIDC authorization
fastify.get( fastify.get(
"/authorize", "/authorize",
{ {
@@ -54,7 +52,6 @@ export async function oidcRoutes(fastify: FastifyInstance) {
oidcController.authorize.bind(oidcController) oidcController.authorize.bind(oidcController)
); );
// Handle OIDC callback
fastify.get( fastify.get(
"/callback", "/callback",
{ {

View File

@@ -26,7 +26,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
} }
}; };
// Create reverse share (authenticated)
app.post( app.post(
"/reverse-shares", "/reverse-shares",
{ {
@@ -50,7 +49,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.createReverseShare.bind(reverseShareController) reverseShareController.createReverseShare.bind(reverseShareController)
); );
// List user's reverse shares (authenticated)
app.get( app.get(
"/reverse-shares", "/reverse-shares",
{ {
@@ -72,7 +70,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.listUserReverseShares.bind(reverseShareController) reverseShareController.listUserReverseShares.bind(reverseShareController)
); );
// Get reverse share by ID (authenticated)
app.get( app.get(
"/reverse-shares/:id", "/reverse-shares/:id",
{ {
@@ -98,7 +95,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.getReverseShare.bind(reverseShareController) reverseShareController.getReverseShare.bind(reverseShareController)
); );
// Update reverse share (authenticated)
app.put( app.put(
"/reverse-shares", "/reverse-shares",
{ {
@@ -123,7 +119,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.updateReverseShare.bind(reverseShareController) reverseShareController.updateReverseShare.bind(reverseShareController)
); );
// Update reverse share password (authenticated)
app.put( app.put(
"/reverse-shares/:id/password", "/reverse-shares/:id/password",
{ {
@@ -151,7 +146,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.updatePassword.bind(reverseShareController) reverseShareController.updatePassword.bind(reverseShareController)
); );
// Delete reverse share (authenticated)
app.delete( app.delete(
"/reverse-shares/:id", "/reverse-shares/:id",
{ {
@@ -177,7 +171,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.deleteReverseShare.bind(reverseShareController) reverseShareController.deleteReverseShare.bind(reverseShareController)
); );
// Get reverse share for upload (public)
app.get( app.get(
"/reverse-shares/:id/upload", "/reverse-shares/:id/upload",
{ {
@@ -207,7 +200,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.getReverseShareForUpload.bind(reverseShareController) reverseShareController.getReverseShareForUpload.bind(reverseShareController)
); );
// Get reverse share for upload by alias (public)
app.get( app.get(
"/reverse-shares/alias/:alias/upload", "/reverse-shares/alias/:alias/upload",
{ {
@@ -237,7 +229,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.getReverseShareForUploadByAlias.bind(reverseShareController) reverseShareController.getReverseShareForUploadByAlias.bind(reverseShareController)
); );
// Get presigned URL for file upload (public)
app.post( app.post(
"/reverse-shares/:id/presigned-url", "/reverse-shares/:id/presigned-url",
{ {
@@ -269,7 +260,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.getPresignedUrl.bind(reverseShareController) reverseShareController.getPresignedUrl.bind(reverseShareController)
); );
// Get presigned URL for file upload by alias (public)
app.post( app.post(
"/reverse-shares/alias/:alias/presigned-url", "/reverse-shares/alias/:alias/presigned-url",
{ {
@@ -301,7 +291,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.getPresignedUrlByAlias.bind(reverseShareController) reverseShareController.getPresignedUrlByAlias.bind(reverseShareController)
); );
// Register file upload completion (public)
app.post( app.post(
"/reverse-shares/:id/register-file", "/reverse-shares/:id/register-file",
{ {
@@ -333,7 +322,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.registerFileUpload.bind(reverseShareController) reverseShareController.registerFileUpload.bind(reverseShareController)
); );
// Register file upload completion by alias (public)
app.post( app.post(
"/reverse-shares/alias/:alias/register-file", "/reverse-shares/alias/:alias/register-file",
{ {
@@ -365,7 +353,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.registerFileUploadByAlias.bind(reverseShareController) reverseShareController.registerFileUploadByAlias.bind(reverseShareController)
); );
// Check password (public)
app.post( app.post(
"/reverse-shares/:id/check-password", "/reverse-shares/:id/check-password",
{ {
@@ -394,7 +381,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.checkPassword.bind(reverseShareController) reverseShareController.checkPassword.bind(reverseShareController)
); );
// Download file from reverse share (authenticated)
app.get( app.get(
"/reverse-shares/files/:fileId/download", "/reverse-shares/files/:fileId/download",
{ {
@@ -421,7 +407,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.downloadFile.bind(reverseShareController) reverseShareController.downloadFile.bind(reverseShareController)
); );
// Delete file from reverse share (authenticated)
app.delete( app.delete(
"/reverse-shares/files/:fileId", "/reverse-shares/files/:fileId",
{ {
@@ -447,7 +432,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.deleteFile.bind(reverseShareController) reverseShareController.deleteFile.bind(reverseShareController)
); );
// Create or update reverse share alias (authenticated)
app.post( app.post(
"/reverse-shares/:reverseShareId/alias", "/reverse-shares/:reverseShareId/alias",
{ {
@@ -486,7 +470,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.createOrUpdateAlias.bind(reverseShareController) reverseShareController.createOrUpdateAlias.bind(reverseShareController)
); );
// Activate reverse share (authenticated)
app.patch( app.patch(
"/reverse-shares/:id/activate", "/reverse-shares/:id/activate",
{ {
@@ -512,7 +495,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.activateReverseShare.bind(reverseShareController) reverseShareController.activateReverseShare.bind(reverseShareController)
); );
// Deactivate reverse share (authenticated)
app.patch( app.patch(
"/reverse-shares/:id/deactivate", "/reverse-shares/:id/deactivate",
{ {
@@ -538,7 +520,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.deactivateReverseShare.bind(reverseShareController) reverseShareController.deactivateReverseShare.bind(reverseShareController)
); );
// Update file from reverse share (authenticated)
app.put( app.put(
"/reverse-shares/files/:fileId", "/reverse-shares/files/:fileId",
{ {

View File

@@ -168,7 +168,6 @@ export class ReverseShareService {
throw new Error("Unauthorized to delete this reverse share"); throw new Error("Unauthorized to delete this reverse share");
} }
// Delete all files associated with this reverse share
for (const file of reverseShare.files) { for (const file of reverseShare.files) {
try { try {
await this.fileService.deleteObject(file.objectName); await this.fileService.deleteObject(file.objectName);
@@ -265,7 +264,6 @@ export class ReverseShareService {
} }
} }
// Check file count limit
if (reverseShare.maxFiles) { if (reverseShare.maxFiles) {
const currentFileCount = await this.reverseShareRepository.countFilesByReverseShareId(reverseShareId); const currentFileCount = await this.reverseShareRepository.countFilesByReverseShareId(reverseShareId);
if (currentFileCount >= reverseShare.maxFiles) { if (currentFileCount >= reverseShare.maxFiles) {
@@ -273,12 +271,10 @@ export class ReverseShareService {
} }
} }
// Check file size limit
if (reverseShare.maxFileSize && BigInt(fileData.size) > reverseShare.maxFileSize) { if (reverseShare.maxFileSize && BigInt(fileData.size) > reverseShare.maxFileSize) {
throw new Error("File size exceeds limit"); throw new Error("File size exceeds limit");
} }
// Check allowed file types
if (reverseShare.allowedFileTypes) { if (reverseShare.allowedFileTypes) {
const allowedTypes = reverseShare.allowedFileTypes.split(",").map((type) => type.trim().toLowerCase()); const allowedTypes = reverseShare.allowedFileTypes.split(",").map((type) => type.trim().toLowerCase());
if (!allowedTypes.includes(fileData.extension.toLowerCase())) { if (!allowedTypes.includes(fileData.extension.toLowerCase())) {
@@ -318,7 +314,6 @@ export class ReverseShareService {
} }
} }
// Check file count limit
if (reverseShare.maxFiles) { if (reverseShare.maxFiles) {
const currentFileCount = await this.reverseShareRepository.countFilesByReverseShareId(reverseShare.id); const currentFileCount = await this.reverseShareRepository.countFilesByReverseShareId(reverseShare.id);
if (currentFileCount >= reverseShare.maxFiles) { if (currentFileCount >= reverseShare.maxFiles) {
@@ -326,12 +321,10 @@ export class ReverseShareService {
} }
} }
// Check file size limit
if (reverseShare.maxFileSize && BigInt(fileData.size) > reverseShare.maxFileSize) { if (reverseShare.maxFileSize && BigInt(fileData.size) > reverseShare.maxFileSize) {
throw new Error("File size exceeds limit"); throw new Error("File size exceeds limit");
} }
// Check allowed file types
if (reverseShare.allowedFileTypes) { if (reverseShare.allowedFileTypes) {
const allowedTypes = reverseShare.allowedFileTypes.split(",").map((type) => type.trim().toLowerCase()); const allowedTypes = reverseShare.allowedFileTypes.split(",").map((type) => type.trim().toLowerCase());
if (!allowedTypes.includes(fileData.extension.toLowerCase())) { if (!allowedTypes.includes(fileData.extension.toLowerCase())) {
@@ -372,10 +365,8 @@ export class ReverseShareService {
throw new Error("Unauthorized to delete this file"); throw new Error("Unauthorized to delete this file");
} }
// Delete from storage
await this.fileService.deleteObject(file.objectName); await this.fileService.deleteObject(file.objectName);
// Delete from database
const deletedFile = await this.reverseShareRepository.deleteFile(fileId); const deletedFile = await this.reverseShareRepository.deleteFile(fileId);
return this.formatFileResponse(deletedFile); return this.formatFileResponse(deletedFile);
} }
@@ -473,7 +464,6 @@ export class ReverseShareService {
data: { name?: string; description?: string | null }, data: { name?: string; description?: string | null },
creatorId: string creatorId: string
) { ) {
// Verificar se o arquivo existe e se o usuário tem permissão
const file = await this.reverseShareRepository.findFileById(fileId); const file = await this.reverseShareRepository.findFileById(fileId);
if (!file) { if (!file) {
throw new Error("File not found"); throw new Error("File not found");
@@ -483,13 +473,10 @@ export class ReverseShareService {
throw new Error("Unauthorized to edit this file"); throw new Error("Unauthorized to edit this file");
} }
// Se o nome está sendo atualizado, preservar a extensão original
const updateData = { ...data }; const updateData = { ...data };
if (data.name) { if (data.name) {
const originalExtension = file.extension; const originalExtension = file.extension;
// Remove qualquer extensão que o usuário possa ter digitado
const nameWithoutExtension = data.name.replace(/\.[^/.]+$/, ""); const nameWithoutExtension = data.name.replace(/\.[^/.]+$/, "");
// Adiciona a extensão original (garantindo que tenha o ponto)
const extensionWithDot = originalExtension.startsWith(".") ? originalExtension : `.${originalExtension}`; const extensionWithDot = originalExtension.startsWith(".") ? originalExtension : `.${originalExtension}`;
updateData.name = `${nameWithoutExtension}${extensionWithDot}`; updateData.name = `${nameWithoutExtension}${extensionWithDot}`;
} }

View File

@@ -22,7 +22,20 @@ export class StorageController {
const diskSpace = await this.storageService.getDiskSpace(userId, isAdmin); const diskSpace = await this.storageService.getDiskSpace(userId, isAdmin);
return reply.send(diskSpace); return reply.send(diskSpace);
} catch (error: any) { } catch (error: any) {
return reply.status(500).send({ error: error.message }); console.error("Controller error in getDiskSpace:", error);
if (error.message?.includes("Unable to determine actual disk space")) {
return reply.status(503).send({
error: "Disk space detection unavailable - system configuration issue",
details: "Please check system permissions and available disk utilities",
code: "DISK_SPACE_DETECTION_FAILED",
});
}
return reply.status(500).send({
error: "Failed to retrieve disk space information",
details: error.message || "Unknown error occurred",
});
} }
} }

View File

@@ -1,35 +1,134 @@
import { IS_RUNNING_IN_CONTAINER } from "../../utils/container-detection";
import { ConfigService } from "../config/service"; import { ConfigService } from "../config/service";
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
import { exec } from "child_process"; import { exec } from "child_process";
import fs from "node:fs";
import { promisify } from "util"; import { promisify } from "util";
import fs from 'node:fs';
const execAsync = promisify(exec); const execAsync = promisify(exec);
const prisma = new PrismaClient(); const prisma = new PrismaClient();
export class StorageService { export class StorageService {
private configService = new ConfigService(); private configService = new ConfigService();
private isDockerCached = undefined;
private _hasDockerEnv() { private _ensureNumber(value: number, fallback: number = 0): number {
if (isNaN(value) || !isFinite(value)) {
return fallback;
}
return value;
}
private _safeParseInt(value: string): number {
const parsed = parseInt(value);
return this._ensureNumber(parsed, 0);
}
private async _tryDiskSpaceCommand(command: string): Promise<{ total: number; available: number } | null> {
try { try {
fs.statSync('/.dockerenv'); console.log(`Trying disk space command: ${command}`);
return true; const { stdout, stderr } = await execAsync(command);
} catch {
return false; if (stderr) {
console.warn(`Command stderr: ${stderr}`);
}
console.log(`Command stdout: ${stdout}`);
let total = 0;
let available = 0;
if (process.platform === "win32") {
const lines = stdout.trim().split("\n").slice(1);
for (const line of lines) {
const parts = line.trim().split(/\s+/);
if (parts.length >= 3) {
const [, size, freespace] = parts;
total += this._safeParseInt(size);
available += this._safeParseInt(freespace);
}
}
} else if (process.platform === "darwin") {
const lines = stdout.trim().split("\n");
if (lines.length >= 2) {
const parts = lines[1].trim().split(/\s+/);
if (parts.length >= 4) {
const [, size, , avail] = parts;
total = this._safeParseInt(size) * 1024;
available = this._safeParseInt(avail) * 1024;
}
}
} else {
const lines = stdout.trim().split("\n");
if (lines.length >= 2) {
const parts = lines[1].trim().split(/\s+/);
if (parts.length >= 4) {
const [, size, , avail] = parts;
if (command.includes("-B1")) {
total = this._safeParseInt(size);
available = this._safeParseInt(avail);
} else {
total = this._safeParseInt(size) * 1024;
available = this._safeParseInt(avail) * 1024;
}
}
}
}
if (total > 0 && available >= 0) {
console.log(`Successfully parsed disk space: ${total} bytes total, ${available} bytes available`);
return { total, available };
} else {
console.warn(`Invalid values parsed: total=${total}, available=${available}`);
return null;
}
} catch (error) {
console.warn(`Command failed: ${command}`, error);
return null;
} }
} }
private _hasDockerCGroup() { private async _getDiskSpaceMultiplePaths(): Promise<{ total: number; available: number } | null> {
try { const pathsToTry = IS_RUNNING_IN_CONTAINER
return fs.readFileSync('/proc/self/cgroup', 'utf8').includes('docker'); ? ["/app/server/uploads", "/app/server", "/app", "/"]
} catch { : [".", "./uploads", process.cwd()];
return false;
}
}
private _isDocker() { for (const pathToCheck of pathsToTry) {
return this.isDockerCached ?? (this._hasDockerEnv() || this._hasDockerCGroup()); console.log(`Trying path: ${pathToCheck}`);
if (pathToCheck.includes("uploads")) {
try {
if (!fs.existsSync(pathToCheck)) {
fs.mkdirSync(pathToCheck, { recursive: true });
console.log(`Created directory: ${pathToCheck}`);
}
} catch (err) {
console.warn(`Could not create path ${pathToCheck}:`, err);
continue;
}
}
if (!fs.existsSync(pathToCheck)) {
console.warn(`Path does not exist: ${pathToCheck}`);
continue;
}
const commandsToTry =
process.platform === "win32"
? ["wmic logicaldisk get size,freespace,caption"]
: process.platform === "darwin"
? [`df -k "${pathToCheck}"`, `df "${pathToCheck}"`]
: [`df -B1 "${pathToCheck}"`, `df -k "${pathToCheck}"`, `df "${pathToCheck}"`];
for (const command of commandsToTry) {
const result = await this._tryDiskSpaceCommand(command);
if (result) {
console.log(`✅ Successfully got disk space for path: ${pathToCheck}`);
return result;
}
}
}
return null;
} }
async getDiskSpace( async getDiskSpace(
@@ -43,49 +142,40 @@ export class StorageService {
}> { }> {
try { try {
if (isAdmin) { if (isAdmin) {
const isDocker = this._isDocker(); console.log(`Running in container: ${IS_RUNNING_IN_CONTAINER}`);
const pathToCheck = isDocker ? "/app/server/uploads" : ".";
const command = process.platform === "win32" const diskInfo = await this._getDiskSpaceMultiplePaths();
? "wmic logicaldisk get size,freespace,caption"
: process.platform === "darwin"
? `df -k ${pathToCheck}`
: `df -B1 ${pathToCheck}`;
const { stdout } = await execAsync(command); if (!diskInfo) {
let total = 0; console.error("❌ CRITICAL: Could not determine disk space using any method!");
let available = 0; console.error("This indicates a serious system issue. Please check:");
console.error("1. File system permissions");
console.error("2. Available disk utilities (df, wmic)");
console.error("3. Container/system configuration");
if (process.platform === "win32") { throw new Error("Unable to determine actual disk space - system configuration issue");
const lines = stdout.trim().split("\n").slice(1);
for (const line of lines) {
const [, size, freespace] = line.trim().split(/\s+/);
total += parseInt(size) || 0;
available += parseInt(freespace) || 0;
}
} else if (process.platform === "darwin") {
const lines = stdout.trim().split("\n");
const [, size, , avail] = lines[1].trim().split(/\s+/);
total = parseInt(size) * 1024;
available = parseInt(avail) * 1024;
} else {
const lines = stdout.trim().split("\n");
const [, size, , avail] = lines[1].trim().split(/\s+/);
total = parseInt(size);
available = parseInt(avail);
} }
const { total, available } = diskInfo;
const used = total - available; const used = total - available;
const diskSizeGB = this._ensureNumber(total / (1024 * 1024 * 1024), 0);
const diskUsedGB = this._ensureNumber(used / (1024 * 1024 * 1024), 0);
const diskAvailableGB = this._ensureNumber(available / (1024 * 1024 * 1024), 0);
console.log(
`✅ Real disk space: ${diskSizeGB.toFixed(2)}GB total, ${diskUsedGB.toFixed(2)}GB used, ${diskAvailableGB.toFixed(2)}GB available`
);
return { return {
diskSizeGB: Number((total / (1024 * 1024 * 1024)).toFixed(2)), diskSizeGB: Number(diskSizeGB.toFixed(2)),
diskUsedGB: Number((used / (1024 * 1024 * 1024)).toFixed(2)), diskUsedGB: Number(diskUsedGB.toFixed(2)),
diskAvailableGB: Number((available / (1024 * 1024 * 1024)).toFixed(2)), diskAvailableGB: Number(diskAvailableGB.toFixed(2)),
uploadAllowed: true, uploadAllowed: diskAvailableGB > 0.1, // At least 100MB free
}; };
} else if (userId) { } else if (userId) {
const maxTotalStorage = BigInt(await this.configService.getValue("maxTotalStoragePerUser")); const maxTotalStorage = BigInt(await this.configService.getValue("maxTotalStoragePerUser"));
const maxStorageGB = Number(maxTotalStorage) / (1024 * 1024 * 1024); const maxStorageGB = this._ensureNumber(Number(maxTotalStorage) / (1024 * 1024 * 1024), 10);
const userFiles = await prisma.file.findMany({ const userFiles = await prisma.file.findMany({
where: { userId }, where: { userId },
@@ -94,21 +184,24 @@ export class StorageService {
const totalUsedStorage = userFiles.reduce((acc, file) => acc + file.size, BigInt(0)); const totalUsedStorage = userFiles.reduce((acc, file) => acc + file.size, BigInt(0));
const usedStorageGB = Number(totalUsedStorage) / (1024 * 1024 * 1024); const usedStorageGB = this._ensureNumber(Number(totalUsedStorage) / (1024 * 1024 * 1024), 0);
const availableStorageGB = maxStorageGB - usedStorageGB; const availableStorageGB = this._ensureNumber(maxStorageGB - usedStorageGB, 0);
return { return {
diskSizeGB: maxStorageGB, diskSizeGB: Number(maxStorageGB.toFixed(2)),
diskUsedGB: usedStorageGB, diskUsedGB: Number(usedStorageGB.toFixed(2)),
diskAvailableGB: availableStorageGB, diskAvailableGB: Number(availableStorageGB.toFixed(2)),
uploadAllowed: availableStorageGB > 0, uploadAllowed: availableStorageGB > 0,
}; };
} }
throw new Error("User ID is required for non-admin users"); throw new Error("User ID is required for non-admin users");
} catch (error) { } catch (error) {
console.error("Error getting disk space:", error); console.error("Error getting disk space:", error);
throw new Error("Failed to get disk space information");
throw new Error(
`Failed to get disk space information: ${error instanceof Error ? error.message : String(error)}`
);
} }
} }

View File

@@ -7,6 +7,7 @@ export interface IUserRepository {
findUserByEmail(email: string): Promise<User | null>; findUserByEmail(email: string): Promise<User | null>;
findUserById(id: string): Promise<User | null>; findUserById(id: string): Promise<User | null>;
findUserByUsername(username: string): Promise<User | null>; findUserByUsername(username: string): Promise<User | null>;
findUserByEmailOrUsername(emailOrUsername: string): Promise<User | null>;
listUsers(): Promise<User[]>; listUsers(): Promise<User[]>;
updateUser(data: UpdateUserInput & { password?: string }): Promise<User>; updateUser(data: UpdateUserInput & { password?: string }): Promise<User>;
deleteUser(id: string): Promise<User>; deleteUser(id: string): Promise<User>;
@@ -41,6 +42,14 @@ export class PrismaUserRepository implements IUserRepository {
return prisma.user.findUnique({ where: { username } }); return prisma.user.findUnique({ where: { username } });
} }
async findUserByEmailOrUsername(emailOrUsername: string): Promise<User | null> {
return prisma.user.findFirst({
where: {
OR: [{ email: emailOrUsername }, { username: emailOrUsername }],
},
});
}
async listUsers(): Promise<User[]> { async listUsers(): Promise<User[]> {
return prisma.user.findMany(); return prisma.user.findMany();
} }

View File

@@ -30,7 +30,6 @@ export async function userRoutes(app: FastifyInstance) {
.description("Unauthorized: a valid token is required to access this resource."); .description("Unauthorized: a valid token is required to access this resource.");
} }
} }
// If usersCount is 0, allow the request to proceed without authentication
} catch (err) { } catch (err) {
console.error(err); console.error(err);
return reply.status(500).send({ error: "Internal server error" }).description("Internal server error"); return reply.status(500).send({ error: "Internal server error" }).description("Internal server error");

View File

@@ -5,7 +5,6 @@ import * as readline from "readline";
const prisma = new PrismaClient(); const prisma = new PrismaClient();
// Função para ler entrada do usuário de forma assíncrona
function createReadlineInterface() { function createReadlineInterface() {
return readline.createInterface({ return readline.createInterface({
input: process.stdin, input: process.stdin,
@@ -17,15 +16,12 @@ function question(rl: readline.Interface, query: string): Promise<string> {
return new Promise((resolve) => rl.question(query, resolve)); return new Promise((resolve) => rl.question(query, resolve));
} }
// Função para validar formato de email básico
function isValidEmail(email: string): boolean { function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email); return emailRegex.test(email);
} }
// Função para validar senha com base nas regras do sistema
function isValidPassword(password: string): boolean { function isValidPassword(password: string): boolean {
// Minimum length baseado na configuração padrão do sistema (8 caracteres)
return password.length >= 8; return password.length >= 8;
} }
@@ -38,7 +34,6 @@ async function resetUserPassword() {
console.log("This script allows you to reset a user's password directly from the Docker terminal."); console.log("This script allows you to reset a user's password directly from the Docker terminal.");
console.log("⚠️ WARNING: This bypasses normal security checks. Use only when necessary!\n"); console.log("⚠️ WARNING: This bypasses normal security checks. Use only when necessary!\n");
// Solicitar email do usuário
let email: string; let email: string;
let user: any; let user: any;
@@ -55,7 +50,6 @@ async function resetUserPassword() {
continue; continue;
} }
// Buscar usuário no banco de dados
user = await prisma.user.findUnique({ user = await prisma.user.findUnique({
where: { email: email.toLowerCase() }, where: { email: email.toLowerCase() },
select: { select: {
@@ -83,7 +77,6 @@ async function resetUserPassword() {
break; break;
} }
// Mostrar informações do usuário encontrado
console.log("\n✅ User found:"); console.log("\n✅ User found:");
console.log(` Name: ${user.firstName} ${user.lastName}`); console.log(` Name: ${user.firstName} ${user.lastName}`);
console.log(` Username: ${user.username}`); console.log(` Username: ${user.username}`);
@@ -91,14 +84,12 @@ async function resetUserPassword() {
console.log(` Status: ${user.isActive ? "Active" : "Inactive"}`); console.log(` Status: ${user.isActive ? "Active" : "Inactive"}`);
console.log(` Admin: ${user.isAdmin ? "Yes" : "No"}\n`); console.log(` Admin: ${user.isAdmin ? "Yes" : "No"}\n`);
// Confirmar se deseja prosseguir
const confirm = await question(rl, "Do you want to reset the password for this user? (y/n): "); const confirm = await question(rl, "Do you want to reset the password for this user? (y/n): ");
if (confirm.toLowerCase() !== "y") { if (confirm.toLowerCase() !== "y") {
console.log("\n👋 Operation cancelled."); console.log("\n👋 Operation cancelled.");
return; return;
} }
// Solicitar nova senha
let newPassword: string; let newPassword: string;
while (true) { while (true) {
console.log("\n🔑 Enter new password requirements:"); console.log("\n🔑 Enter new password requirements:");
@@ -126,18 +117,15 @@ async function resetUserPassword() {
break; break;
} }
// Hash da senha usando bcrypt (mesmo método usado pelo sistema)
console.log("\n🔄 Hashing password..."); console.log("\n🔄 Hashing password...");
const hashedPassword = await bcrypt.hash(newPassword, 10); const hashedPassword = await bcrypt.hash(newPassword, 10);
// Atualizar senha no banco de dados
console.log("💾 Updating password in database..."); console.log("💾 Updating password in database...");
await prisma.user.update({ await prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { password: hashedPassword }, data: { password: hashedPassword },
}); });
// Limpar tokens de reset de senha existentes para este usuário
console.log("🧹 Cleaning up existing password reset tokens..."); console.log("🧹 Cleaning up existing password reset tokens...");
await prisma.passwordReset.deleteMany({ await prisma.passwordReset.deleteMany({
where: { where: {
@@ -159,7 +147,6 @@ async function resetUserPassword() {
} }
} }
// Função para listar usuários (funcionalidade auxiliar)
async function listUsers() { async function listUsers() {
try { try {
console.log("\n👥 Registered Users:"); console.log("\n👥 Registered Users:");
@@ -198,7 +185,6 @@ async function listUsers() {
} }
} }
// Main function
async function main() { async function main() {
const args = process.argv.slice(2); const args = process.argv.slice(2);
@@ -227,7 +213,6 @@ async function main() {
await resetUserPassword(); await resetUserPassword();
} }
// Handle process termination
process.on("SIGINT", async () => { process.on("SIGINT", async () => {
console.log("\n\n👋 Goodbye!"); console.log("\n\n👋 Goodbye!");
await prisma.$disconnect(); await prisma.$disconnect();
@@ -239,7 +224,6 @@ process.on("SIGTERM", async () => {
process.exit(0); process.exit(0);
}); });
// Run the script
if (require.main === module) { if (require.main === module) {
main().catch(console.error); main().catch(console.error);
} }

View File

@@ -202,6 +202,8 @@
"login": { "login": {
"welcome": "مرحبا بك", "welcome": "مرحبا بك",
"signInToContinue": "قم بتسجيل الدخول للمتابعة", "signInToContinue": "قم بتسجيل الدخول للمتابعة",
"emailOrUsernameLabel": "البريد الإلكتروني أو اسم المستخدم",
"emailOrUsernamePlaceholder": "أدخل بريدك الإلكتروني أو اسم المستخدم",
"emailLabel": "البريد الإلكتروني", "emailLabel": "البريد الإلكتروني",
"emailPlaceholder": "أدخل بريدك الإلكتروني", "emailPlaceholder": "أدخل بريدك الإلكتروني",
"passwordLabel": "كلمة المرور", "passwordLabel": "كلمة المرور",
@@ -730,7 +732,15 @@
"title": "استخدام التخزين", "title": "استخدام التخزين",
"ariaLabel": "شريط تقدم استخدام التخزين", "ariaLabel": "شريط تقدم استخدام التخزين",
"used": "المستخدمة", "used": "المستخدمة",
"available": "المتاحة" "available": "متاح",
"loading": "جارٍ التحميل...",
"retry": "إعادة المحاولة",
"errors": {
"title": "معلومات التخزين غير متوفرة",
"detectionFailed": "تعذر اكتشاف مساحة القرص. قد يكون هذا بسبب مشاكل في إعدادات النظام أو صلاحيات غير كافية.",
"serverError": "حدث خطأ في الخادم أثناء استرجاع معلومات التخزين. يرجى المحاولة مرة أخرى لاحقاً.",
"unknown": "حدث خطأ غير متوقع أثناء تحميل معلومات التخزين."
}
}, },
"theme": { "theme": {
"toggle": "تبديل السمة", "toggle": "تبديل السمة",
@@ -748,6 +758,7 @@
"uploadProgress": "تقدم الرفع", "uploadProgress": "تقدم الرفع",
"upload": "رفع", "upload": "رفع",
"startUploads": "بدء الرفع", "startUploads": "بدء الرفع",
"retry": "إعادة المحاولة",
"finish": "إنهاء", "finish": "إنهاء",
"success": "تم رفع الملف بنجاح", "success": "تم رفع الملف بنجاح",
"allSuccess": "{count, plural, =1 {تم رفع الملف بنجاح} other {تم رفع # ملف بنجاح}}", "allSuccess": "{count, plural, =1 {تم رفع الملف بنجاح} other {تم رفع # ملف بنجاح}}",
@@ -844,6 +855,7 @@
"passwordLength": "يجب أن تحتوي كلمة المرور على 8 أحرف على الأقل", "passwordLength": "يجب أن تحتوي كلمة المرور على 8 أحرف على الأقل",
"passwordsMatch": "كلمتا المرور غير متطابقتين", "passwordsMatch": "كلمتا المرور غير متطابقتين",
"emailRequired": "البريد الإلكتروني مطلوب", "emailRequired": "البريد الإلكتروني مطلوب",
"emailOrUsernameRequired": "البريد الإلكتروني أو اسم المستخدم مطلوب",
"passwordRequired": "كلمة المرور مطلوبة", "passwordRequired": "كلمة المرور مطلوبة",
"nameRequired": "الاسم مطلوب", "nameRequired": "الاسم مطلوب",
"required": "هذا الحقل مطلوب" "required": "هذا الحقل مطلوب"
@@ -1269,6 +1281,7 @@
"linkInactive": "هذا الرابط غير نشط.", "linkInactive": "هذا الرابط غير نشط.",
"linkExpired": "هذا الرابط منتهي الصلاحية.", "linkExpired": "هذا الرابط منتهي الصلاحية.",
"uploadFailed": "خطأ في رفع الملف", "uploadFailed": "خطأ في رفع الملف",
"retry": "إعادة المحاولة",
"fileTooLarge": "الملف كبير جداً. الحجم الأقصى: {maxSize}", "fileTooLarge": "الملف كبير جداً. الحجم الأقصى: {maxSize}",
"fileTypeNotAllowed": "نوع الملف غير مسموح به. الأنواع المقبولة: {allowedTypes}", "fileTypeNotAllowed": "نوع الملف غير مسموح به. الأنواع المقبولة: {allowedTypes}",
"maxFilesExceeded": "الحد الأقصى المسموح به هو {maxFiles} ملف/ملفات", "maxFilesExceeded": "الحد الأقصى المسموح به هو {maxFiles} ملف/ملفات",

View File

@@ -202,6 +202,8 @@
"login": { "login": {
"welcome": "Willkommen zu", "welcome": "Willkommen zu",
"signInToContinue": "Melden Sie sich an, um fortzufahren", "signInToContinue": "Melden Sie sich an, um fortzufahren",
"emailOrUsernameLabel": "E-Mail-Adresse oder Benutzername",
"emailOrUsernamePlaceholder": "Geben Sie Ihre E-Mail-Adresse oder Benutzernamen ein",
"emailLabel": "E-Mail-Adresse", "emailLabel": "E-Mail-Adresse",
"emailPlaceholder": "Geben Sie Ihre E-Mail-Adresse ein", "emailPlaceholder": "Geben Sie Ihre E-Mail-Adresse ein",
"passwordLabel": "Passwort", "passwordLabel": "Passwort",
@@ -730,7 +732,15 @@
"title": "Speichernutzung", "title": "Speichernutzung",
"ariaLabel": "Fortschrittsbalken der Speichernutzung", "ariaLabel": "Fortschrittsbalken der Speichernutzung",
"used": "genutzt", "used": "genutzt",
"available": "verfügbar" "available": "verfügbar",
"loading": "Wird geladen...",
"retry": "Wiederholen",
"errors": {
"title": "Speicherinformationen nicht verfügbar",
"detectionFailed": "Speicherplatz konnte nicht erkannt werden. Dies kann an Systemkonfigurationsproblemen oder unzureichenden Berechtigungen liegen.",
"serverError": "Serverfehler beim Abrufen der Speicherinformationen. Bitte versuchen Sie es später erneut.",
"unknown": "Ein unerwarteter Fehler ist beim Laden der Speicherinformationen aufgetreten."
}
}, },
"theme": { "theme": {
"toggle": "Design umschalten", "toggle": "Design umschalten",
@@ -748,6 +758,7 @@
"uploadProgress": "Upload-Fortschritt", "uploadProgress": "Upload-Fortschritt",
"upload": "Hochladen", "upload": "Hochladen",
"startUploads": "Uploads Starten", "startUploads": "Uploads Starten",
"retry": "Wiederholen",
"finish": "Beenden", "finish": "Beenden",
"success": "Datei erfolgreich hochgeladen", "success": "Datei erfolgreich hochgeladen",
"allSuccess": "{count, plural, =1 {Datei erfolgreich hochgeladen} other {# Dateien erfolgreich hochgeladen}}", "allSuccess": "{count, plural, =1 {Datei erfolgreich hochgeladen} other {# Dateien erfolgreich hochgeladen}}",
@@ -844,6 +855,7 @@
"passwordLength": "Das Passwort muss mindestens 8 Zeichen lang sein", "passwordLength": "Das Passwort muss mindestens 8 Zeichen lang sein",
"passwordsMatch": "Die Passwörter stimmen nicht überein", "passwordsMatch": "Die Passwörter stimmen nicht überein",
"emailRequired": "E-Mail ist erforderlich", "emailRequired": "E-Mail ist erforderlich",
"emailOrUsernameRequired": "E-Mail oder Benutzername ist erforderlich",
"passwordRequired": "Passwort ist erforderlich", "passwordRequired": "Passwort ist erforderlich",
"nameRequired": "Name ist erforderlich", "nameRequired": "Name ist erforderlich",
"required": "Dieses Feld ist erforderlich" "required": "Dieses Feld ist erforderlich"
@@ -1269,6 +1281,7 @@
"linkInactive": "Dieser Link ist inaktiv.", "linkInactive": "Dieser Link ist inaktiv.",
"linkExpired": "Dieser Link ist abgelaufen.", "linkExpired": "Dieser Link ist abgelaufen.",
"uploadFailed": "Fehler beim Hochladen der Datei", "uploadFailed": "Fehler beim Hochladen der Datei",
"retry": "Wiederholen",
"fileTooLarge": "Datei zu groß. Maximale Größe: {maxSize}", "fileTooLarge": "Datei zu groß. Maximale Größe: {maxSize}",
"fileTypeNotAllowed": "Dateityp nicht erlaubt. Erlaubte Typen: {allowedTypes}", "fileTypeNotAllowed": "Dateityp nicht erlaubt. Erlaubte Typen: {allowedTypes}",
"maxFilesExceeded": "Maximal {maxFiles} Dateien erlaubt", "maxFilesExceeded": "Maximal {maxFiles} Dateien erlaubt",

View File

@@ -202,6 +202,8 @@
"login": { "login": {
"welcome": "Welcome to", "welcome": "Welcome to",
"signInToContinue": "Sign in to continue", "signInToContinue": "Sign in to continue",
"emailOrUsernameLabel": "Email or Username",
"emailOrUsernamePlaceholder": "Enter your email or username",
"emailLabel": "Email Address", "emailLabel": "Email Address",
"emailPlaceholder": "Enter your email", "emailPlaceholder": "Enter your email",
"passwordLabel": "Password", "passwordLabel": "Password",
@@ -788,7 +790,15 @@
"title": "Storage Usage", "title": "Storage Usage",
"ariaLabel": "Storage usage progress bar", "ariaLabel": "Storage usage progress bar",
"used": "used", "used": "used",
"available": "available" "available": "available",
"loading": "Loading...",
"retry": "Retry",
"errors": {
"title": "Storage information unavailable",
"detectionFailed": "Unable to detect disk space. This may be due to system configuration issues or insufficient permissions.",
"serverError": "Server error occurred while retrieving storage information. Please try again later.",
"unknown": "An unexpected error occurred while loading storage information."
}
}, },
"theme": { "theme": {
"toggle": "Toggle theme", "toggle": "Toggle theme",
@@ -806,6 +816,7 @@
"uploadProgress": "Upload progress", "uploadProgress": "Upload progress",
"upload": "Upload", "upload": "Upload",
"startUploads": "Start Uploads", "startUploads": "Start Uploads",
"retry": "Retry",
"finish": "Finish", "finish": "Finish",
"success": "File uploaded successfully", "success": "File uploaded successfully",
"allSuccess": "{count, plural, =1 {File uploaded successfully} other {# files uploaded successfully}}", "allSuccess": "{count, plural, =1 {File uploaded successfully} other {# files uploaded successfully}}",
@@ -901,6 +912,7 @@
"passwordLength": "Password must be at least 8 characters long", "passwordLength": "Password must be at least 8 characters long",
"passwordsMatch": "Passwords must match", "passwordsMatch": "Passwords must match",
"emailRequired": "Email is required", "emailRequired": "Email is required",
"emailOrUsernameRequired": "Email or username is required",
"passwordRequired": "Password is required", "passwordRequired": "Password is required",
"passwordMinLength": "Password must be at least 6 characters", "passwordMinLength": "Password must be at least 6 characters",
"nameRequired": "Name is required", "nameRequired": "Name is required",
@@ -1269,6 +1281,7 @@
"linkInactive": "This link is inactive.", "linkInactive": "This link is inactive.",
"linkExpired": "This link has expired.", "linkExpired": "This link has expired.",
"uploadFailed": "Error uploading file", "uploadFailed": "Error uploading file",
"retry": "Retry",
"fileTooLarge": "File too large. Maximum size: {maxSize}", "fileTooLarge": "File too large. Maximum size: {maxSize}",
"fileTypeNotAllowed": "File type not allowed. Accepted types: {allowedTypes}", "fileTypeNotAllowed": "File type not allowed. Accepted types: {allowedTypes}",
"maxFilesExceeded": "Maximum of {maxFiles} files allowed", "maxFilesExceeded": "Maximum of {maxFiles} files allowed",

View File

@@ -202,6 +202,8 @@
"login": { "login": {
"welcome": "Bienvenido a", "welcome": "Bienvenido a",
"signInToContinue": "Inicia sesión para continuar", "signInToContinue": "Inicia sesión para continuar",
"emailOrUsernameLabel": "Correo electrónico o nombre de usuario",
"emailOrUsernamePlaceholder": "Introduce tu correo electrónico o nombre de usuario",
"emailLabel": "Dirección de correo electrónico", "emailLabel": "Dirección de correo electrónico",
"emailPlaceholder": "Introduce tu correo electrónico", "emailPlaceholder": "Introduce tu correo electrónico",
"passwordLabel": "Contraseña", "passwordLabel": "Contraseña",
@@ -730,7 +732,15 @@
"title": "Uso de almacenamiento", "title": "Uso de almacenamiento",
"ariaLabel": "Barra de progreso del uso de almacenamiento", "ariaLabel": "Barra de progreso del uso de almacenamiento",
"used": "usados", "used": "usados",
"available": "disponibles" "available": "disponible",
"loading": "Cargando...",
"retry": "Reintentar",
"errors": {
"title": "Información de almacenamiento no disponible",
"detectionFailed": "No se pudo detectar el espacio en disco. Esto puede deberse a problemas de configuración del sistema o permisos insuficientes.",
"serverError": "Ocurrió un error del servidor al recuperar la información de almacenamiento. Por favor, inténtelo de nuevo más tarde.",
"unknown": "Ocurrió un error inesperado al cargar la información de almacenamiento."
}
}, },
"theme": { "theme": {
"toggle": "Cambiar tema", "toggle": "Cambiar tema",
@@ -748,6 +758,7 @@
"uploadProgress": "Progreso de la subida", "uploadProgress": "Progreso de la subida",
"upload": "Subir", "upload": "Subir",
"startUploads": "Iniciar Subidas", "startUploads": "Iniciar Subidas",
"retry": "Reintentar",
"finish": "Finalizar", "finish": "Finalizar",
"success": "Archivo subido exitosamente", "success": "Archivo subido exitosamente",
"allSuccess": "{count, plural, =1 {Archivo subido exitosamente} other {# archivos subidos exitosamente}}", "allSuccess": "{count, plural, =1 {Archivo subido exitosamente} other {# archivos subidos exitosamente}}",
@@ -844,6 +855,7 @@
"passwordLength": "La contraseña debe tener al menos 8 caracteres", "passwordLength": "La contraseña debe tener al menos 8 caracteres",
"passwordsMatch": "Las contraseñas no coinciden", "passwordsMatch": "Las contraseñas no coinciden",
"emailRequired": "Se requiere el correo electrónico", "emailRequired": "Se requiere el correo electrónico",
"emailOrUsernameRequired": "Se requiere el correo electrónico o nombre de usuario",
"passwordRequired": "Se requiere la contraseña", "passwordRequired": "Se requiere la contraseña",
"nameRequired": "El nombre es obligatorio", "nameRequired": "El nombre es obligatorio",
"required": "Este campo es obligatorio" "required": "Este campo es obligatorio"
@@ -1269,6 +1281,7 @@
"linkInactive": "Este enlace está inactivo.", "linkInactive": "Este enlace está inactivo.",
"linkExpired": "Este enlace ha expirado.", "linkExpired": "Este enlace ha expirado.",
"uploadFailed": "Error al subir archivo", "uploadFailed": "Error al subir archivo",
"retry": "Reintentar",
"fileTooLarge": "Archivo demasiado grande. Tamaño máximo: {maxSize}", "fileTooLarge": "Archivo demasiado grande. Tamaño máximo: {maxSize}",
"fileTypeNotAllowed": "Tipo de archivo no permitido. Tipos aceptados: {allowedTypes}", "fileTypeNotAllowed": "Tipo de archivo no permitido. Tipos aceptados: {allowedTypes}",
"maxFilesExceeded": "Máximo de {maxFiles} archivos permitidos", "maxFilesExceeded": "Máximo de {maxFiles} archivos permitidos",

View File

@@ -202,6 +202,8 @@
"login": { "login": {
"welcome": "Bienvenue à", "welcome": "Bienvenue à",
"signInToContinue": "Connectez-vous pour continuer", "signInToContinue": "Connectez-vous pour continuer",
"emailOrUsernameLabel": "Email ou Nom d'utilisateur",
"emailOrUsernamePlaceholder": "Entrez votre email ou nom d'utilisateur",
"emailLabel": "Adresse e-mail", "emailLabel": "Adresse e-mail",
"emailPlaceholder": "Entrez votre e-mail", "emailPlaceholder": "Entrez votre e-mail",
"passwordLabel": "Mot de passe", "passwordLabel": "Mot de passe",
@@ -730,7 +732,15 @@
"title": "Utilisation du Stockage", "title": "Utilisation du Stockage",
"ariaLabel": "Barre de progression de l'utilisation du stockage", "ariaLabel": "Barre de progression de l'utilisation du stockage",
"used": "utilisé", "used": "utilisé",
"available": "disponible" "available": "disponible",
"loading": "Chargement...",
"retry": "Réessayer",
"errors": {
"title": "Informations de stockage non disponibles",
"detectionFailed": "Impossible de détecter l'espace disque. Cela peut être dû à des problèmes de configuration système ou à des permissions insuffisantes.",
"serverError": "Une erreur serveur s'est produite lors de la récupération des informations de stockage. Veuillez réessayer plus tard.",
"unknown": "Une erreur inattendue s'est produite lors du chargement des informations de stockage."
}
}, },
"theme": { "theme": {
"toggle": "Changer le thème", "toggle": "Changer le thème",
@@ -748,6 +758,7 @@
"uploadProgress": "Progression du téléchargement", "uploadProgress": "Progression du téléchargement",
"upload": "Télécharger", "upload": "Télécharger",
"startUploads": "Commencer les Téléchargements", "startUploads": "Commencer les Téléchargements",
"retry": "Réessayer",
"finish": "Terminer", "finish": "Terminer",
"success": "Fichier téléchargé avec succès", "success": "Fichier téléchargé avec succès",
"allSuccess": "{count, plural, =1 {Fichier téléchargé avec succès} other {# fichiers téléchargés avec succès}}", "allSuccess": "{count, plural, =1 {Fichier téléchargé avec succès} other {# fichiers téléchargés avec succès}}",
@@ -844,6 +855,7 @@
"passwordLength": "Le mot de passe doit contenir au moins 8 caractères", "passwordLength": "Le mot de passe doit contenir au moins 8 caractères",
"passwordsMatch": "Les mots de passe ne correspondent pas", "passwordsMatch": "Les mots de passe ne correspondent pas",
"emailRequired": "L'email est requis", "emailRequired": "L'email est requis",
"emailOrUsernameRequired": "L'email ou le nom d'utilisateur est requis",
"passwordRequired": "Le mot de passe est requis", "passwordRequired": "Le mot de passe est requis",
"nameRequired": "Nome é obrigatório", "nameRequired": "Nome é obrigatório",
"required": "Este campo é obrigatório" "required": "Este campo é obrigatório"
@@ -1269,6 +1281,7 @@
"linkInactive": "Ce lien est inactif.", "linkInactive": "Ce lien est inactif.",
"linkExpired": "Ce lien a expiré.", "linkExpired": "Ce lien a expiré.",
"uploadFailed": "Erreur lors de l'envoi du fichier", "uploadFailed": "Erreur lors de l'envoi du fichier",
"retry": "Réessayer",
"fileTooLarge": "Fichier trop volumineux. Taille maximale : {maxSize}", "fileTooLarge": "Fichier trop volumineux. Taille maximale : {maxSize}",
"fileTypeNotAllowed": "Type de fichier non autorisé. Types acceptés : {allowedTypes}", "fileTypeNotAllowed": "Type de fichier non autorisé. Types acceptés : {allowedTypes}",
"maxFilesExceeded": "Maximum de {maxFiles} fichiers autorisés", "maxFilesExceeded": "Maximum de {maxFiles} fichiers autorisés",

View File

@@ -202,6 +202,8 @@
"login": { "login": {
"welcome": "स्वागत है में", "welcome": "स्वागत है में",
"signInToContinue": "जारी रखने के लिए साइन इन करें", "signInToContinue": "जारी रखने के लिए साइन इन करें",
"emailOrUsernameLabel": "ईमेल या उपयोगकर्ता नाम",
"emailOrUsernamePlaceholder": "अपना ईमेल या उपयोगकर्ता नाम दर्ज करें",
"emailLabel": "ईमेल पता", "emailLabel": "ईमेल पता",
"emailPlaceholder": "अपना ईमेल दर्ज करें", "emailPlaceholder": "अपना ईमेल दर्ज करें",
"passwordLabel": "पासवर्ड", "passwordLabel": "पासवर्ड",
@@ -730,7 +732,15 @@
"title": "स्टोरेज उपयोग", "title": "स्टोरेज उपयोग",
"ariaLabel": "स्टोरेज उपयोग प्रगति पट्टी", "ariaLabel": "स्टोरेज उपयोग प्रगति पट्टी",
"used": "उपयोग किया गया", "used": "उपयोग किया गया",
"available": "उपलब्ध" "available": "उपलब्ध",
"loading": "लोड हो रहा है...",
"retry": "पुनः प्रयास करें",
"errors": {
"title": "स्टोरेज जानकारी अनुपलब्ध",
"detectionFailed": "डिस्क स्पेस का पता लगाने में असमर्थ। यह सिस्टम कॉन्फ़िगरेशन समस्याओं या अपर्याप्त अनुमतियों के कारण हो सकता है।",
"serverError": "स्टोरेज जानकारी प्राप्त करते समय सर्वर त्रुटि हुई। कृपया बाद में पुनः प्रयास करें।",
"unknown": "स्टोरेज जानकारी लोड करते समय एक अनपेक्षित त्रुटि हुई।"
}
}, },
"theme": { "theme": {
"toggle": "थीम टॉगल करें", "toggle": "थीम टॉगल करें",
@@ -748,6 +758,7 @@
"uploadProgress": "अपलोड प्रगति", "uploadProgress": "अपलोड प्रगति",
"upload": "अपलोड", "upload": "अपलोड",
"startUploads": "अपलोड शुरू करें", "startUploads": "अपलोड शुरू करें",
"retry": "पुनः प्रयास करें",
"finish": "समाप्त", "finish": "समाप्त",
"success": "फ़ाइल सफलतापूर्वक अपलोड की गई", "success": "फ़ाइल सफलतापूर्वक अपलोड की गई",
"allSuccess": "{count, plural, =1 {फ़ाइल सफलतापूर्वक अपलोड की गई} other {# फ़ाइलें सफलतापूर्वक अपलोड की गईं}}", "allSuccess": "{count, plural, =1 {फ़ाइल सफलतापूर्वक अपलोड की गई} other {# फ़ाइलें सफलतापूर्वक अपलोड की गईं}}",
@@ -844,6 +855,7 @@
"passwordLength": "पासवर्ड कम से कम 8 अक्षर का होना चाहिए", "passwordLength": "पासवर्ड कम से कम 8 अक्षर का होना चाहिए",
"passwordsMatch": "पासवर्ड मेल नहीं खाते", "passwordsMatch": "पासवर्ड मेल नहीं खाते",
"emailRequired": "ईमेल आवश्यक है", "emailRequired": "ईमेल आवश्यक है",
"emailOrUsernameRequired": "ईमेल या उपयोगकर्ता नाम आवश्यक है",
"passwordRequired": "पासवर्ड आवश्यक है", "passwordRequired": "पासवर्ड आवश्यक है",
"nameRequired": "नाम आवश्यक है", "nameRequired": "नाम आवश्यक है",
"required": "यह फ़ील्ड आवश्यक है" "required": "यह फ़ील्ड आवश्यक है"
@@ -1269,6 +1281,7 @@
"linkInactive": "यह लिंक निष्क्रिय है।", "linkInactive": "यह लिंक निष्क्रिय है।",
"linkExpired": "यह लिंक समाप्त हो गया है।", "linkExpired": "यह लिंक समाप्त हो गया है।",
"uploadFailed": "फ़ाइल अपलोड करने में त्रुटि", "uploadFailed": "फ़ाइल अपलोड करने में त्रुटि",
"retry": "पुनः प्रयास करें",
"fileTooLarge": "फ़ाइल बहुत बड़ी है। अधिकतम आकार: {maxSize}", "fileTooLarge": "फ़ाइल बहुत बड़ी है। अधिकतम आकार: {maxSize}",
"fileTypeNotAllowed": "फ़ाइल प्रकार अनुमत नहीं है। स्वीकृत प्रकार: {allowedTypes}", "fileTypeNotAllowed": "फ़ाइल प्रकार अनुमत नहीं है। स्वीकृत प्रकार: {allowedTypes}",
"maxFilesExceeded": "अधिकतम {maxFiles} फ़ाइलें अनुमत हैं", "maxFilesExceeded": "अधिकतम {maxFiles} फ़ाइलें अनुमत हैं",

View File

@@ -202,6 +202,8 @@
"login": { "login": {
"welcome": "Benvenuto in", "welcome": "Benvenuto in",
"signInToContinue": "Accedi per continuare", "signInToContinue": "Accedi per continuare",
"emailOrUsernameLabel": "Email o Nome utente",
"emailOrUsernamePlaceholder": "Inserisci la tua email o nome utente",
"emailLabel": "Indirizzo Email", "emailLabel": "Indirizzo Email",
"emailPlaceholder": "Inserisci la tua email", "emailPlaceholder": "Inserisci la tua email",
"passwordLabel": "Parola d'accesso", "passwordLabel": "Parola d'accesso",
@@ -730,7 +732,15 @@
"title": "Utilizzo Archiviazione", "title": "Utilizzo Archiviazione",
"ariaLabel": "Barra di progresso utilizzo archiviazione", "ariaLabel": "Barra di progresso utilizzo archiviazione",
"used": "utilizzato", "used": "utilizzato",
"available": "disponibile" "available": "disponibile",
"loading": "Caricamento...",
"retry": "Riprova",
"errors": {
"title": "Informazioni di archiviazione non disponibili",
"detectionFailed": "Impossibile rilevare lo spazio su disco. Ciò potrebbe essere dovuto a problemi di configurazione del sistema o permessi insufficienti.",
"serverError": "Si è verificato un errore del server durante il recupero delle informazioni di archiviazione. Riprova più tardi.",
"unknown": "Si è verificato un errore imprevisto durante il caricamento delle informazioni di archiviazione."
}
}, },
"theme": { "theme": {
"toggle": "Cambia tema", "toggle": "Cambia tema",
@@ -748,6 +758,7 @@
"uploadProgress": "Progresso caricamento", "uploadProgress": "Progresso caricamento",
"upload": "Carica", "upload": "Carica",
"startUploads": "Inizia Caricamenti", "startUploads": "Inizia Caricamenti",
"retry": "Riprova",
"finish": "Termina", "finish": "Termina",
"success": "File caricato con successo", "success": "File caricato con successo",
"allSuccess": "{count, plural, =1 {File caricato con successo} other {# file caricati con successo}}", "allSuccess": "{count, plural, =1 {File caricato con successo} other {# file caricati con successo}}",
@@ -843,6 +854,7 @@
"passwordLength": "La parola d'accesso deve essere di almeno 8 caratteri", "passwordLength": "La parola d'accesso deve essere di almeno 8 caratteri",
"passwordsMatch": "Le parole d'accesso devono corrispondere", "passwordsMatch": "Le parole d'accesso devono corrispondere",
"emailRequired": "L'indirizzo email è obbligatorio", "emailRequired": "L'indirizzo email è obbligatorio",
"emailOrUsernameRequired": "L'indirizzo email o il nome utente è obbligatorio",
"passwordRequired": "La parola d'accesso è obbligatoria", "passwordRequired": "La parola d'accesso è obbligatoria",
"passwordMinLength": "La password deve contenere almeno 6 caratteri", "passwordMinLength": "La password deve contenere almeno 6 caratteri",
"nameRequired": "Il nome è obbligatorio", "nameRequired": "Il nome è obbligatorio",
@@ -1269,6 +1281,7 @@
"linkInactive": "Questo link è inattivo.", "linkInactive": "Questo link è inattivo.",
"linkExpired": "Questo link è scaduto.", "linkExpired": "Questo link è scaduto.",
"uploadFailed": "Errore durante l'invio del file", "uploadFailed": "Errore durante l'invio del file",
"retry": "Riprova",
"fileTooLarge": "File troppo grande. Dimensione massima: {maxSize}", "fileTooLarge": "File troppo grande. Dimensione massima: {maxSize}",
"fileTypeNotAllowed": "Tipo di file non consentito. Tipi accettati: {allowedTypes}", "fileTypeNotAllowed": "Tipo di file non consentito. Tipi accettati: {allowedTypes}",
"maxFilesExceeded": "Massimo {maxFiles} file consentiti", "maxFilesExceeded": "Massimo {maxFiles} file consentiti",

View File

@@ -202,6 +202,8 @@
"login": { "login": {
"welcome": "ようこそへ", "welcome": "ようこそへ",
"signInToContinue": "続行するにはサインインしてください", "signInToContinue": "続行するにはサインインしてください",
"emailOrUsernameLabel": "メールアドレスまたはユーザー名",
"emailOrUsernamePlaceholder": "メールアドレスまたはユーザー名を入力してください",
"emailLabel": "メールアドレス", "emailLabel": "メールアドレス",
"emailPlaceholder": "メールアドレスを入力してください", "emailPlaceholder": "メールアドレスを入力してください",
"passwordLabel": "パスワード", "passwordLabel": "パスワード",
@@ -730,7 +732,15 @@
"title": "ストレージ使用量", "title": "ストレージ使用量",
"ariaLabel": "ストレージ使用状況のプログレスバー", "ariaLabel": "ストレージ使用状況のプログレスバー",
"used": "使用済み", "used": "使用済み",
"available": "利用可能" "available": "利用可能",
"loading": "読み込み中...",
"retry": "再試行",
"errors": {
"title": "ストレージ情報が利用できません",
"detectionFailed": "ディスク容量を検出できません。システム設定の問題または権限が不足している可能性があります。",
"serverError": "ストレージ情報の取得中にサーバーエラーが発生しました。後でもう一度お試しください。",
"unknown": "ストレージ情報の読み込み中に予期せぬエラーが発生しました。"
}
}, },
"theme": { "theme": {
"toggle": "テーマを切り替える", "toggle": "テーマを切り替える",
@@ -761,6 +771,7 @@
}, },
"multipleTitle": "複数ファイルをアップロード", "multipleTitle": "複数ファイルをアップロード",
"startUploads": "アップロードを開始", "startUploads": "アップロードを開始",
"retry": "再試行",
"allSuccess": "{count, plural, =1 {ファイルがアップロードされました} other {#個のファイルがアップロードされました}}", "allSuccess": "{count, plural, =1 {ファイルがアップロードされました} other {#個のファイルがアップロードされました}}",
"partialSuccess": "{success}個のファイルがアップロードされ、{error}個が失敗しました", "partialSuccess": "{success}個のファイルがアップロードされ、{error}個が失敗しました",
"dragAndDrop": "またはここにファイルをドラッグ&ドロップ" "dragAndDrop": "またはここにファイルをドラッグ&ドロップ"
@@ -844,6 +855,7 @@
"passwordLength": "パスワードは最低8文字必要です", "passwordLength": "パスワードは最低8文字必要です",
"passwordsMatch": "パスワードが一致しません", "passwordsMatch": "パスワードが一致しません",
"emailRequired": "メールアドレスは必須です", "emailRequired": "メールアドレスは必須です",
"emailOrUsernameRequired": "メールアドレスまたはユーザー名は必須です",
"passwordRequired": "パスワードは必須です", "passwordRequired": "パスワードは必須です",
"nameRequired": "名前は必須です", "nameRequired": "名前は必須です",
"required": "このフィールドは必須です" "required": "このフィールドは必須です"
@@ -1269,6 +1281,7 @@
"linkInactive": "このリンクは無効です。", "linkInactive": "このリンクは無効です。",
"linkExpired": "このリンクは期限切れです。", "linkExpired": "このリンクは期限切れです。",
"uploadFailed": "ファイルのアップロードに失敗しました", "uploadFailed": "ファイルのアップロードに失敗しました",
"retry": "再試行",
"fileTooLarge": "ファイルが大きすぎます。最大サイズ: {maxSize}", "fileTooLarge": "ファイルが大きすぎます。最大サイズ: {maxSize}",
"fileTypeNotAllowed": "このファイル形式は許可されていません。許可される形式: {allowedTypes}", "fileTypeNotAllowed": "このファイル形式は許可されていません。許可される形式: {allowedTypes}",
"maxFilesExceeded": "最大 {maxFiles} ファイルまで許可されています", "maxFilesExceeded": "最大 {maxFiles} ファイルまで許可されています",

View File

@@ -202,6 +202,8 @@
"login": { "login": {
"welcome": "에 오신 것을 환영합니다", "welcome": "에 오신 것을 환영합니다",
"signInToContinue": "계속하려면 로그인하세요", "signInToContinue": "계속하려면 로그인하세요",
"emailOrUsernameLabel": "이메일 또는 사용자 이름",
"emailOrUsernamePlaceholder": "이메일 또는 사용자 이름을 입력하세요",
"emailLabel": "이메일 주소", "emailLabel": "이메일 주소",
"emailPlaceholder": "이메일을 입력하세요", "emailPlaceholder": "이메일을 입력하세요",
"passwordLabel": "비밀번호", "passwordLabel": "비밀번호",
@@ -730,7 +732,15 @@
"title": "스토리지 사용량", "title": "스토리지 사용량",
"ariaLabel": "스토리지 사용량 진행 바", "ariaLabel": "스토리지 사용량 진행 바",
"used": "사용됨", "used": "사용됨",
"available": "사용 가능" "available": "사용 가능",
"loading": "로딩 중...",
"retry": "다시 시도",
"errors": {
"title": "스토리지 정보를 사용할 수 없음",
"detectionFailed": "디스크 공간을 감지할 수 없습니다. 시스템 구성 문제 또는 권한이 부족한 것이 원인일 수 있습니다.",
"serverError": "스토리지 정보를 검색하는 중에 서버 오류가 발생했습니다. 나중에 다시 시도해 주세요.",
"unknown": "스토리지 정보를 로드하는 중에 예기치 않은 오류가 발생했습니다."
}
}, },
"theme": { "theme": {
"toggle": "테마 전환", "toggle": "테마 전환",
@@ -748,6 +758,7 @@
"uploadProgress": "업로드 진행률", "uploadProgress": "업로드 진행률",
"upload": "업로드", "upload": "업로드",
"startUploads": "업로드 시작", "startUploads": "업로드 시작",
"retry": "다시 시도",
"finish": "완료", "finish": "완료",
"success": "파일이 성공적으로 업로드되었습니다", "success": "파일이 성공적으로 업로드되었습니다",
"allSuccess": "{count, plural, =1 {파일이 성공적으로 업로드되었습니다} other {# 개 파일이 성공적으로 업로드되었습니다}}", "allSuccess": "{count, plural, =1 {파일이 성공적으로 업로드되었습니다} other {# 개 파일이 성공적으로 업로드되었습니다}}",
@@ -844,6 +855,7 @@
"passwordLength": "비밀번호는 최소 8자 이상이어야 합니다", "passwordLength": "비밀번호는 최소 8자 이상이어야 합니다",
"passwordsMatch": "비밀번호가 일치하지 않습니다", "passwordsMatch": "비밀번호가 일치하지 않습니다",
"emailRequired": "이메일은 필수입니다", "emailRequired": "이메일은 필수입니다",
"emailOrUsernameRequired": "이메일 또는 사용자 이름은 필수입니다",
"passwordRequired": "비밀번호는 필수입니다", "passwordRequired": "비밀번호는 필수입니다",
"nameRequired": "이름은 필수입니다", "nameRequired": "이름은 필수입니다",
"required": "이 필드는 필수입니다" "required": "이 필드는 필수입니다"
@@ -1269,6 +1281,7 @@
"linkInactive": "이 링크는 비활성 상태입니다.", "linkInactive": "이 링크는 비활성 상태입니다.",
"linkExpired": "이 링크는 만료되었습니다.", "linkExpired": "이 링크는 만료되었습니다.",
"uploadFailed": "파일 업로드 오류", "uploadFailed": "파일 업로드 오류",
"retry": "다시 시도",
"fileTooLarge": "파일이 너무 큽니다. 최대 크기: {maxSize}", "fileTooLarge": "파일이 너무 큽니다. 최대 크기: {maxSize}",
"fileTypeNotAllowed": "허용되지 않는 파일 유형입니다. 허용된 유형: {allowedTypes}", "fileTypeNotAllowed": "허용되지 않는 파일 유형입니다. 허용된 유형: {allowedTypes}",
"maxFilesExceeded": "최대 {maxFiles}개의 파일만 허용됩니다", "maxFilesExceeded": "최대 {maxFiles}개의 파일만 허용됩니다",

View File

@@ -202,6 +202,8 @@
"login": { "login": {
"welcome": "Welkom bij", "welcome": "Welkom bij",
"signInToContinue": "Log in om door te gaan", "signInToContinue": "Log in om door te gaan",
"emailOrUsernameLabel": "E-mail of Gebruikersnaam",
"emailOrUsernamePlaceholder": "Voer je e-mail of gebruikersnaam in",
"emailLabel": "E-mailadres", "emailLabel": "E-mailadres",
"emailPlaceholder": "Voer je e-mail in", "emailPlaceholder": "Voer je e-mail in",
"passwordLabel": "Wachtwoord", "passwordLabel": "Wachtwoord",
@@ -730,7 +732,15 @@
"title": "Opslaggebruik", "title": "Opslaggebruik",
"ariaLabel": "Opslaggebruik voortgangsbalk", "ariaLabel": "Opslaggebruik voortgangsbalk",
"used": "gebruikt", "used": "gebruikt",
"available": "beschikbaar" "available": "beschikbaar",
"loading": "Laden...",
"retry": "Opnieuw proberen",
"errors": {
"title": "Opslaginformatie niet beschikbaar",
"detectionFailed": "Kan schijfruimte niet detecteren. Dit kan komen door systeemconfiguratieproblemen of onvoldoende rechten.",
"serverError": "Er is een serverfout opgetreden bij het ophalen van opslaginformatie. Probeer het later opnieuw.",
"unknown": "Er is een onverwachte fout opgetreden bij het laden van opslaginformatie."
}
}, },
"theme": { "theme": {
"toggle": "Thema wisselen", "toggle": "Thema wisselen",
@@ -748,6 +758,7 @@
"uploadProgress": "Upload voortgang", "uploadProgress": "Upload voortgang",
"upload": "Uploaden", "upload": "Uploaden",
"startUploads": "Uploads Starten", "startUploads": "Uploads Starten",
"retry": "Opnieuw Proberen",
"finish": "Voltooien", "finish": "Voltooien",
"success": "Bestand succesvol geüpload", "success": "Bestand succesvol geüpload",
"allSuccess": "{count, plural, =1 {Bestand succesvol geüpload} other {# bestanden succesvol geüpload}}", "allSuccess": "{count, plural, =1 {Bestand succesvol geüpload} other {# bestanden succesvol geüpload}}",
@@ -843,6 +854,7 @@
"passwordLength": "Wachtwoord moet minimaal 8 tekens zijn", "passwordLength": "Wachtwoord moet minimaal 8 tekens zijn",
"passwordsMatch": "Wachtwoorden moeten overeenkomen", "passwordsMatch": "Wachtwoorden moeten overeenkomen",
"emailRequired": "E-mail is verplicht", "emailRequired": "E-mail is verplicht",
"emailOrUsernameRequired": "E-mail of gebruikersnaam is verplicht",
"passwordRequired": "Wachtwoord is verplicht", "passwordRequired": "Wachtwoord is verplicht",
"passwordMinLength": "Wachtwoord moet minimaal 6 tekens bevatten", "passwordMinLength": "Wachtwoord moet minimaal 6 tekens bevatten",
"nameRequired": "Naam is verplicht", "nameRequired": "Naam is verplicht",
@@ -1269,6 +1281,7 @@
"linkInactive": "Deze link is inactief.", "linkInactive": "Deze link is inactief.",
"linkExpired": "Deze link is verlopen.", "linkExpired": "Deze link is verlopen.",
"uploadFailed": "Fout bij uploaden bestand", "uploadFailed": "Fout bij uploaden bestand",
"retry": "Opnieuw Proberen",
"fileTooLarge": "Bestand te groot. Maximum grootte: {maxSize}", "fileTooLarge": "Bestand te groot. Maximum grootte: {maxSize}",
"fileTypeNotAllowed": "Bestandstype niet toegestaan. Toegestane types: {allowedTypes}", "fileTypeNotAllowed": "Bestandstype niet toegestaan. Toegestane types: {allowedTypes}",
"maxFilesExceeded": "Maximum van {maxFiles} bestanden toegestaan", "maxFilesExceeded": "Maximum van {maxFiles} bestanden toegestaan",

View File

@@ -202,6 +202,8 @@
"login": { "login": {
"welcome": "Witaj w", "welcome": "Witaj w",
"signInToContinue": "Zaloguj się, aby kontynuować", "signInToContinue": "Zaloguj się, aby kontynuować",
"emailOrUsernameLabel": "E-mail lub nazwa użytkownika",
"emailOrUsernamePlaceholder": "Wprowadź swój e-mail lub nazwę użytkownika",
"emailLabel": "Adres e-mail", "emailLabel": "Adres e-mail",
"emailPlaceholder": "Wprowadź swój adres e-mail", "emailPlaceholder": "Wprowadź swój adres e-mail",
"passwordLabel": "Hasło", "passwordLabel": "Hasło",
@@ -788,7 +790,15 @@
"title": "Użycie pamięci", "title": "Użycie pamięci",
"ariaLabel": "Pasek postępu użycia pamięci", "ariaLabel": "Pasek postępu użycia pamięci",
"used": "użyte", "used": "użyte",
"available": "dostępne" "available": "dostępne",
"loading": "Ładowanie...",
"retry": "Spróbuj ponownie",
"errors": {
"title": "Informacje o pamięci niedostępne",
"detectionFailed": "Nie można wykryć miejsca na dysku. Może to być spowodowane problemami z konfiguracją systemu lub niewystarczającymi uprawnieniami.",
"serverError": "Wystąpił błąd serwera podczas pobierania informacji o pamięci. Spróbuj ponownie później.",
"unknown": "Wystąpił nieoczekiwany błąd podczas ładowania informacji o pamięci."
}
}, },
"theme": { "theme": {
"toggle": "Przełącz motyw", "toggle": "Przełącz motyw",
@@ -806,6 +816,7 @@
"uploadProgress": "Postęp przesyłania", "uploadProgress": "Postęp przesyłania",
"upload": "Prześlij", "upload": "Prześlij",
"startUploads": "Rozpocznij przesyłanie", "startUploads": "Rozpocznij przesyłanie",
"retry": "Spróbuj Ponownie",
"finish": "Zakończ", "finish": "Zakończ",
"success": "Plik przesłany pomyślnie", "success": "Plik przesłany pomyślnie",
"allSuccess": "{count, plural, =1 {Plik przesłany pomyślnie} other {# plików przesłanych pomyślnie}}", "allSuccess": "{count, plural, =1 {Plik przesłany pomyślnie} other {# plików przesłanych pomyślnie}}",
@@ -901,6 +912,7 @@
"passwordLength": "Hasło musi mieć co najmniej 8 znaków", "passwordLength": "Hasło musi mieć co najmniej 8 znaków",
"passwordsMatch": "Hasła muszą być zgodne", "passwordsMatch": "Hasła muszą być zgodne",
"emailRequired": "E-mail jest wymagany", "emailRequired": "E-mail jest wymagany",
"emailOrUsernameRequired": "E-mail lub nazwa użytkownika jest wymagana",
"passwordRequired": "Hasło jest wymagane", "passwordRequired": "Hasło jest wymagane",
"passwordMinLength": "Hasło musi mieć co najmniej 6 znaków", "passwordMinLength": "Hasło musi mieć co najmniej 6 znaków",
"nameRequired": "Nazwa jest wymagana", "nameRequired": "Nazwa jest wymagana",
@@ -1269,6 +1281,7 @@
"linkInactive": "Ten link jest nieaktywny.", "linkInactive": "Ten link jest nieaktywny.",
"linkExpired": "Ten link wygasł.", "linkExpired": "Ten link wygasł.",
"uploadFailed": "Błąd przesyłania pliku", "uploadFailed": "Błąd przesyłania pliku",
"retry": "Spróbuj Ponownie",
"fileTooLarge": "Plik za duży. Maksymalny rozmiar: {maxSize}", "fileTooLarge": "Plik za duży. Maksymalny rozmiar: {maxSize}",
"fileTypeNotAllowed": "Typ pliku niedozwolony. Akceptowane typy: {allowedTypes}", "fileTypeNotAllowed": "Typ pliku niedozwolony. Akceptowane typy: {allowedTypes}",
"maxFilesExceeded": "Dozwolono maksymalnie {maxFiles} plików", "maxFilesExceeded": "Dozwolono maksymalnie {maxFiles} plików",
@@ -1342,4 +1355,4 @@
} }
} }
} }
} }

View File

@@ -18,17 +18,17 @@
"click": "Clique para" "click": "Clique para"
}, },
"createShare": { "createShare": {
"title": "Criar Compartilhamento", "title": "Criar compartilhamento",
"nameLabel": "Nome do Compartilhamento", "nameLabel": "Nome do compartilhamento",
"descriptionLabel": "Descrição", "descriptionLabel": "Descrição",
"descriptionPlaceholder": "Digite uma descrição (opcional)", "descriptionPlaceholder": "Digite uma descrição (opcional)",
"expirationLabel": "Data de Expiração", "expirationLabel": "Data de expiração",
"expirationPlaceholder": "DD/MM/AAAA HH:MM", "expirationPlaceholder": "DD/MM/AAAA HH:MM",
"maxViewsLabel": "Máximo de Visualizações", "maxViewsLabel": "Máximo de visualizações",
"maxViewsPlaceholder": "Deixe vazio para ilimitado", "maxViewsPlaceholder": "Deixe vazio para ilimitado",
"passwordProtection": "Protegido por Senha", "passwordProtection": "Protegido por Senha",
"passwordLabel": "Senha", "passwordLabel": "Senha",
"create": "Criar Compartilhamento", "create": "Criar compartilhamento",
"success": "Compartilhamento criado com sucesso", "success": "Compartilhamento criado com sucesso",
"error": "Falha ao criar compartilhamento" "error": "Falha ao criar compartilhamento"
}, },
@@ -44,7 +44,7 @@
}, },
"emptyState": { "emptyState": {
"noFiles": "Nenhum arquivo enviado ainda", "noFiles": "Nenhum arquivo enviado ainda",
"uploadFile": "Enviar Arquivo" "uploadFile": "Enviar arquivo"
}, },
"errors": { "errors": {
"invalidCredentials": "E-mail ou senha inválidos", "invalidCredentials": "E-mail ou senha inválidos",
@@ -53,13 +53,13 @@
"unexpectedError": "Ocorreu um erro inesperado. Por favor, tente novamente" "unexpectedError": "Ocorreu um erro inesperado. Por favor, tente novamente"
}, },
"fileActions": { "fileActions": {
"editFile": "Editar Arquivo", "editFile": "Editar arquivo",
"nameLabel": "Nome", "nameLabel": "Nome",
"namePlaceholder": "Digite o novo nome", "namePlaceholder": "Digite o novo nome",
"extension": "Extensão", "extension": "Extensão",
"descriptionLabel": "Descrição", "descriptionLabel": "Descrição",
"descriptionPlaceholder": "Digite a descrição do arquivo", "descriptionPlaceholder": "Digite a descrição do arquivo",
"deleteFile": "Excluir Arquivo", "deleteFile": "Excluir arquivo",
"deleteConfirmation": "Tem certeza que deseja excluir ?", "deleteConfirmation": "Tem certeza que deseja excluir ?",
"deleteWarning": "Esta ação não pode ser desfeita." "deleteWarning": "Esta ação não pode ser desfeita."
}, },
@@ -154,9 +154,9 @@
"bulkActions": { "bulkActions": {
"selected": "{count, plural, =1 {1 arquivo selecionado} other {# arquivos selecionados}}", "selected": "{count, plural, =1 {1 arquivo selecionado} other {# arquivos selecionados}}",
"actions": "Ações", "actions": "Ações",
"download": "Baixar Selecionados", "download": "Baixar selecionados",
"share": "Compartilhar Selecionados", "share": "Compartilhar selecionados",
"delete": "Excluir Selecionados" "delete": "Excluir selecionados"
} }
}, },
"footer": { "footer": {
@@ -175,23 +175,23 @@
"pageTitle": "Esqueceu a Senha" "pageTitle": "Esqueceu a Senha"
}, },
"generateShareLink": { "generateShareLink": {
"generateTitle": "Gerar Link de Compartilhamento", "generateTitle": "Gerar link de compartilhamento",
"updateTitle": "Atualizar Link de Compartilhamento", "updateTitle": "Atualizar link de compartilhamento",
"generateDescription": "Gere um link para compartilhar seus arquivos", "generateDescription": "Gere um link para compartilhar seus arquivos",
"updateDescription": "Atualize o alias deste link de compartilhamento", "updateDescription": "Atualize o alias deste link de compartilhamento",
"aliasPlaceholder": "Digite o alias", "aliasPlaceholder": "Digite o alias",
"linkReady": "Seu link de compartilhamento está pronto:", "linkReady": "Seu link de compartilhamento está pronto:",
"generateButton": "Gerar Link", "generateButton": "Gerar link",
"updateButton": "Atualizar Link", "updateButton": "Atualizar link",
"copyButton": "Copiar Link", "copyButton": "Copiar link",
"success": "Link gerado com sucesso", "success": "Link gerado com sucesso",
"error": "Erro ao gerar link", "error": "Erro ao gerar link",
"copied": "Link copiado para a área de transferência" "copied": "Link copiado para a área de transferência"
}, },
"shareFile": { "shareFile": {
"title": "Compartilhar Arquivo", "title": "Compartilhar arquivo",
"linkTitle": "Gerar Link", "linkTitle": "Gerar link",
"nameLabel": "Nome do Compartilhamento", "nameLabel": "Nome do compartilhamento",
"namePlaceholder": "Digite o nome do compartilhamento", "namePlaceholder": "Digite o nome do compartilhamento",
"descriptionLabel": "Descrição", "descriptionLabel": "Descrição",
"descriptionPlaceholder": "Digite uma descrição (opcional)", "descriptionPlaceholder": "Digite uma descrição (opcional)",
@@ -199,16 +199,16 @@
"expirationPlaceholder": "DD/MM/AAAA HH:MM", "expirationPlaceholder": "DD/MM/AAAA HH:MM",
"maxViewsLabel": "Máximo de Visualizações", "maxViewsLabel": "Máximo de Visualizações",
"maxViewsPlaceholder": "Deixe vazio para ilimitado", "maxViewsPlaceholder": "Deixe vazio para ilimitado",
"passwordProtection": "Protegido por Senha", "passwordProtection": "Protegido por senha",
"passwordLabel": "Senha", "passwordLabel": "Senha",
"passwordPlaceholder": "Digite a senha", "passwordPlaceholder": "Digite a senha",
"linkDescription": "Gere um link personalizado para compartilhar o arquivo", "linkDescription": "Gere um link personalizado para compartilhar o arquivo",
"aliasLabel": "Alias do Link", "aliasLabel": "Alias do link",
"aliasPlaceholder": "Digite um alias personalizado", "aliasPlaceholder": "Digite um alias personalizado",
"linkReady": "Seu link de compartilhamento está pronto:", "linkReady": "Seu link de compartilhamento está pronto:",
"createShare": "Criar Compartilhamento", "createShare": "Criar compartilhamento",
"generateLink": "Gerar Link", "generateLink": "Gerar link",
"copyLink": "Copiar Link" "copyLink": "Copiar link"
}, },
"home": { "home": {
"description": "A alternativa open-source ao WeTransfer. Compartilhe arquivos com segurança, sem rastreamento ou limitações.", "description": "A alternativa open-source ao WeTransfer. Compartilhe arquivos com segurança, sem rastreamento ou limitações.",
@@ -223,7 +223,9 @@
}, },
"login": { "login": {
"welcome": "Bem-vindo ao", "welcome": "Bem-vindo ao",
"signInToContinue": "Entre para continuar", "signInToContinue": "Faça login para continuar",
"emailOrUsernameLabel": "E-mail ou Nome de Usuário",
"emailOrUsernamePlaceholder": "Digite seu e-mail ou nome de usuário",
"emailLabel": "Endereço de E-mail", "emailLabel": "Endereço de E-mail",
"emailPlaceholder": "Digite seu e-mail", "emailPlaceholder": "Digite seu e-mail",
"passwordLabel": "Senha", "passwordLabel": "Senha",
@@ -231,18 +233,18 @@
"signIn": "Entrar", "signIn": "Entrar",
"signingIn": "Entrando...", "signingIn": "Entrando...",
"forgotPassword": "Esqueceu a senha?", "forgotPassword": "Esqueceu a senha?",
"pageTitle": "Entrar", "pageTitle": "Login",
"or": "ou", "or": "ou",
"continueWithSSO": "Continuar com SSO", "continueWithSSO": "Continuar com SSO",
"processing": "Processando autenticação..." "processing": "Processando autenticação..."
}, },
"logo": { "logo": {
"labels": { "labels": {
"appLogo": "Logo do Aplicativo" "appLogo": "Logo do aplicativo"
}, },
"buttons": { "buttons": {
"upload": "Enviar Logo", "upload": "Enviar logo",
"remove": "Remover Logo" "remove": "Remover logo"
}, },
"messages": { "messages": {
"uploadSuccess": "Logo enviado com sucesso", "uploadSuccess": "Logo enviado com sucesso",
@@ -254,11 +256,11 @@
} }
}, },
"navbar": { "navbar": {
"logoAlt": "Logo do Aplicativo", "logoAlt": "Logo do aplicativo",
"profileMenu": "Menu do Perfil", "profileMenu": "Menu do Perfil",
"profile": "Perfil", "profile": "Perfil",
"settings": "Configurações", "settings": "Configurações",
"usersManagement": "Gerenciar Usuários", "usersManagement": "Gerenciar usuários",
"logout": "Sair" "logout": "Sair"
}, },
"navigation": { "navigation": {
@@ -752,7 +754,15 @@
"title": "Uso de Armazenamento", "title": "Uso de Armazenamento",
"ariaLabel": "Barra de progresso do uso de armazenamento", "ariaLabel": "Barra de progresso do uso de armazenamento",
"used": "usado", "used": "usado",
"available": "disponível" "available": "disponível",
"loading": "Carregando...",
"retry": "Tentar novamente",
"errors": {
"title": "Informações de armazenamento indisponíveis",
"detectionFailed": "Não foi possível detectar o espaço em disco. Isso pode ser devido a problemas de configuração do sistema ou permissões insuficientes.",
"serverError": "Ocorreu um erro no servidor ao recuperar as informações de armazenamento. Por favor, tente novamente mais tarde.",
"unknown": "Ocorreu um erro inesperado ao carregar as informações de armazenamento."
}
}, },
"theme": { "theme": {
"toggle": "Alternar tema", "toggle": "Alternar tema",
@@ -770,6 +780,7 @@
"uploadProgress": "Progresso do upload", "uploadProgress": "Progresso do upload",
"upload": "Enviar", "upload": "Enviar",
"startUploads": "Iniciar Uploads", "startUploads": "Iniciar Uploads",
"retry": "Tentar Novamente",
"finish": "Concluir", "finish": "Concluir",
"success": "Arquivo enviado com sucesso", "success": "Arquivo enviado com sucesso",
"allSuccess": "{count, plural, =1 {Arquivo enviado com sucesso} other {# arquivos enviados com sucesso}}", "allSuccess": "{count, plural, =1 {Arquivo enviado com sucesso} other {# arquivos enviados com sucesso}}",
@@ -857,18 +868,15 @@
} }
}, },
"validation": { "validation": {
"invalidEmail": "Endereço de email inválido", "invalidEmail": "Por favor, insira um endereço de e-mail válido",
"passwordMinLength": "A senha deve ter pelo menos 6 caracteres",
"firstNameRequired": "Nome é obrigatório",
"lastNameRequired": "Sobrenome é obrigatório",
"usernameLength": "Nome de usuário deve ter pelo menos 3 caracteres",
"usernameSpaces": "Nome de usuário não pode conter espaços",
"passwordLength": "A senha deve ter pelo menos 8 caracteres", "passwordLength": "A senha deve ter pelo menos 8 caracteres",
"passwordsMatch": "As senhas não coincidem", "passwordsMatch": "As senhas devem coincidir",
"emailRequired": "Email é obrigatório", "emailRequired": "Email é obrigatório",
"emailOrUsernameRequired": "E-mail ou nome de usuário é obrigatório",
"passwordRequired": "Senha é obrigatória", "passwordRequired": "Senha é obrigatória",
"required": "Este campo é obrigatório", "passwordMinLength": "A senha deve ter pelo menos 6 caracteres",
"nameRequired": "Nome é obrigatório" "nameRequired": "Nome é obrigatório",
"required": "Este campo é obrigatório"
}, },
"bulkDownload": { "bulkDownload": {
"title": "Download em Lote", "title": "Download em Lote",
@@ -937,8 +945,8 @@
"noExpiration": "Este compartilhamento nunca expirará e permanecerá acessível indefinidamente.", "noExpiration": "Este compartilhamento nunca expirará e permanecerá acessível indefinidamente.",
"title": "Sobre expiração:" "title": "Sobre expiração:"
}, },
"enableExpiration": "Habilitar Expiração", "enableExpiration": "Habilitar expiração",
"title": "Configurações de Expiração do Compartilhamento", "title": "Configurações de expiração do compartilhamento",
"subtitle": "Configurar quando este compartilhamento expirará", "subtitle": "Configurar quando este compartilhamento expirará",
"validation": { "validation": {
"dateMustBeFuture": "A data de expiração deve estar no futuro", "dateMustBeFuture": "A data de expiração deve estar no futuro",
@@ -949,7 +957,7 @@
"updateFailed": "Falha ao atualizar configurações de expiração" "updateFailed": "Falha ao atualizar configurações de expiração"
}, },
"expires": "Expira:", "expires": "Expira:",
"expirationDate": "Data de Expiração" "expirationDate": "Data de expiração"
}, },
"auth": { "auth": {
"errors": { "errors": {
@@ -961,10 +969,10 @@
} }
}, },
"reverseShares": { "reverseShares": {
"pageTitle": "Receber Arquivos", "pageTitle": "Receber arquivos",
"search": { "search": {
"title": "Gerenciar Links de Recebimento", "title": "Gerenciar links de recebimento",
"createButton": "Criar Link", "createButton": "Criar link",
"placeholder": "Buscar links de recebimento...", "placeholder": "Buscar links de recebimento...",
"results": "Encontrados {filtered} de {total} links de recebimento" "results": "Encontrados {filtered} de {total} links de recebimento"
}, },
@@ -974,13 +982,13 @@
"status": "status", "status": "status",
"access": "acesso", "access": "acesso",
"description": "Descrição", "description": "Descrição",
"pageLayout": "Layout da Página", "pageLayout": "Layout da página",
"security": "Segurança & Status", "security": "Segurança & Status",
"limits": "Limites", "limits": "Limites",
"maxFiles": "Máximo de Arquivos", "maxFiles": "Máximo de arquivos",
"maxFileSize": "Tamanho Máximo", "maxFileSize": "Tamanho máximo",
"allowedTypes": "Tipos Permitidos", "allowedTypes": "Tipos permitidos",
"filesReceived": "Arquivos Recebidos", "filesReceived": "Arquivos recebidos",
"fileLimit": "Limite de Arquivos", "fileLimit": "Limite de Arquivos",
"noLimit": "Sem limite", "noLimit": "Sem limite",
"noLinkCreated": "Nenhum link criado", "noLinkCreated": "Nenhum link criado",
@@ -1269,6 +1277,7 @@
"linkInactive": "Este link está inativo.", "linkInactive": "Este link está inativo.",
"linkExpired": "Este link expirou.", "linkExpired": "Este link expirou.",
"uploadFailed": "Erro ao enviar arquivo", "uploadFailed": "Erro ao enviar arquivo",
"retry": "Tentar Novamente",
"fileTooLarge": "Arquivo muito grande. Tamanho máximo: {maxSize}", "fileTooLarge": "Arquivo muito grande. Tamanho máximo: {maxSize}",
"fileTypeNotAllowed": "Tipo de arquivo não permitido. Tipos aceitos: {allowedTypes}", "fileTypeNotAllowed": "Tipo de arquivo não permitido. Tipos aceitos: {allowedTypes}",
"maxFilesExceeded": "Máximo de {maxFiles} arquivos permitidos", "maxFilesExceeded": "Máximo de {maxFiles} arquivos permitidos",

View File

@@ -202,6 +202,8 @@
"login": { "login": {
"welcome": "Добро пожаловать в", "welcome": "Добро пожаловать в",
"signInToContinue": "Войдите, чтобы продолжить", "signInToContinue": "Войдите, чтобы продолжить",
"emailOrUsernameLabel": "Электронная почта или имя пользователя",
"emailOrUsernamePlaceholder": "Введите электронную почту или имя пользователя",
"emailLabel": "Адрес электронной почты", "emailLabel": "Адрес электронной почты",
"emailPlaceholder": "Введите вашу электронную почту", "emailPlaceholder": "Введите вашу электронную почту",
"passwordLabel": "Пароль", "passwordLabel": "Пароль",
@@ -730,7 +732,15 @@
"title": "Использование хранилища", "title": "Использование хранилища",
"ariaLabel": "Индикатор использования хранилища", "ariaLabel": "Индикатор использования хранилища",
"used": "Использовано", "used": "Использовано",
"available": "Доступно" "available": "Доступно",
"loading": "Загрузка...",
"retry": "Повторить",
"errors": {
"title": "Информация о хранилище недоступна",
"detectionFailed": "Не удалось определить свободное место на диске. Это может быть связано с проблемами конфигурации системы или недостаточными правами доступа.",
"serverError": "Произошла ошибка сервера при получении информации о хранилище. Пожалуйста, повторите попытку позже.",
"unknown": "Произошла непредвиденная ошибка при загрузке информации о хранилище."
}
}, },
"theme": { "theme": {
"toggle": "Переключить тему", "toggle": "Переключить тему",
@@ -748,6 +758,7 @@
"uploadProgress": "Прогресс загрузки", "uploadProgress": "Прогресс загрузки",
"upload": "Загрузить", "upload": "Загрузить",
"startUploads": "Начать Загрузку", "startUploads": "Начать Загрузку",
"retry": "Повторить",
"finish": "Завершить", "finish": "Завершить",
"success": "Файл успешно загружен", "success": "Файл успешно загружен",
"allSuccess": "{count, plural, =1 {Файл успешно загружен} other {# файлов успешно загружено}}", "allSuccess": "{count, plural, =1 {Файл успешно загружен} other {# файлов успешно загружено}}",
@@ -844,6 +855,7 @@
"passwordLength": "Пароль должен содержать не менее 8 символов", "passwordLength": "Пароль должен содержать не менее 8 символов",
"passwordsMatch": "Пароли не совпадают", "passwordsMatch": "Пароли не совпадают",
"emailRequired": "Требуется электронная почта", "emailRequired": "Требуется электронная почта",
"emailOrUsernameRequired": "Электронная почта или имя пользователя обязательно",
"passwordRequired": "Требуется пароль", "passwordRequired": "Требуется пароль",
"nameRequired": "Требуется имя", "nameRequired": "Требуется имя",
"required": "Это поле обязательно" "required": "Это поле обязательно"
@@ -1269,6 +1281,7 @@
"linkInactive": "Эта ссылка неактивна.", "linkInactive": "Эта ссылка неактивна.",
"linkExpired": "Срок действия этой ссылки истек.", "linkExpired": "Срок действия этой ссылки истек.",
"uploadFailed": "Ошибка при загрузке файла", "uploadFailed": "Ошибка при загрузке файла",
"retry": "Повторить",
"fileTooLarge": "Файл слишком большой. Максимальный размер: {maxSize}", "fileTooLarge": "Файл слишком большой. Максимальный размер: {maxSize}",
"fileTypeNotAllowed": "Тип файла не разрешен. Разрешенные типы: {allowedTypes}", "fileTypeNotAllowed": "Тип файла не разрешен. Разрешенные типы: {allowedTypes}",
"maxFilesExceeded": "Максимально разрешено {maxFiles} файлов", "maxFilesExceeded": "Максимально разрешено {maxFiles} файлов",

View File

@@ -202,6 +202,8 @@
"login": { "login": {
"welcome": "Hoş geldiniz'e", "welcome": "Hoş geldiniz'e",
"signInToContinue": "Devam etmek için oturum açın", "signInToContinue": "Devam etmek için oturum açın",
"emailOrUsernameLabel": "E-posta veya Kullanıcı Adı",
"emailOrUsernamePlaceholder": "E-posta veya kullanıcı adınızı girin",
"emailLabel": "E-posta Adresi", "emailLabel": "E-posta Adresi",
"emailPlaceholder": "E-posta adresinizi girin", "emailPlaceholder": "E-posta adresinizi girin",
"passwordLabel": "Şifre", "passwordLabel": "Şifre",
@@ -730,7 +732,15 @@
"title": "Depolama Kullanımı", "title": "Depolama Kullanımı",
"ariaLabel": "Depolama kullanım ilerleme çubuğu", "ariaLabel": "Depolama kullanım ilerleme çubuğu",
"used": "kullanıldı", "used": "kullanıldı",
"available": "kullanılabilir" "available": "kullanılabilir",
"loading": "Yükleniyor...",
"retry": "Tekrar Dene",
"errors": {
"title": "Depolama bilgisi kullanılamıyor",
"detectionFailed": "Disk alanı tespit edilemiyor. Bu, sistem yapılandırma sorunlarından veya yetersiz izinlerden kaynaklanıyor olabilir.",
"serverError": "Depolama bilgisi alınırken sunucu hatası oluştu. Lütfen daha sonra tekrar deneyin.",
"unknown": "Depolama bilgisi yüklenirken beklenmeyen bir hata oluştu."
}
}, },
"theme": { "theme": {
"toggle": "Temayı değiştir", "toggle": "Temayı değiştir",
@@ -748,6 +758,7 @@
"uploadProgress": "Yükleme ilerlemesi", "uploadProgress": "Yükleme ilerlemesi",
"upload": "Yükle", "upload": "Yükle",
"startUploads": "Yüklemeleri Başlat", "startUploads": "Yüklemeleri Başlat",
"retry": "Tekrar Dene",
"finish": "Bitir", "finish": "Bitir",
"success": "Dosya başarıyla yüklendi", "success": "Dosya başarıyla yüklendi",
"allSuccess": "{count, plural, =1 {Dosya başarıyla yüklendi} other {# dosya başarıyla yüklendi}}", "allSuccess": "{count, plural, =1 {Dosya başarıyla yüklendi} other {# dosya başarıyla yüklendi}}",
@@ -844,6 +855,7 @@
"passwordLength": "Şifre en az 8 karakter olmalıdır", "passwordLength": "Şifre en az 8 karakter olmalıdır",
"passwordsMatch": "Şifreler eşleşmiyor", "passwordsMatch": "Şifreler eşleşmiyor",
"emailRequired": "E-posta gerekli", "emailRequired": "E-posta gerekli",
"emailOrUsernameRequired": "E-posta veya kullanıcı adı gereklidir",
"passwordRequired": "Şifre gerekli", "passwordRequired": "Şifre gerekli",
"nameRequired": "İsim gereklidir", "nameRequired": "İsim gereklidir",
"required": "Bu alan zorunludur" "required": "Bu alan zorunludur"
@@ -1269,6 +1281,7 @@
"linkInactive": "Bu bağlantı pasif durumda.", "linkInactive": "Bu bağlantı pasif durumda.",
"linkExpired": "Bu bağlantının süresi doldu.", "linkExpired": "Bu bağlantının süresi doldu.",
"uploadFailed": "Dosya yüklenirken hata oluştu", "uploadFailed": "Dosya yüklenirken hata oluştu",
"retry": "Tekrar Dene",
"fileTooLarge": "Dosya çok büyük. Maksimum boyut: {maxSize}", "fileTooLarge": "Dosya çok büyük. Maksimum boyut: {maxSize}",
"fileTypeNotAllowed": "Dosya türüne izin verilmiyor. İzin verilen türler: {allowedTypes}", "fileTypeNotAllowed": "Dosya türüne izin verilmiyor. İzin verilen türler: {allowedTypes}",
"maxFilesExceeded": "Maksimum {maxFiles} dosyaya izin veriliyor", "maxFilesExceeded": "Maksimum {maxFiles} dosyaya izin veriliyor",

View File

@@ -202,6 +202,8 @@
"login": { "login": {
"welcome": "欢迎您", "welcome": "欢迎您",
"signInToContinue": "请登录以继续", "signInToContinue": "请登录以继续",
"emailOrUsernameLabel": "电子邮件或用户名",
"emailOrUsernamePlaceholder": "请输入您的电子邮件或用户名",
"emailLabel": "电子邮件地址", "emailLabel": "电子邮件地址",
"emailPlaceholder": "请输入您的电子邮件", "emailPlaceholder": "请输入您的电子邮件",
"passwordLabel": "密码", "passwordLabel": "密码",
@@ -730,7 +732,15 @@
"title": "存储使用情况", "title": "存储使用情况",
"ariaLabel": "存储使用进度条", "ariaLabel": "存储使用进度条",
"used": "已使用:", "used": "已使用:",
"available": "可用" "available": "可用",
"loading": "加载中...",
"retry": "重试",
"errors": {
"title": "存储信息不可用",
"detectionFailed": "无法检测磁盘空间。这可能是由于系统配置问题或权限不足。",
"serverError": "检索存储信息时发生服务器错误。请稍后重试。",
"unknown": "加载存储信息时发生意外错误。"
}
}, },
"theme": { "theme": {
"toggle": "切换主题", "toggle": "切换主题",
@@ -748,6 +758,7 @@
"uploadProgress": "上传进度", "uploadProgress": "上传进度",
"upload": "上传", "upload": "上传",
"startUploads": "开始上传", "startUploads": "开始上传",
"retry": "重试",
"finish": "完成", "finish": "完成",
"success": "文件上传成功", "success": "文件上传成功",
"allSuccess": "{count, plural, =1 {文件上传成功} other {# 个文件上传成功}}", "allSuccess": "{count, plural, =1 {文件上传成功} other {# 个文件上传成功}}",
@@ -844,6 +855,7 @@
"passwordLength": "密码至少需要8个字符", "passwordLength": "密码至少需要8个字符",
"passwordsMatch": "密码不匹配", "passwordsMatch": "密码不匹配",
"emailRequired": "电子邮件为必填项", "emailRequired": "电子邮件为必填项",
"emailOrUsernameRequired": "电子邮件或用户名是必填项",
"passwordRequired": "密码为必填项", "passwordRequired": "密码为必填项",
"nameRequired": "名称为必填项", "nameRequired": "名称为必填项",
"required": "此字段为必填项" "required": "此字段为必填项"
@@ -1269,6 +1281,7 @@
"linkInactive": "此链接已停用。", "linkInactive": "此链接已停用。",
"linkExpired": "此链接已过期。", "linkExpired": "此链接已过期。",
"uploadFailed": "上传文件时出错", "uploadFailed": "上传文件时出错",
"retry": "重试",
"fileTooLarge": "文件太大。最大大小:{maxSize}", "fileTooLarge": "文件太大。最大大小:{maxSize}",
"fileTypeNotAllowed": "不允许的文件类型。允许的类型:{allowedTypes}", "fileTypeNotAllowed": "不允许的文件类型。允许的类型:{allowedTypes}",
"maxFilesExceeded": "最多允许 {maxFiles} 个文件", "maxFilesExceeded": "最多允许 {maxFiles} 个文件",

View File

@@ -156,13 +156,11 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
const { file } = fileWithProgress; const { file } = fileWithProgress;
try { try {
// Start upload
updateFileStatus(index, { updateFileStatus(index, {
status: FILE_STATUS.UPLOADING, status: FILE_STATUS.UPLOADING,
progress: UPLOAD_PROGRESS.INITIAL, progress: UPLOAD_PROGRESS.INITIAL,
}); });
// Generate object name and get presigned URL
const objectName = generateObjectName(file.name); const objectName = generateObjectName(file.name);
const presignedResponse = await getPresignedUrlForUploadByAlias( const presignedResponse = await getPresignedUrlForUploadByAlias(
alias, alias,
@@ -170,16 +168,12 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
password ? { password } : undefined password ? { password } : undefined
); );
// Upload to storage
await uploadFileToStorage(file, presignedResponse.data.url); await uploadFileToStorage(file, presignedResponse.data.url);
// Update progress
updateFileStatus(index, { progress: UPLOAD_PROGRESS.COMPLETE }); updateFileStatus(index, { progress: UPLOAD_PROGRESS.COMPLETE });
// Register file upload
await registerUploadedFile(file, objectName); await registerUploadedFile(file, objectName);
// Mark as successful
updateFileStatus(index, { status: FILE_STATUS.SUCCESS }); updateFileStatus(index, { status: FILE_STATUS.SUCCESS });
} catch (error: any) { } catch (error: any) {
console.error("Upload error:", error); console.error("Upload error:", error);
@@ -243,7 +237,6 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
); );
const hasSuccessfulUploads = files.some((file) => file.status === FILE_STATUS.SUCCESS); const hasSuccessfulUploads = files.some((file) => file.status === FILE_STATUS.SUCCESS);
// Call onUploadSuccess when all files are processed and there are successful uploads
useEffect(() => { useEffect(() => {
if (allFilesProcessed && hasSuccessfulUploads && files.length > 0) { if (allFilesProcessed && hasSuccessfulUploads && files.length > 0) {
onUploadSuccess?.(); onUploadSuccess?.();
@@ -266,7 +259,6 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
}; };
const renderFileRestrictions = () => { const renderFileRestrictions = () => {
// Calculate remaining files that can be uploaded
const calculateRemainingFiles = (): number => { const calculateRemainingFiles = (): number => {
if (!reverseShare.maxFiles) return 0; if (!reverseShare.maxFiles) return 0;
const currentTotal = reverseShare.currentFileCount + files.length; const currentTotal = reverseShare.currentFileCount + files.length;
@@ -339,13 +331,34 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
<IconX className="h-4 w-4" /> <IconX className="h-4 w-4" />
</Button> </Button>
)} )}
{fileWithProgress.status === FILE_STATUS.ERROR && (
<div className="flex gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => {
setFiles((prev) =>
prev.map((file, i) =>
i === index ? { ...file, status: FILE_STATUS.PENDING, error: undefined } : file
)
);
}}
disabled={isUploading}
title={t("reverseShares.upload.retry")}
>
<IconUpload className="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" onClick={() => removeFile(index)} disabled={isUploading}>
<IconX className="h-4 w-4" />
</Button>
</div>
)}
</div> </div>
</div> </div>
); );
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* File Drop Zone */}
<div {...getRootProps()} className={getDropzoneStyles()}> <div {...getRootProps()} className={getDropzoneStyles()}>
<input {...getInputProps()} /> <input {...getInputProps()} />
<IconUpload className="mx-auto h-12 w-12 text-gray-400 mb-4" /> <IconUpload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
@@ -357,7 +370,6 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
{renderFileRestrictions()} {renderFileRestrictions()}
</div> </div>
{/* File List */}
{files.length > 0 && ( {files.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<h4 className="font-medium text-gray-900 dark:text-white">{t("reverseShares.upload.fileList.title")}</h4> <h4 className="font-medium text-gray-900 dark:text-white">{t("reverseShares.upload.fileList.title")}</h4>
@@ -365,7 +377,6 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
</div> </div>
)} )}
{/* User Information */}
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-1 gap-4"> <div className="grid grid-cols-1 gap-4">
<div className="space-y-2"> <div className="space-y-2">
@@ -409,14 +420,12 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
</div> </div>
</div> </div>
{/* Upload Button */}
<Button onClick={handleUpload} disabled={!canUpload} className="w-full text-white" size="lg" variant="default"> <Button onClick={handleUpload} disabled={!canUpload} className="w-full text-white" size="lg" variant="default">
{isUploading {isUploading
? t("reverseShares.upload.form.uploading") ? t("reverseShares.upload.form.uploading")
: t("reverseShares.upload.form.uploadButton", { count: files.length })} : t("reverseShares.upload.form.uploadButton", { count: files.length })}
</Button> </Button>
{/* Success Message */}
{allFilesProcessed && hasSuccessfulUploads && ( {allFilesProcessed && hasSuccessfulUploads && (
<div className="text-center p-4 bg-green-50 dark:bg-green-950/20 rounded-lg"> <div className="text-center p-4 bg-green-50 dark:bg-green-950/20 rounded-lg">
<p className="text-green-800 dark:text-green-200 font-medium">{t("reverseShares.upload.success.title")}</p> <p className="text-green-800 dark:text-green-200 font-medium">{t("reverseShares.upload.success.title")}</p>

View File

@@ -66,7 +66,6 @@ export function WeTransferStatusMessage({
}: WeTransferStatusMessageProps) { }: WeTransferStatusMessageProps) {
const t = useTranslations(); const t = useTranslations();
// Map message types to variants
const getVariant = (): "success" | "warning" | "error" | "info" | "neutral" => { const getVariant = (): "success" | "warning" | "error" | "info" | "neutral" => {
switch (type) { switch (type) {
case MESSAGE_TYPES.SUCCESS: case MESSAGE_TYPES.SUCCESS:

View File

@@ -12,13 +12,11 @@ import { FileUploadSection } from "./file-upload-section";
import { WeTransferStatusMessage } from "./shared/status-message"; import { WeTransferStatusMessage } from "./shared/status-message";
import { TransparentFooter } from "./transparent-footer"; import { TransparentFooter } from "./transparent-footer";
// Função para escolher uma imagem aleatória
const getRandomBackgroundImage = (): string => { const getRandomBackgroundImage = (): string => {
const randomIndex = Math.floor(Math.random() * BACKGROUND_IMAGES.length); const randomIndex = Math.floor(Math.random() * BACKGROUND_IMAGES.length);
return BACKGROUND_IMAGES[randomIndex]; return BACKGROUND_IMAGES[randomIndex];
}; };
// Hook para gerenciar a imagem de background
const useBackgroundImage = () => { const useBackgroundImage = () => {
const [selectedImage, setSelectedImage] = useState<string>(""); const [selectedImage, setSelectedImage] = useState<string>("");
const [imageLoaded, setImageLoaded] = useState(false); const [imageLoaded, setImageLoaded] = useState(false);
@@ -42,7 +40,6 @@ const useBackgroundImage = () => {
return { selectedImage, imageLoaded }; return { selectedImage, imageLoaded };
}; };
// Componente para controles do header
const HeaderControls = () => ( const HeaderControls = () => (
<div className="absolute top-4 right-4 md:top-6 md:right-6 z-40 flex items-center gap-2"> <div className="absolute top-4 right-4 md:top-6 md:right-6 z-40 flex items-center gap-2">
<div className="bg-white/10 dark:bg-black/20 backdrop-blur-xs border border-white/20 dark:border-white/10 rounded-lg p-1"> <div className="bg-white/10 dark:bg-black/20 backdrop-blur-xs border border-white/20 dark:border-white/10 rounded-lg p-1">
@@ -54,7 +51,6 @@ const HeaderControls = () => (
</div> </div>
); );
// Componente para o fundo com imagem
const BackgroundLayer = ({ selectedImage, imageLoaded }: { selectedImage: string; imageLoaded: boolean }) => ( const BackgroundLayer = ({ selectedImage, imageLoaded }: { selectedImage: string; imageLoaded: boolean }) => (
<> <>
<div className="absolute inset-0 z-0 bg-background" /> <div className="absolute inset-0 z-0 bg-background" />
@@ -162,18 +158,15 @@ export function WeTransferLayout({
<BackgroundLayer selectedImage={selectedImage} imageLoaded={imageLoaded} /> <BackgroundLayer selectedImage={selectedImage} imageLoaded={imageLoaded} />
<HeaderControls /> <HeaderControls />
{/* Loading indicator */}
{!imageLoaded && ( {!imageLoaded && (
<div className="absolute inset-0 z-30 flex items-center justify-center"> <div className="absolute inset-0 z-30 flex items-center justify-center">
<div className="animate-pulse text-white/70 text-sm">{t("reverseShares.upload.layout.loading")}</div> <div className="animate-pulse text-white/70 text-sm">{t("reverseShares.upload.layout.loading")}</div>
</div> </div>
)} )}
{/* Main Content */}
<div className="relative z-30 min-h-screen flex items-center justify-start p-4 md:p-8 lg:p-12 xl:p-16"> <div className="relative z-30 min-h-screen flex items-center justify-start p-4 md:p-8 lg:p-12 xl:p-16">
<div className="w-full max-w-md lg:max-w-lg xl:max-w-xl"> <div className="w-full max-w-md lg:max-w-lg xl:max-w-xl">
<div className="bg-white dark:bg-black rounded-2xl shadow-2xl p-6 md:p-8 backdrop-blur-sm border border-white/20"> <div className="bg-white dark:bg-black rounded-2xl shadow-2xl p-6 md:p-8 backdrop-blur-sm border border-white/20">
{/* Header */}
<div className="text-left mb-6 md:mb-8"> <div className="text-left mb-6 md:mb-8">
<h1 className="text-xl md:text-2xl lg:text-3xl font-bold text-gray-900 dark:text-white mb-2"> <h1 className="text-xl md:text-2xl lg:text-3xl font-bold text-gray-900 dark:text-white mb-2">
{reverseShare?.name || t("reverseShares.upload.layout.defaultTitle")} {reverseShare?.name || t("reverseShares.upload.layout.defaultTitle")}
@@ -183,7 +176,6 @@ export function WeTransferLayout({
)} )}
</div> </div>
{/* Upload Section */}
{getUploadSectionContent()} {getUploadSectionContent()}
</div> </div>
</div> </div>

View File

@@ -1,4 +1,3 @@
// HTTP Status Constants
export const HTTP_STATUS = { export const HTTP_STATUS = {
UNAUTHORIZED: 401, UNAUTHORIZED: 401,
FORBIDDEN: 403, FORBIDDEN: 403,
@@ -6,13 +5,11 @@ export const HTTP_STATUS = {
GONE: 410, GONE: 410,
} as const; } as const;
// Error Messages
export const ERROR_MESSAGES = { export const ERROR_MESSAGES = {
PASSWORD_REQUIRED: "Password required", PASSWORD_REQUIRED: "Password required",
INVALID_PASSWORD: "Invalid password", INVALID_PASSWORD: "Invalid password",
} as const; } as const;
// Error types
export type ErrorType = "inactive" | "notFound" | "expired" | "generic" | null; export type ErrorType = "inactive" | "notFound" | "expired" | "generic" | null;
export const STATUS_VARIANTS = { export const STATUS_VARIANTS = {

View File

@@ -17,7 +17,6 @@ export function useReverseShareUpload({ alias }: UseReverseShareUploadProps) {
const router = useRouter(); const router = useRouter();
const t = useTranslations(); const t = useTranslations();
// States
const [reverseShare, setReverseShare] = useState<ReverseShareInfo | null>(null); const [reverseShare, setReverseShare] = useState<ReverseShareInfo | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false); const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false);
@@ -25,7 +24,6 @@ export function useReverseShareUpload({ alias }: UseReverseShareUploadProps) {
const [hasUploadedSuccessfully, setHasUploadedSuccessfully] = useState(false); const [hasUploadedSuccessfully, setHasUploadedSuccessfully] = useState(false);
const [error, setError] = useState<{ type: ErrorType }>({ type: null }); const [error, setError] = useState<{ type: ErrorType }>({ type: null });
// Utility functions
const redirectToHome = () => router.push("/"); const redirectToHome = () => router.push("/");
const checkIfMaxFilesReached = (reverseShareData: ReverseShareInfo): boolean => { const checkIfMaxFilesReached = (reverseShareData: ReverseShareInfo): boolean => {
@@ -109,23 +107,19 @@ export function useReverseShareUpload({ alias }: UseReverseShareUploadProps) {
} }
}, [alias]); }, [alias]);
// Computed values
const isMaxFilesReached = reverseShare ? checkIfMaxFilesReached(reverseShare) : false; const isMaxFilesReached = reverseShare ? checkIfMaxFilesReached(reverseShare) : false;
const isWeTransferLayout = reverseShare?.pageLayout === "WETRANSFER"; const isWeTransferLayout = reverseShare?.pageLayout === "WETRANSFER";
const hasError = error.type !== null || (!reverseShare && !isLoading && !isPasswordModalOpen); const hasError = error.type !== null || (!reverseShare && !isLoading && !isPasswordModalOpen);
// Error state booleans for backward compatibility
const isLinkInactive = error.type === "inactive"; const isLinkInactive = error.type === "inactive";
const isLinkNotFound = error.type === "notFound" || (!reverseShare && !isLoading && !isPasswordModalOpen); const isLinkNotFound = error.type === "notFound" || (!reverseShare && !isLoading && !isPasswordModalOpen);
const isLinkExpired = error.type === "expired"; const isLinkExpired = error.type === "expired";
return { return {
// Data
reverseShare, reverseShare,
currentPassword, currentPassword,
alias, alias,
// States
isLoading, isLoading,
isPasswordModalOpen, isPasswordModalOpen,
hasUploadedSuccessfully, hasUploadedSuccessfully,
@@ -134,12 +128,10 @@ export function useReverseShareUpload({ alias }: UseReverseShareUploadProps) {
isWeTransferLayout, isWeTransferLayout,
hasError, hasError,
// Error states (for backward compatibility)
isLinkInactive, isLinkInactive,
isLinkNotFound, isLinkNotFound,
isLinkExpired, isLinkExpired,
// Actions
handlePasswordSubmit, handlePasswordSubmit,
handlePasswordModalClose, handlePasswordModalClose,
handleUploadSuccess, handleUploadSuccess,

View File

@@ -27,19 +27,16 @@ export default function ReverseShareUploadPage() {
handleUploadSuccess, handleUploadSuccess,
} = useReverseShareUpload({ alias: shareAlias }); } = useReverseShareUpload({ alias: shareAlias });
// Loading state
if (isLoading) { if (isLoading) {
return <LoadingScreen />; return <LoadingScreen />;
} }
// Password required state
if (isPasswordModalOpen) { if (isPasswordModalOpen) {
return ( return (
<PasswordModal isOpen={isPasswordModalOpen} onSubmit={handlePasswordSubmit} onClose={handlePasswordModalClose} /> <PasswordModal isOpen={isPasswordModalOpen} onSubmit={handlePasswordSubmit} onClose={handlePasswordModalClose} />
); );
} }
// Error states or missing data - always use DefaultLayout for simplicity
if (hasError) { if (hasError) {
return ( return (
<DefaultLayout <DefaultLayout
@@ -56,7 +53,6 @@ export default function ReverseShareUploadPage() {
); );
} }
// Render appropriate layout for normal states
if (isWeTransferLayout) { if (isWeTransferLayout) {
return ( return (
<WeTransferLayout <WeTransferLayout

View File

@@ -37,20 +37,12 @@ import { ReverseShare } from "../hooks/use-reverse-shares";
import { FileSizeInput } from "./file-size-input"; import { FileSizeInput } from "./file-size-input";
import { FileTypesTagsInput } from "./file-types-tags-input"; import { FileTypesTagsInput } from "./file-types-tags-input";
// Constants
const DEFAULT_VALUES = { const DEFAULT_VALUES = {
EMPTY_STRING: "", EMPTY_STRING: "",
ZERO_STRING: "0", ZERO_STRING: "0",
PAGE_LAYOUT: "DEFAULT" as const, PAGE_LAYOUT: "DEFAULT" as const,
} as const; } as const;
const FORM_SECTIONS = {
BASIC_INFO: "basicInfo",
EXPIRATION: "expiration",
FILE_LIMITS: "fileLimits",
PASSWORD: "password",
} as const;
interface EditReverseShareFormData { interface EditReverseShareFormData {
name: string; name: string;
description?: string; description?: string;
@@ -168,7 +160,6 @@ export function EditReverseShareModal({
); );
} }
// Helper functions
function getFormDefaultValues(): EditReverseShareFormData { function getFormDefaultValues(): EditReverseShareFormData {
return { return {
name: DEFAULT_VALUES.EMPTY_STRING, name: DEFAULT_VALUES.EMPTY_STRING,
@@ -224,19 +215,16 @@ function buildUpdatePayload(data: EditReverseShareFormData, id: string): UpdateR
isActive: data.isActive, isActive: data.isActive,
}; };
// Add optional fields
if (data.description?.trim()) { if (data.description?.trim()) {
payload.description = data.description.trim(); payload.description = data.description.trim();
} }
// Handle expiration
if (data.hasExpiration && data.expiration) { if (data.hasExpiration && data.expiration) {
payload.expiration = new Date(data.expiration).toISOString(); payload.expiration = new Date(data.expiration).toISOString();
} else if (!data.hasExpiration) { } else if (!data.hasExpiration) {
payload.expiration = undefined; payload.expiration = undefined;
} }
// Handle file limits
if (data.hasFileLimits) { if (data.hasFileLimits) {
payload.maxFiles = parsePositiveIntegerOrNull(data.maxFiles); payload.maxFiles = parsePositiveIntegerOrNull(data.maxFiles);
payload.maxFileSize = parsePositiveIntegerOrNull(data.maxFileSize); payload.maxFileSize = parsePositiveIntegerOrNull(data.maxFileSize);
@@ -245,10 +233,8 @@ function buildUpdatePayload(data: EditReverseShareFormData, id: string): UpdateR
payload.maxFileSize = null; payload.maxFileSize = null;
} }
// Handle allowed file types
payload.allowedFileTypes = data.allowedFileTypes?.trim() || null; payload.allowedFileTypes = data.allowedFileTypes?.trim() || null;
// Handle password
if (data.hasPassword && data.password) { if (data.hasPassword && data.password) {
payload.password = data.password; payload.password = data.password;
} else if (!data.hasPassword) { } else if (!data.hasPassword) {
@@ -289,7 +275,6 @@ function createLimitCheckbox(id: string, checked: boolean, onChange: (checked: b
); );
} }
// Section Components
function BasicInfoSection({ form, t }: { form: any; t: any }) { function BasicInfoSection({ form, t }: { form: any; t: any }) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -442,7 +427,6 @@ function FileLimitsSection({
{hasFileLimits && ( {hasFileLimits && (
<div className="space-y-4"> <div className="space-y-4">
{/* Max Files Field */}
<FormField <FormField
control={form.control} control={form.control}
name="maxFiles" name="maxFiles"
@@ -479,7 +463,6 @@ function FileLimitsSection({
)} )}
/> />
{/* Max File Size Field */}
<FormField <FormField
control={form.control} control={form.control}
name="maxFileSize" name="maxFileSize"
@@ -515,7 +498,6 @@ function FileLimitsSection({
)} )}
/> />
{/* Allowed File Types Field */}
<FormField <FormField
control={form.control} control={form.control}
name="allowedFileTypes" name="allowedFileTypes"

View File

@@ -34,10 +34,8 @@ function bytesToHumanReadable(bytes: string): { value: string; unit: Unit } {
const value = numBytes / multiplier; const value = numBytes / multiplier;
if (value >= 1) { if (value >= 1) {
// Se o valor é >= 1 nesta unidade, usar ela const rounded = Math.round(value * 100) / 100;
const rounded = Math.round(value * 100) / 100; // Arredonda para 2 casas decimais
// Se está muito próximo de um inteiro, usar inteiro
if (Math.abs(rounded - Math.round(rounded)) < 0.01) { if (Math.abs(rounded - Math.round(rounded)) < 0.01) {
return { value: Math.round(rounded).toString(), unit }; return { value: Math.round(rounded).toString(), unit };
} else { } else {
@@ -46,7 +44,6 @@ function bytesToHumanReadable(bytes: string): { value: string; unit: Unit } {
} }
} }
// Fallback para MB
const mbValue = numBytes / UNIT_MULTIPLIERS.MB; const mbValue = numBytes / UNIT_MULTIPLIERS.MB;
return { value: mbValue.toFixed(2), unit: "MB" as Unit }; return { value: mbValue.toFixed(2), unit: "MB" as Unit };
} }
@@ -92,7 +89,6 @@ export function FileSizeInput({ value, onChange, disabled = false, error, placeh
}; };
const handleUnitChange = (newUnit: Unit) => { const handleUnitChange = (newUnit: Unit) => {
// Ignorar valores vazios ou inválidos que podem vir do Select quando atualizado programaticamente
if (!newUnit || !["MB", "GB", "TB", "PB"].includes(newUnit)) { if (!newUnit || !["MB", "GB", "TB", "PB"].includes(newUnit)) {
return; return;
} }

View File

@@ -24,7 +24,6 @@ export function FileTypesTagsInput({
const [inputValue, setInputValue] = useState(""); const [inputValue, setInputValue] = useState("");
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
// Separadores: Enter, espaço, vírgula, pipe, traço
if (e.key === "Enter" || e.key === " " || e.key === "," || e.key === "|" || e.key === "-") { if (e.key === "Enter" || e.key === " " || e.key === "," || e.key === "|" || e.key === "-") {
e.preventDefault(); e.preventDefault();
addTag(); addTag();
@@ -32,7 +31,6 @@ export function FileTypesTagsInput({
e.preventDefault(); e.preventDefault();
removeTag(value.length - 1); removeTag(value.length - 1);
} else if (e.key === ".") { } else if (e.key === ".") {
// Impedir pontos
e.preventDefault(); e.preventDefault();
} }
}; };
@@ -51,7 +49,6 @@ export function FileTypesTagsInput({
}; };
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// Remover pontos e forçar minúsculo
const sanitizedValue = e.target.value.replace(/\./g, "").toLowerCase(); const sanitizedValue = e.target.value.replace(/\./g, "").toLowerCase();
setInputValue(sanitizedValue); setInputValue(sanitizedValue);
}; };

View File

@@ -54,14 +54,11 @@ export function GenerateAliasModal({
}, },
}); });
// Atualiza o valor padrão quando o reverseShare muda
React.useEffect(() => { React.useEffect(() => {
if (reverseShare) { if (reverseShare) {
if (reverseShare.alias?.alias) { if (reverseShare.alias?.alias) {
// Se já tem alias, usa o existente
form.setValue("alias", reverseShare.alias.alias); form.setValue("alias", reverseShare.alias.alias);
} else { } else {
// Se não tem alias, gera um novo valor padrão
form.setValue("alias", generateDefaultAlias()); form.setValue("alias", generateDefaultAlias());
} }
} }
@@ -75,7 +72,6 @@ export function GenerateAliasModal({
await onCreateAlias(reverseShare.id, data.alias); await onCreateAlias(reverseShare.id, data.alias);
onClose(); onClose();
} catch (error) { } catch (error) {
// Erro já é tratado no hook
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@@ -150,17 +146,15 @@ export function GenerateAliasModal({
className="max-w-full" className="max-w-full"
{...field} {...field}
onChange={(e) => { onChange={(e) => {
// Converter espaços em hífens e remover caracteres não permitidos
const value = e.target.value const value = e.target.value
.replace(/\s+/g, "-") // espaços viram hífens .replace(/\s+/g, "-")
.replace(/[^a-zA-Z0-9-_]/g, "") // remove caracteres não permitidos .replace(/[^a-zA-Z0-9-_]/g, "")
.toLowerCase(); // converte para minúsculo .toLowerCase();
field.onChange(value); field.onChange(value);
}} }}
/> />
</FormControl> </FormControl>
{/* Preview do link */}
{field.value && field.value.length >= 3 && ( {field.value && field.value.length >= 3 && (
<div className="mt-2 p-2 bg-primary/5 border border-primary/20 rounded-md overflow-hidden"> <div className="mt-2 p-2 bg-primary/5 border border-primary/20 rounded-md overflow-hidden">
<label className="text-xs text-muted-foreground block mb-1"> <label className="text-xs text-muted-foreground block mb-1">

View File

@@ -25,7 +25,6 @@ import { getFileIcon } from "@/utils/file-icons";
import { ReverseShare } from "../hooks/use-reverse-shares"; import { ReverseShare } from "../hooks/use-reverse-shares";
import { ReverseShareFilePreviewModal } from "./reverse-share-file-preview-modal"; import { ReverseShareFilePreviewModal } from "./reverse-share-file-preview-modal";
// Types
interface EditingState { interface EditingState {
fileId: string; fileId: string;
field: string; field: string;
@@ -36,7 +35,6 @@ interface HoverState {
field: string; field: string;
} }
// Custom Hooks
function useFileEdit() { function useFileEdit() {
const [editingFile, setEditingFile] = useState<EditingState | null>(null); const [editingFile, setEditingFile] = useState<EditingState | null>(null);
const [editValue, setEditValue] = useState(""); const [editValue, setEditValue] = useState("");
@@ -74,7 +72,6 @@ function useFileEdit() {
}; };
} }
// Utility Functions
const formatFileSize = (sizeString: string) => { const formatFileSize = (sizeString: string) => {
const sizeInBytes = parseInt(sizeString); const sizeInBytes = parseInt(sizeString);
if (sizeInBytes === 0) return "0 B"; if (sizeInBytes === 0) return "0 B";
@@ -122,7 +119,6 @@ const getSenderInitials = (file: ReverseShareFile) => {
return "?"; return "?";
}; };
// Components
interface EditableFieldProps { interface EditableFieldProps {
file: ReverseShareFile; file: ReverseShareFile;
field: "name" | "description"; field: "name" | "description";

View File

@@ -103,7 +103,6 @@ export function ReverseShareCard({
const { field } = editingField; const { field } = editingField;
let processedValue: string | number | null | boolean = editValue; let processedValue: string | number | null | boolean = editValue;
// Processar valores específicos
if (field === "isActive") { if (field === "isActive") {
processedValue = editValue === "true"; processedValue = editValue === "true";
} }

View File

@@ -18,7 +18,6 @@ import type {
UpdateReverseShareBody, UpdateReverseShareBody,
} from "@/http/endpoints/reverse-shares/types"; } from "@/http/endpoints/reverse-shares/types";
// Tipo baseado na resposta da API
export type ReverseShare = ListUserReverseSharesResult["data"]["reverseShares"][0]; export type ReverseShare = ListUserReverseSharesResult["data"]["reverseShares"][0];
export function useReverseShares() { export function useReverseShares() {
@@ -62,7 +61,6 @@ export function useReverseShares() {
setReverseShares(sortedReverseShares); setReverseShares(sortedReverseShares);
// Atualiza o reverseShare específico que está sendo visualizado
const updatedReverseShare = allReverseShares.find((rs) => rs.id === id); const updatedReverseShare = allReverseShares.find((rs) => rs.id === id);
if (updatedReverseShare) { if (updatedReverseShare) {
if (reverseShareToViewFiles && reverseShareToViewFiles.id === id) { if (reverseShareToViewFiles && reverseShareToViewFiles.id === id) {
@@ -83,13 +81,11 @@ export function useReverseShares() {
const response = await createReverseShare(data); const response = await createReverseShare(data);
const newReverseShare = response.data.reverseShare; const newReverseShare = response.data.reverseShare;
// Adiciona ao estado local
setReverseShares((prev) => [newReverseShare as ReverseShare, ...prev]); setReverseShares((prev) => [newReverseShare as ReverseShare, ...prev]);
toast.success(t("reverseShares.messages.createSuccess")); toast.success(t("reverseShares.messages.createSuccess"));
setIsCreateModalOpen(false); setIsCreateModalOpen(false);
// Automaticamente abre o modal de alias para o reverse share criado
setReverseShareToGenerateLink(newReverseShare as ReverseShare); setReverseShareToGenerateLink(newReverseShare as ReverseShare);
return newReverseShare; return newReverseShare;
@@ -113,7 +109,6 @@ export function useReverseShares() {
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
// Atualiza o estado local
setReverseShares((prev) => setReverseShares((prev) =>
prev.map((rs) => prev.map((rs) =>
rs.id === reverseShareId rs.id === reverseShareId
@@ -125,7 +120,6 @@ export function useReverseShares() {
) )
); );
// Atualiza o reverseShare que está sendo visualizado no modal de detalhes
if (reverseShareToViewDetails && reverseShareToViewDetails.id === reverseShareId) { if (reverseShareToViewDetails && reverseShareToViewDetails.id === reverseShareId) {
setReverseShareToViewDetails({ setReverseShareToViewDetails({
...reverseShareToViewDetails, ...reverseShareToViewDetails,
@@ -145,7 +139,6 @@ export function useReverseShares() {
try { try {
await deleteReverseShare(reverseShare.id); await deleteReverseShare(reverseShare.id);
// Remove do estado local
setReverseShares((prev) => prev.filter((rs) => rs.id !== reverseShare.id)); setReverseShares((prev) => prev.filter((rs) => rs.id !== reverseShare.id));
toast.success(t("reverseShares.messages.deleteSuccess")); toast.success(t("reverseShares.messages.deleteSuccess"));
@@ -163,7 +156,6 @@ export function useReverseShares() {
const response = await updateReverseShare(data); const response = await updateReverseShare(data);
const updatedReverseShare = response.data.reverseShare; const updatedReverseShare = response.data.reverseShare;
// Atualiza o estado local
setReverseShares((prev) => setReverseShares((prev) =>
prev.map((rs) => (rs.id === data.id ? ({ ...rs, ...updatedReverseShare } as ReverseShare) : rs)) prev.map((rs) => (rs.id === data.id ? ({ ...rs, ...updatedReverseShare } as ReverseShare) : rs))
); );
@@ -186,12 +178,10 @@ export function useReverseShares() {
const response = await updateReverseSharePassword(id, payload); const response = await updateReverseSharePassword(id, payload);
const updatedReverseShare = response.data.reverseShare; const updatedReverseShare = response.data.reverseShare;
// Atualiza o estado local
setReverseShares((prev) => setReverseShares((prev) =>
prev.map((rs) => (rs.id === id ? ({ ...rs, ...updatedReverseShare } as ReverseShare) : rs)) prev.map((rs) => (rs.id === id ? ({ ...rs, ...updatedReverseShare } as ReverseShare) : rs))
); );
// Atualiza o reverseShare que está sendo visualizado no modal de detalhes
if (reverseShareToViewDetails && reverseShareToViewDetails.id === id) { if (reverseShareToViewDetails && reverseShareToViewDetails.id === id) {
setReverseShareToViewDetails({ ...reverseShareToViewDetails, ...updatedReverseShare } as ReverseShare); setReverseShareToViewDetails({ ...reverseShareToViewDetails, ...updatedReverseShare } as ReverseShare);
} }
@@ -208,12 +198,10 @@ export function useReverseShares() {
const response = await updateReverseShare(payload); const response = await updateReverseShare(payload);
const updatedReverseShare = response.data.reverseShare; const updatedReverseShare = response.data.reverseShare;
// Atualiza o estado local
setReverseShares((prev) => setReverseShares((prev) =>
prev.map((rs) => (rs.id === id ? ({ ...rs, ...updatedReverseShare } as ReverseShare) : rs)) prev.map((rs) => (rs.id === id ? ({ ...rs, ...updatedReverseShare } as ReverseShare) : rs))
); );
// Atualiza o reverseShare que está sendo visualizado no modal de detalhes
if (reverseShareToViewDetails && reverseShareToViewDetails.id === id) { if (reverseShareToViewDetails && reverseShareToViewDetails.id === id) {
setReverseShareToViewDetails({ ...reverseShareToViewDetails, ...updatedReverseShare } as ReverseShare); setReverseShareToViewDetails({ ...reverseShareToViewDetails, ...updatedReverseShare } as ReverseShare);
} }
@@ -232,12 +220,10 @@ export function useReverseShares() {
const response = await updateReverseShare(payload); const response = await updateReverseShare(payload);
const updatedReverseShare = response.data.reverseShare; const updatedReverseShare = response.data.reverseShare;
// Atualiza o estado local
setReverseShares((prev) => setReverseShares((prev) =>
prev.map((rs) => (rs.id === id ? ({ ...rs, ...updatedReverseShare } as ReverseShare) : rs)) prev.map((rs) => (rs.id === id ? ({ ...rs, ...updatedReverseShare } as ReverseShare) : rs))
); );
// Atualiza o reverseShare que está sendo visualizado no modal de detalhes
if (reverseShareToViewDetails && reverseShareToViewDetails.id === id) { if (reverseShareToViewDetails && reverseShareToViewDetails.id === id) {
setReverseShareToViewDetails({ ...reverseShareToViewDetails, ...updatedReverseShare } as ReverseShare); setReverseShareToViewDetails({ ...reverseShareToViewDetails, ...updatedReverseShare } as ReverseShare);
} }
@@ -256,7 +242,6 @@ export function useReverseShares() {
loadReverseShares(); loadReverseShares();
}, []); }, []);
// Sincroniza o reverseShareToViewDetails com a lista atualizada
useEffect(() => { useEffect(() => {
if (reverseShareToViewDetails) { if (reverseShareToViewDetails) {
const updatedReverseShare = reverseShares.find((rs) => rs.id === reverseShareToViewDetails.id); const updatedReverseShare = reverseShares.find((rs) => rs.id === reverseShareToViewDetails.id);
@@ -266,7 +251,6 @@ export function useReverseShares() {
} }
}, [reverseShares, reverseShareToViewDetails?.id]); }, [reverseShares, reverseShareToViewDetails?.id]);
// Sincroniza o reverseShareToViewFiles com a lista atualizada
useEffect(() => { useEffect(() => {
if (reverseShareToViewFiles) { if (reverseShareToViewFiles) {
const updatedReverseShare = reverseShares.find((rs) => rs.id === reverseShareToViewFiles.id); const updatedReverseShare = reverseShares.find((rs) => rs.id === reverseShareToViewFiles.id);

View File

@@ -2,12 +2,12 @@ import { useState } from "react";
import { IconDownload, IconEye } from "@tabler/icons-react"; import { IconDownload, IconEye } from "@tabler/icons-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { FilePreviewModal } from "@/components/modals/file-preview-modal";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { getFileIcon } from "@/utils/file-icons"; import { getFileIcon } from "@/utils/file-icons";
import { formatFileSize } from "@/utils/format-file-size"; import { formatFileSize } from "@/utils/format-file-size";
import { ShareFilesTableProps } from "../types"; import { ShareFilesTableProps } from "../types";
import { ShareFilePreviewModal } from "./share-file-preview-modal";
export function ShareFilesTable({ files, onDownload }: ShareFilesTableProps) { export function ShareFilesTable({ files, onDownload }: ShareFilesTableProps) {
const t = useTranslations(); const t = useTranslations();
@@ -99,14 +99,7 @@ export function ShareFilesTable({ files, onDownload }: ShareFilesTableProps) {
</Table> </Table>
</div> </div>
{selectedFile && ( {selectedFile && <FilePreviewModal isOpen={isPreviewOpen} onClose={handleClosePreview} file={selectedFile} />}
<ShareFilePreviewModal
isOpen={isPreviewOpen}
onClose={handleClosePreview}
file={selectedFile}
onDownload={onDownload}
/>
)}
</div> </div>
); );
} }

View File

@@ -1,320 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { IconDownload } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { CustomAudioPlayer } from "@/components/audio/custom-audio-player";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { getDownloadUrl } from "@/http/endpoints";
import { getFileIcon } from "@/utils/file-icons";
interface ShareFilePreviewModalProps {
isOpen: boolean;
onClose: () => void;
file: {
name: string;
objectName: string;
type?: string;
};
onDownload: (objectName: string, fileName: string) => void;
}
export function ShareFilePreviewModal({ isOpen, onClose, file, onDownload }: ShareFilePreviewModalProps) {
const t = useTranslations();
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [videoBlob, setVideoBlob] = useState<string | null>(null);
const [pdfAsBlob, setPdfAsBlob] = useState(false);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [pdfLoadFailed, setPdfLoadFailed] = useState(false);
const [isLoadingPreview, setIsLoadingPreview] = useState(false);
useEffect(() => {
if (isOpen && file.objectName && !isLoadingPreview) {
setIsLoading(true);
setPreviewUrl(null);
setVideoBlob(null);
setPdfAsBlob(false);
setDownloadUrl(null);
setPdfLoadFailed(false);
loadPreview();
}
}, [file.objectName, isOpen]);
useEffect(() => {
return () => {
if (previewUrl && previewUrl.startsWith("blob:")) {
URL.revokeObjectURL(previewUrl);
}
if (videoBlob && videoBlob.startsWith("blob:")) {
URL.revokeObjectURL(videoBlob);
}
};
}, [previewUrl, videoBlob]);
useEffect(() => {
if (!isOpen) {
if (previewUrl && previewUrl.startsWith("blob:")) {
URL.revokeObjectURL(previewUrl);
setPreviewUrl(null);
}
if (videoBlob && videoBlob.startsWith("blob:")) {
URL.revokeObjectURL(videoBlob);
setVideoBlob(null);
}
}
}, [isOpen]);
const loadPreview = async () => {
if (!file.objectName || isLoadingPreview) return;
setIsLoadingPreview(true);
try {
const encodedObjectName = encodeURIComponent(file.objectName);
const response = await getDownloadUrl(encodedObjectName);
const url = response.data.url;
setDownloadUrl(url);
const fileType = getFileType();
if (fileType === "video") {
await loadVideoPreview(url);
} else if (fileType === "audio") {
await loadAudioPreview(url);
} else if (fileType === "pdf") {
await loadPdfPreview(url);
} else {
setPreviewUrl(url);
}
} catch (error) {
console.error("Failed to load preview:", error);
toast.error(t("filePreview.loadError"));
} finally {
setIsLoading(false);
setIsLoadingPreview(false);
}
};
const loadVideoPreview = async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
setVideoBlob(blobUrl);
} catch (error) {
console.error("Failed to load video as blob:", error);
setPreviewUrl(url);
}
};
const loadAudioPreview = async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
setPreviewUrl(blobUrl);
} catch (error) {
console.error("Failed to load audio as blob:", error);
setPreviewUrl(url);
}
};
const loadPdfPreview = async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
const finalBlob = new Blob([blob], { type: "application/pdf" });
const blobUrl = URL.createObjectURL(finalBlob);
setPreviewUrl(blobUrl);
setPdfAsBlob(true);
} catch (error) {
console.error("Failed to load PDF as blob:", error);
setPreviewUrl(url);
setTimeout(() => {
if (!pdfLoadFailed && !pdfAsBlob) {
handlePdfLoadError();
}
}, 4000);
}
};
const handlePdfLoadError = async () => {
if (pdfLoadFailed || pdfAsBlob) return;
setPdfLoadFailed(true);
if (downloadUrl) {
setTimeout(() => {
loadPdfPreview(downloadUrl);
}, 500);
}
};
const handleDownload = () => {
onDownload(file.objectName, file.name);
};
const getFileType = () => {
const extension = file.name.split(".").pop()?.toLowerCase();
if (extension === "pdf") return "pdf";
if (["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "tiff"].includes(extension || "")) return "image";
if (["mp3", "wav", "ogg", "m4a", "aac", "flac"].includes(extension || "")) return "audio";
if (["mp4", "webm", "ogg", "mov", "avi", "mkv", "wmv", "flv", "m4v"].includes(extension || "")) return "video";
return "other";
};
const renderPreview = () => {
const fileType = getFileType();
const { icon: FileIcon, color } = getFileIcon(file.name);
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
<p className="text-muted-foreground">{t("filePreview.loading")}</p>
</div>
);
}
const mediaUrl = fileType === "video" ? videoBlob : previewUrl;
if (!mediaUrl && (fileType === "video" || fileType === "audio")) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<FileIcon className={`h-12 w-12 ${color}`} />
<p className="text-muted-foreground">{t("filePreview.notAvailable")}</p>
<p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
</div>
);
}
if (!previewUrl && fileType !== "video") {
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<FileIcon className={`h-12 w-12 ${color}`} />
<p className="text-muted-foreground">{t("filePreview.notAvailable")}</p>
<p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
</div>
);
}
switch (fileType) {
case "pdf":
return (
<ScrollArea className="w-full">
<div className="w-full min-h-[600px] border rounded-lg overflow-hidden bg-card">
{pdfAsBlob ? (
<iframe
src={`${previewUrl!}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
className="w-full h-full min-h-[600px]"
title={file.name}
style={{ border: "none" }}
/>
) : pdfLoadFailed ? (
<div className="flex items-center justify-center h-full min-h-[600px]">
<div className="flex flex-col items-center gap-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
<p className="text-muted-foreground">{t("filePreview.loadingAlternative")}</p>
</div>
</div>
) : (
<div className="w-full h-full min-h-[600px] relative">
<object
data={`${previewUrl!}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
type="application/pdf"
className="w-full h-full min-h-[600px]"
onError={handlePdfLoadError}
>
<iframe
src={`${previewUrl!}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
className="w-full h-full min-h-[600px]"
title={file.name}
style={{ border: "none" }}
onError={handlePdfLoadError}
/>
</object>
</div>
)}
</div>
</ScrollArea>
);
case "image":
return (
<AspectRatio ratio={16 / 9} className="bg-muted">
<img src={previewUrl!} alt={file.name} className="object-contain w-full h-full rounded-md" />
</AspectRatio>
);
case "audio":
return (
<div className="flex flex-col items-center justify-center gap-6 py-4">
<CustomAudioPlayer src={mediaUrl!} />
</div>
);
case "video":
return (
<div className="flex flex-col items-center justify-center gap-4 py-6">
<div className="w-full max-w-4xl">
<video controls className="w-full rounded-lg" preload="metadata" style={{ maxHeight: "70vh" }}>
<source src={mediaUrl!} />
{t("filePreview.videoNotSupported")}
</video>
</div>
</div>
);
default:
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<FileIcon className={`text-6xl ${color}`} />
<p className="text-muted-foreground">{t("filePreview.notAvailable")}</p>
<p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
</div>
);
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{(() => {
const FileIcon = getFileIcon(file.name).icon;
return <FileIcon size={24} />;
})()}
<span className="truncate">{file.name}</span>
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto">{renderPreview()}</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{t("common.close")}
</Button>
<Button onClick={handleDownload}>
<IconDownload className="h-4 w-4" />
{t("common.download")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -4,7 +4,6 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ obje
const { objectPath } = await params; const { objectPath } = await params;
const cookieHeader = req.headers.get("cookie"); const cookieHeader = req.headers.get("cookie");
// Reconstruct the full objectName from the path segments
const objectName = objectPath.join("/"); const objectName = objectPath.join("/");
const url = `${process.env.API_BASE_URL}/files/${encodeURIComponent(objectName)}/download`; const url = `${process.env.API_BASE_URL}/files/${encodeURIComponent(objectName)}/download`;

View File

@@ -1,14 +1,76 @@
import { IconDatabaseCog } from "@tabler/icons-react"; import { IconAlertCircle, IconDatabaseCog, IconRefresh } from "@tabler/icons-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import type { StorageUsageProps } from "../types"; import type { StorageUsageProps } from "../types";
import { formatStorageSize } from "../utils/format-storage-size"; import { formatStorageSize } from "../utils/format-storage-size";
export function StorageUsage({ diskSpace }: StorageUsageProps) { export function StorageUsage({ diskSpace, diskSpaceError, onRetry }: StorageUsageProps) {
const t = useTranslations(); const t = useTranslations();
const getErrorMessage = (error: string) => {
switch (error) {
case "disk_detection_failed":
return t("storageUsage.errors.detectionFailed");
case "server_error":
return t("storageUsage.errors.serverError");
default:
return t("storageUsage.errors.unknown");
}
};
if (diskSpaceError) {
return (
<Card className="w-full">
<CardContent className="">
<div className="flex flex-col gap-4">
<h2 className="text-xl font-semibold flex items-center gap-2">
<IconDatabaseCog className="text-gray-500" size={24} />
{t("storageUsage.title")}
</h2>
<div className="flex flex-col gap-3 py-4">
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
<IconAlertCircle size={20} />
<span className="text-sm font-medium">{t("storageUsage.errors.title")}</span>
</div>
<p className="text-sm text-muted-foreground">{getErrorMessage(diskSpaceError)}</p>
{onRetry && (
<Button variant="outline" size="sm" onClick={onRetry} className="w-fit">
<IconRefresh size={16} className="mr-2" />
{t("storageUsage.retry")}
</Button>
)}
</div>
</div>
</CardContent>
</Card>
);
}
if (!diskSpace) {
return (
<Card className="w-full">
<CardContent className="">
<div className="flex flex-col gap-4">
<h2 className="text-xl font-semibold flex items-center gap-2">
<IconDatabaseCog className="text-gray-500" size={24} />
{t("storageUsage.title")}
</h2>
<div className="flex flex-col gap-2">
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
<div className="flex justify-between text-sm text-muted-foreground">
<span>{t("storageUsage.loading")}</span>
<span>{t("storageUsage.loading")}</span>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
return ( return (
<Card className="w-full"> <Card className="w-full">
<CardContent className=""> <CardContent className="">

View File

@@ -18,6 +18,7 @@ export function useDashboard() {
diskAvailableGB: number; diskAvailableGB: number;
uploadAllowed: boolean; uploadAllowed: boolean;
} | null>(null); } | null>(null);
const [diskSpaceError, setDiskSpaceError] = useState<string | null>(null);
const [recentFiles, setRecentFiles] = useState<any[]>([]); const [recentFiles, setRecentFiles] = useState<any[]>([]);
const [recentShares, setRecentShares] = useState<any[]>([]); const [recentShares, setRecentShares] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -34,24 +35,44 @@ export function useDashboard() {
const loadDashboardData = async () => { const loadDashboardData = async () => {
try { try {
const [diskSpaceRes, filesRes, sharesRes] = await Promise.all([getDiskSpace(), listFiles(), listUserShares()]); const loadDiskSpace = async () => {
try {
const diskSpaceRes = await getDiskSpace();
setDiskSpace(diskSpaceRes.data);
setDiskSpaceError(null);
} catch (error: any) {
console.warn("Failed to load disk space:", error);
setDiskSpace(null);
setDiskSpace(diskSpaceRes.data); if (error.response?.status === 503 && error.response?.data?.code === "DISK_SPACE_DETECTION_FAILED") {
setDiskSpaceError("disk_detection_failed");
} else if (error.response?.status >= 500) {
setDiskSpaceError("server_error");
} else {
setDiskSpaceError("unknown_error");
}
}
};
const allFiles = filesRes.data.files || []; const loadFilesAndShares = async () => {
const sortedFiles = [...allFiles].sort( const [filesRes, sharesRes] = await Promise.all([listFiles(), listUserShares()]);
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
setRecentFiles(sortedFiles.slice(0, 5)); const allFiles = filesRes.data.files || [];
const sortedFiles = [...allFiles].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
setRecentFiles(sortedFiles.slice(0, 5));
const allShares = sharesRes.data.shares || []; const allShares = sharesRes.data.shares || [];
const sortedShares = [...allShares].sort( const sortedShares = [...allShares].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
); );
setRecentShares(sortedShares.slice(0, 5));
};
setRecentShares(sortedShares.slice(0, 5)); await Promise.allSettled([loadDiskSpace(), loadFilesAndShares()]);
} catch (error) { } catch (error) {
console.error("Critical dashboard error:", error);
toast.error(t("dashboard.loadError")); toast.error(t("dashboard.loadError"));
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@@ -76,6 +97,7 @@ export function useDashboard() {
return { return {
isLoading, isLoading,
diskSpace, diskSpace,
diskSpaceError,
recentFiles, recentFiles,
recentShares, recentShares,
modals: { modals: {

View File

@@ -19,6 +19,7 @@ export default function DashboardPage() {
const { const {
isLoading, isLoading,
diskSpace, diskSpace,
diskSpaceError,
recentFiles, recentFiles,
recentShares, recentShares,
modals, modals,
@@ -32,6 +33,10 @@ export default function DashboardPage() {
return <LoadingScreen />; return <LoadingScreen />;
} }
const handleRetryDiskSpace = async () => {
await loadDashboardData();
};
return ( return (
<ProtectedRoute> <ProtectedRoute>
<FileManagerLayout <FileManagerLayout
@@ -40,7 +45,7 @@ export default function DashboardPage() {
showBreadcrumb={false} showBreadcrumb={false}
title={t("dashboard.pageTitle")} title={t("dashboard.pageTitle")}
> >
<StorageUsage diskSpace={diskSpace} /> <StorageUsage diskSpace={diskSpace} diskSpaceError={diskSpaceError} onRetry={handleRetryDiskSpace} />
<QuickAccessCards /> <QuickAccessCards />
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">

View File

@@ -24,6 +24,8 @@ export interface StorageUsageProps {
diskAvailableGB: number; diskAvailableGB: number;
uploadAllowed: boolean; uploadAllowed: boolean;
} | null; } | null;
diskSpaceError?: string | null;
onRetry?: () => void;
} }
export interface DashboardModalsProps { export interface DashboardModalsProps {

View File

@@ -23,7 +23,7 @@ export function LoginForm({ error, isVisible, onToggleVisibility, onSubmit }: Lo
const form = useForm<LoginFormValues>({ const form = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema), resolver: zodResolver(loginSchema),
defaultValues: { defaultValues: {
email: "", emailOrUsername: "",
password: "", password: "",
}, },
}); });
@@ -37,18 +37,18 @@ export function LoginForm({ error, isVisible, onToggleVisibility, onSubmit }: Lo
</p> </p>
); );
const renderEmailField = () => ( const renderEmailOrUsernameField = () => (
<FormField <FormField
control={form.control} control={form.control}
name="email" name="emailOrUsername"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t("login.emailLabel")}</FormLabel> <FormLabel>{t("login.emailOrUsernameLabel")}</FormLabel>
<FormControl className="-mb-1"> <FormControl className="-mb-1">
<Input <Input
{...field} {...field}
type="email" type="text"
placeholder={t("login.emailPlaceholder")} placeholder={t("login.emailOrUsernamePlaceholder")}
disabled={isSubmitting} disabled={isSubmitting}
className="bg-transparent backdrop-blur-md" className="bg-transparent backdrop-blur-md"
/> />
@@ -89,7 +89,7 @@ export function LoginForm({ error, isVisible, onToggleVisibility, onSubmit }: Lo
{renderErrorMessage()} {renderErrorMessage()}
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
{renderEmailField()} {renderEmailOrUsernameField()}
{renderPasswordField()} {renderPasswordField()}
<Button className="w-full mt-4 cursor-pointer" variant="default" size="lg" type="submit"> <Button className="w-full mt-4 cursor-pointer" variant="default" size="lg" type="submit">
{isSubmitting ? t("login.signingIn") : t("login.signIn")} {isSubmitting ? t("login.signingIn") : t("login.signIn")}

View File

@@ -12,7 +12,7 @@ import { getCurrentUser, login } from "@/http/endpoints";
import { LoginFormValues } from "../schemas/schema"; import { LoginFormValues } from "../schemas/schema";
export const loginSchema = z.object({ export const loginSchema = z.object({
email: z.string(), emailOrUsername: z.string(),
password: z.string(), password: z.string(),
}); });

View File

@@ -5,7 +5,7 @@ type TFunction = ReturnType<typeof useTranslations>;
export const createLoginSchema = (t: TFunction) => export const createLoginSchema = (t: TFunction) =>
z.object({ z.object({
email: z.string().min(1, t("validation.emailRequired")).email(t("validation.invalidEmail")), emailOrUsername: z.string().min(1, t("validation.emailOrUsernameRequired")),
password: z.string().min(1, t("validation.passwordRequired")), password: z.string().min(1, t("validation.passwordRequired")),
}); });

View File

@@ -1,17 +1,13 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import { IconDownload } from "@tabler/icons-react"; import { IconDownload } from "@tabler/icons-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { CustomAudioPlayer } from "@/components/audio/custom-audio-player";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area"; import { useFilePreview } from "@/hooks/use-file-preview";
import { getDownloadUrl } from "@/http/endpoints";
import { getFileIcon } from "@/utils/file-icons"; import { getFileIcon } from "@/utils/file-icons";
import { FilePreviewRenderer } from "./previews";
interface FilePreviewModalProps { interface FilePreviewModalProps {
isOpen: boolean; isOpen: boolean;
@@ -25,299 +21,7 @@ interface FilePreviewModalProps {
export function FilePreviewModal({ isOpen, onClose, file }: FilePreviewModalProps) { export function FilePreviewModal({ isOpen, onClose, file }: FilePreviewModalProps) {
const t = useTranslations(); const t = useTranslations();
const [previewUrl, setPreviewUrl] = useState<string | null>(null); const previewState = useFilePreview({ file, isOpen });
const [isLoading, setIsLoading] = useState(true);
const [videoBlob, setVideoBlob] = useState<string | null>(null);
const [pdfAsBlob, setPdfAsBlob] = useState(false);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [pdfLoadFailed, setPdfLoadFailed] = useState(false);
const [isLoadingPreview, setIsLoadingPreview] = useState(false);
useEffect(() => {
if (isOpen && file.objectName && !isLoadingPreview) {
setIsLoading(true);
setPreviewUrl(null);
setVideoBlob(null);
setPdfAsBlob(false);
setDownloadUrl(null);
setPdfLoadFailed(false);
loadPreview();
}
}, [file.objectName, isOpen]);
useEffect(() => {
return () => {
if (previewUrl && previewUrl.startsWith("blob:")) {
URL.revokeObjectURL(previewUrl);
}
if (videoBlob && videoBlob.startsWith("blob:")) {
URL.revokeObjectURL(videoBlob);
}
};
}, [previewUrl, videoBlob]);
useEffect(() => {
if (!isOpen) {
if (previewUrl && previewUrl.startsWith("blob:")) {
URL.revokeObjectURL(previewUrl);
setPreviewUrl(null);
}
if (videoBlob && videoBlob.startsWith("blob:")) {
URL.revokeObjectURL(videoBlob);
setVideoBlob(null);
}
}
}, [isOpen]);
const loadPreview = async () => {
if (!file.objectName || isLoadingPreview) return;
setIsLoadingPreview(true);
try {
const encodedObjectName = encodeURIComponent(file.objectName);
const response = await getDownloadUrl(encodedObjectName);
const url = response.data.url;
setDownloadUrl(url);
const fileType = getFileType();
if (fileType === "video") {
await loadVideoPreview(url);
} else if (fileType === "audio") {
await loadAudioPreview(url);
} else if (fileType === "pdf") {
await loadPdfPreview(url);
} else {
setPreviewUrl(url);
}
} catch (error) {
console.error("Failed to load preview:", error);
toast.error(t("filePreview.loadError"));
} finally {
setIsLoading(false);
setIsLoadingPreview(false);
}
};
const loadVideoPreview = async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
setVideoBlob(blobUrl);
} catch (error) {
console.error("Failed to load video as blob:", error);
setPreviewUrl(url);
}
};
const loadAudioPreview = async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
setPreviewUrl(blobUrl);
} catch (error) {
console.error("Failed to load audio as blob:", error);
setPreviewUrl(url);
}
};
const loadPdfPreview = async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
const finalBlob = new Blob([blob], { type: "application/pdf" });
const blobUrl = URL.createObjectURL(finalBlob);
setPreviewUrl(blobUrl);
setPdfAsBlob(true);
} catch (error) {
console.error("Failed to load PDF as blob:", error);
setPreviewUrl(url);
setTimeout(() => {
if (!pdfLoadFailed && !pdfAsBlob) {
handlePdfLoadError();
}
}, 4000);
}
};
const handlePdfLoadError = async () => {
if (pdfLoadFailed || pdfAsBlob) return;
setPdfLoadFailed(true);
if (downloadUrl) {
setTimeout(() => {
loadPdfPreview(downloadUrl);
}, 500);
}
};
const handleDownload = async () => {
try {
let downloadUrlToUse = downloadUrl;
if (!downloadUrlToUse) {
const encodedObjectName = encodeURIComponent(file.objectName);
const response = await getDownloadUrl(encodedObjectName);
downloadUrlToUse = response.data.url;
}
const fileResponse = await fetch(downloadUrlToUse);
if (!fileResponse.ok) {
throw new Error(`Download failed: ${fileResponse.status} - ${fileResponse.statusText}`);
}
const blob = await fileResponse.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error) {
toast.error(t("filePreview.downloadError"));
console.error("Download error:", error);
}
};
const getFileType = () => {
const extension = file.name.split(".").pop()?.toLowerCase();
if (extension === "pdf") return "pdf";
if (["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "tiff"].includes(extension || "")) return "image";
if (["mp3", "wav", "ogg", "m4a", "aac", "flac"].includes(extension || "")) return "audio";
if (["mp4", "webm", "ogg", "mov", "avi", "mkv", "wmv", "flv", "m4v"].includes(extension || "")) return "video";
return "other";
};
const renderPreview = () => {
const fileType = getFileType();
const { icon: FileIcon, color } = getFileIcon(file.name);
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
<p className="text-muted-foreground">{t("filePreview.loading")}</p>
</div>
);
}
const mediaUrl = fileType === "video" ? videoBlob : previewUrl;
if (!mediaUrl && (fileType === "video" || fileType === "audio")) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<FileIcon className={`h-12 w-12 ${color}`} />
<p className="text-muted-foreground">{t("filePreview.notAvailable")}</p>
<p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
</div>
);
}
if (!previewUrl && fileType !== "video") {
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<FileIcon className={`h-12 w-12 ${color}`} />
<p className="text-muted-foreground">{t("filePreview.notAvailable")}</p>
<p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
</div>
);
}
switch (fileType) {
case "pdf":
return (
<ScrollArea className="w-full">
<div className="w-full min-h-[600px] border rounded-lg overflow-hidden bg-card">
{pdfAsBlob ? (
<iframe
src={`${previewUrl!}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
className="w-full h-full min-h-[600px]"
title={file.name}
style={{ border: "none" }}
/>
) : pdfLoadFailed ? (
<div className="flex items-center justify-center h-full min-h-[600px]">
<div className="flex flex-col items-center gap-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
<p className="text-muted-foreground">{t("filePreview.loadingAlternative")}</p>
</div>
</div>
) : (
<div className="w-full h-full min-h-[600px] relative">
<object
data={`${previewUrl!}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
type="application/pdf"
className="w-full h-full min-h-[600px]"
onError={handlePdfLoadError}
>
<iframe
src={`${previewUrl!}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
className="w-full h-full min-h-[600px]"
title={file.name}
style={{ border: "none" }}
onError={handlePdfLoadError}
/>
</object>
</div>
)}
</div>
</ScrollArea>
);
case "image":
return (
<AspectRatio ratio={16 / 9} className="bg-muted">
<img src={previewUrl!} alt={file.name} className="object-contain w-full h-full rounded-md" />
</AspectRatio>
);
case "audio":
return (
<div className="flex flex-col items-center justify-center gap-6 py-4">
<CustomAudioPlayer src={mediaUrl!} />
</div>
);
case "video":
return (
<div className="flex flex-col items-center justify-center gap-4 py-6">
<div className="w-full max-w-4xl">
<video controls className="w-full rounded-lg" preload="metadata" style={{ maxHeight: "70vh" }}>
<source src={mediaUrl!} />
{t("filePreview.videoNotSupported")}
</video>
</div>
</div>
);
default:
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<FileIcon className={`text-6xl ${color}`} />
<p className="text-muted-foreground">{t("filePreview.notAvailable")}</p>
<p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
</div>
);
}
};
return ( return (
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
@@ -331,12 +35,24 @@ export function FilePreviewModal({ isOpen, onClose, file }: FilePreviewModalProp
<span className="truncate">{file.name}</span> <span className="truncate">{file.name}</span>
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="flex-1 overflow-auto">{renderPreview()}</div> <div className="flex-1 overflow-auto">
<FilePreviewRenderer
fileType={previewState.fileType}
fileName={file.name}
previewUrl={previewState.previewUrl}
videoBlob={previewState.videoBlob}
textContent={previewState.textContent}
isLoading={previewState.isLoading}
pdfAsBlob={previewState.pdfAsBlob}
pdfLoadFailed={previewState.pdfLoadFailed}
onPdfLoadError={previewState.handlePdfLoadError}
/>
</div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose}> <Button variant="outline" onClick={onClose}>
{t("common.close")} {t("common.close")}
</Button> </Button>
<Button onClick={handleDownload}> <Button onClick={previewState.handleDownload}>
<IconDownload className="h-4 w-4" /> <IconDownload className="h-4 w-4" />
{t("common.download")} {t("common.download")}
</Button> </Button>

View File

@@ -0,0 +1,13 @@
import { CustomAudioPlayer } from "@/components/audio/custom-audio-player";
interface AudioPreviewProps {
src: string;
}
export function AudioPreview({ src }: AudioPreviewProps) {
return (
<div className="flex flex-col items-center justify-center gap-6 py-4">
<CustomAudioPlayer src={src} />
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { useTranslations } from "next-intl";
import { getFileIcon } from "@/utils/file-icons";
interface DefaultPreviewProps {
fileName: string;
isLoading?: boolean;
message?: string;
}
export function DefaultPreview({ fileName, isLoading, message }: DefaultPreviewProps) {
const t = useTranslations();
const { icon: FileIcon, color } = getFileIcon(fileName);
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
<p className="text-muted-foreground">{t("filePreview.loading")}</p>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<FileIcon className={`h-12 w-12 ${color}`} />
<p className="text-muted-foreground">{message || t("filePreview.notAvailable")}</p>
<p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { type FileType } from "@/utils/file-types";
import { AudioPreview } from "./audio-preview";
import { DefaultPreview } from "./default-preview";
import { ImagePreview } from "./image-preview";
import { PdfPreview } from "./pdf-preview";
import { TextPreview } from "./text-preview";
import { VideoPreview } from "./video-preview";
interface FilePreviewRendererProps {
fileType: FileType;
fileName: string;
previewUrl: string | null;
videoBlob: string | null;
textContent: string | null;
isLoading: boolean;
pdfAsBlob: boolean;
pdfLoadFailed: boolean;
onPdfLoadError: () => void;
}
export function FilePreviewRenderer({
fileType,
fileName,
previewUrl,
videoBlob,
textContent,
isLoading,
pdfAsBlob,
pdfLoadFailed,
onPdfLoadError,
}: FilePreviewRendererProps) {
if (isLoading) {
return <DefaultPreview fileName={fileName} isLoading />;
}
const mediaUrl = fileType === "video" ? videoBlob : previewUrl;
if (!mediaUrl && (fileType === "video" || fileType === "audio")) {
return <DefaultPreview fileName={fileName} />;
}
if (fileType === "text" && !textContent) {
return <DefaultPreview fileName={fileName} />;
}
if (!previewUrl && fileType !== "video" && fileType !== "text") {
return <DefaultPreview fileName={fileName} />;
}
switch (fileType) {
case "pdf":
return (
<PdfPreview
src={previewUrl!}
fileName={fileName}
pdfAsBlob={pdfAsBlob}
pdfLoadFailed={pdfLoadFailed}
onLoadError={onPdfLoadError}
/>
);
case "text":
return <TextPreview content={textContent} fileName={fileName} />;
case "image":
return <ImagePreview src={previewUrl!} alt={fileName} />;
case "audio":
return <AudioPreview src={mediaUrl!} />;
case "video":
return <VideoPreview src={mediaUrl!} />;
default:
return <DefaultPreview fileName={fileName} />;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,54 @@
import { useTranslations } from "next-intl";
import { ScrollArea } from "@/components/ui/scroll-area";
interface PdfPreviewProps {
src: string;
fileName: string;
pdfAsBlob: boolean;
pdfLoadFailed: boolean;
onLoadError: () => void;
}
export function PdfPreview({ src, fileName, pdfAsBlob, pdfLoadFailed, onLoadError }: PdfPreviewProps) {
const t = useTranslations();
return (
<ScrollArea className="w-full">
<div className="w-full min-h-[600px] border rounded-lg overflow-hidden bg-card">
{pdfAsBlob ? (
<iframe
src={`${src}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
className="w-full h-full min-h-[600px]"
title={fileName}
style={{ border: "none" }}
/>
) : pdfLoadFailed ? (
<div className="flex items-center justify-center h-full min-h-[600px]">
<div className="flex flex-col items-center gap-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
<p className="text-muted-foreground">{t("filePreview.loadingAlternative")}</p>
</div>
</div>
) : (
<div className="w-full h-full min-h-[600px] relative">
<object
data={`${src}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
type="application/pdf"
className="w-full h-full min-h-[600px]"
onError={onLoadError}
>
<iframe
src={`${src}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
className="w-full h-full min-h-[600px]"
title={fileName}
style={{ border: "none" }}
onError={onLoadError}
/>
</object>
</div>
)}
</div>
</ScrollArea>
);
}

View File

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

View File

@@ -0,0 +1,20 @@
import { useTranslations } from "next-intl";
interface VideoPreviewProps {
src: string;
}
export function VideoPreview({ src }: VideoPreviewProps) {
const t = useTranslations();
return (
<div className="flex flex-col items-center justify-center gap-4 py-6">
<div className="w-full max-w-4xl">
<video controls className="w-full rounded-lg" preload="metadata" style={{ maxHeight: "70vh" }}>
<source src={src} />
{t("filePreview.videoNotSupported")}
</video>
</div>
</div>
);
}

View File

@@ -1,18 +1,7 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import { IconAlertTriangle, IconCheck, IconCloudUpload, IconLoader, IconTrash, IconX } from "@tabler/icons-react";
IconAlertTriangle,
IconCheck,
IconCloudUpload,
IconFileText,
IconFileTypePdf,
IconFileTypography,
IconLoader,
IconPhoto,
IconTrash,
IconX,
} from "@tabler/icons-react";
import axios from "axios"; import axios from "axios";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -21,6 +10,7 @@ import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { checkFile, getPresignedUrl, registerFile } from "@/http/endpoints"; import { checkFile, getPresignedUrl, registerFile } from "@/http/endpoints";
import { getFileIcon } from "@/utils/file-icons";
import { generateSafeFileName } from "@/utils/file-utils"; import { generateSafeFileName } from "@/utils/file-utils";
import getErrorData from "@/utils/getErrorData"; import getErrorData from "@/utils/getErrorData";
@@ -157,11 +147,9 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
handleFilesSelect(event.dataTransfer.files); handleFilesSelect(event.dataTransfer.files);
}; };
const getFileIcon = (fileType: string) => { const renderFileIcon = (fileName: string) => {
if (fileType.startsWith("image/")) return <IconPhoto size={24} className="text-blue-500" />; const { icon: FileIcon, color } = getFileIcon(fileName);
if (fileType.includes("pdf")) return <IconFileTypePdf size={24} className="text-red-500" />; return <FileIcon size={24} className={color} />;
if (fileType.includes("word")) return <IconFileTypography size={24} className="text-blue-700" />;
return <IconFileText size={24} className="text-muted-foreground" />;
}; };
const getStatusIcon = (status: UploadStatus) => { const getStatusIcon = (status: UploadStatus) => {
@@ -420,7 +408,7 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
className="w-10 h-10 rounded object-cover" className="w-10 h-10 rounded object-cover"
/> />
) : ( ) : (
getFileIcon(upload.file.type) renderFileIcon(upload.file.name)
)} )}
</div> </div>
@@ -453,7 +441,33 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
> >
<IconX size={14} /> <IconX size={14} />
</Button> </Button>
) : upload.status === UploadStatus.SUCCESS ? null : ( ) : upload.status === UploadStatus.SUCCESS ? null : upload.status === UploadStatus.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
)
);
}}
className="h-8 w-8 p-0"
title={t("uploadFile.retry")}
>
<IconLoader size={14} />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => removeFile(upload.id)}
className="h-8 w-8 p-0"
>
<IconTrash size={14} />
</Button>
</div>
) : (
<Button variant="ghost" size="sm" onClick={() => removeFile(upload.id)} className="h-8 w-8 p-0"> <Button variant="ghost" size="sm" onClick={() => removeFile(upload.id)} className="h-8 w-8 p-0">
<IconTrash size={14} /> <IconTrash size={14} />
</Button> </Button>

View File

@@ -0,0 +1,255 @@
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { getDownloadUrl } from "@/http/endpoints";
import { getFileExtension, getFileType, type FileType } from "@/utils/file-types";
interface FilePreviewState {
previewUrl: string | null;
videoBlob: string | null;
textContent: string | null;
downloadUrl: string | null;
isLoading: boolean;
isLoadingPreview: boolean;
pdfAsBlob: boolean;
pdfLoadFailed: boolean;
}
interface UseFilePreviewProps {
file: {
name: string;
objectName: string;
type?: string;
};
isOpen: boolean;
}
export function useFilePreview({ file, isOpen }: UseFilePreviewProps) {
const t = useTranslations();
const [state, setState] = useState<FilePreviewState>({
previewUrl: null,
videoBlob: null,
textContent: null,
downloadUrl: null,
isLoading: true,
isLoadingPreview: false,
pdfAsBlob: false,
pdfLoadFailed: false,
});
const fileType: FileType = getFileType(file.name);
// Reset state when file changes or modal opens
useEffect(() => {
if (isOpen && file.objectName && !state.isLoadingPreview) {
resetState();
loadPreview();
}
}, [file.objectName, isOpen]);
// Cleanup blob URLs
useEffect(() => {
return () => {
cleanupBlobUrls();
};
}, [state.previewUrl, state.videoBlob]);
// Cleanup when modal closes
useEffect(() => {
if (!isOpen) {
cleanupBlobUrls();
}
}, [isOpen]);
const resetState = () => {
setState((prev) => ({
...prev,
previewUrl: null,
videoBlob: null,
textContent: null,
downloadUrl: null,
pdfAsBlob: false,
pdfLoadFailed: false,
isLoading: true,
}));
};
const cleanupBlobUrls = () => {
if (state.previewUrl && state.previewUrl.startsWith("blob:")) {
URL.revokeObjectURL(state.previewUrl);
}
if (state.videoBlob && state.videoBlob.startsWith("blob:")) {
URL.revokeObjectURL(state.videoBlob);
}
};
const loadPreview = async () => {
if (!file.objectName || state.isLoadingPreview) return;
setState((prev) => ({ ...prev, isLoadingPreview: true }));
try {
const encodedObjectName = encodeURIComponent(file.objectName);
const response = await getDownloadUrl(encodedObjectName);
const url = response.data.url;
setState((prev) => ({ ...prev, downloadUrl: url }));
switch (fileType) {
case "video":
await loadVideoPreview(url);
break;
case "audio":
await loadAudioPreview(url);
break;
case "pdf":
await loadPdfPreview(url);
break;
case "text":
await loadTextPreview(url);
break;
default:
setState((prev) => ({ ...prev, previewUrl: url }));
}
} catch (error) {
console.error("Failed to load preview:", error);
toast.error(t("filePreview.loadError"));
} finally {
setState((prev) => ({
...prev,
isLoading: false,
isLoadingPreview: false,
}));
}
};
const loadVideoPreview = async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
setState((prev) => ({ ...prev, videoBlob: blobUrl }));
} catch (error) {
console.error("Failed to load video as blob:", error);
setState((prev) => ({ ...prev, previewUrl: url }));
}
};
const loadAudioPreview = async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
setState((prev) => ({ ...prev, previewUrl: blobUrl }));
} catch (error) {
console.error("Failed to load audio as blob:", error);
setState((prev) => ({ ...prev, previewUrl: url }));
}
};
const loadPdfPreview = async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
const finalBlob = new Blob([blob], { type: "application/pdf" });
const blobUrl = URL.createObjectURL(finalBlob);
setState((prev) => ({
...prev,
previewUrl: blobUrl,
pdfAsBlob: true,
}));
} catch (error) {
console.error("Failed to load PDF as blob:", error);
setState((prev) => ({ ...prev, previewUrl: url }));
setTimeout(() => {
if (!state.pdfLoadFailed && !state.pdfAsBlob) {
handlePdfLoadError();
}
}, 4000);
}
};
const loadTextPreview = async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const text = await response.text();
const extension = getFileExtension(file.name);
try {
// For JSON files, validate and format
if (extension === "json") {
const parsed = JSON.parse(text);
const formatted = JSON.stringify(parsed, null, 2);
setState((prev) => ({ ...prev, textContent: formatted }));
} else {
// For other text files, show as-is
setState((prev) => ({ ...prev, textContent: text }));
}
} catch (jsonError) {
// If JSON parsing fails, show as plain text
setState((prev) => ({ ...prev, textContent: text }));
}
} catch (error) {
console.error("Failed to load text content:", error);
setState((prev) => ({ ...prev, textContent: null }));
}
};
const handlePdfLoadError = async () => {
if (state.pdfLoadFailed || state.pdfAsBlob) return;
setState((prev) => ({ ...prev, pdfLoadFailed: true }));
if (state.downloadUrl) {
setTimeout(() => {
loadPdfPreview(state.downloadUrl!);
}, 500);
}
};
const handleDownload = async () => {
try {
let downloadUrlToUse = state.downloadUrl;
if (!downloadUrlToUse) {
const encodedObjectName = encodeURIComponent(file.objectName);
const response = await getDownloadUrl(encodedObjectName);
downloadUrlToUse = response.data.url;
}
const link = document.createElement("a");
link.href = downloadUrlToUse;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
toast.error(t("filePreview.downloadError"));
console.error("Download error:", error);
}
};
return {
...state,
fileType,
handleDownload,
handlePdfLoadError,
};
}

View File

@@ -1,6 +1,5 @@
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
// Create Reverse Share
export type CreateReverseShareBody = { export type CreateReverseShareBody = {
name?: string; name?: string;
description?: string; description?: string;
@@ -31,7 +30,6 @@ export type CreateReverseShareResult = AxiosResponse<{
}; };
}>; }>;
// Update Reverse Share
export type UpdateReverseShareBody = { export type UpdateReverseShareBody = {
id: string; id: string;
name?: string; name?: string;
@@ -64,7 +62,6 @@ export type UpdateReverseShareResult = AxiosResponse<{
}; };
}>; }>;
// List User Reverse Shares
export type ListUserReverseSharesResult = AxiosResponse<{ export type ListUserReverseSharesResult = AxiosResponse<{
reverseShares: { reverseShares: {
id: string; id: string;
@@ -91,7 +88,6 @@ export type ListUserReverseSharesResult = AxiosResponse<{
}[]; }[];
}>; }>;
// Get Reverse Share
export type GetReverseShareResult = AxiosResponse<{ export type GetReverseShareResult = AxiosResponse<{
reverseShare: { reverseShare: {
id: string; id: string;
@@ -118,7 +114,6 @@ export type GetReverseShareResult = AxiosResponse<{
}; };
}>; }>;
// Delete Reverse Share
export type DeleteReverseShareResult = AxiosResponse<{ export type DeleteReverseShareResult = AxiosResponse<{
reverseShare: { reverseShare: {
id: string; id: string;
@@ -138,7 +133,6 @@ export type DeleteReverseShareResult = AxiosResponse<{
}; };
}>; }>;
// Get Reverse Share for Upload (Public)
export type GetReverseShareForUploadParams = { export type GetReverseShareForUploadParams = {
password?: string; password?: string;
}; };
@@ -157,7 +151,6 @@ export type GetReverseShareForUploadResult = AxiosResponse<{
}; };
}>; }>;
// Update Password
export type UpdateReverseSharePasswordBody = { export type UpdateReverseSharePasswordBody = {
password: string | null; password: string | null;
}; };
@@ -181,7 +174,6 @@ export type UpdateReverseSharePasswordResult = AxiosResponse<{
}; };
}>; }>;
// Presigned URL
export type GetPresignedUrlBody = { export type GetPresignedUrlBody = {
objectName: string; objectName: string;
}; };
@@ -191,7 +183,6 @@ export type GetPresignedUrlResult = AxiosResponse<{
expiresIn: number; expiresIn: number;
}>; }>;
// Register File Upload
export type RegisterFileUploadBody = { export type RegisterFileUploadBody = {
name: string; name: string;
description?: string; description?: string;
@@ -210,7 +201,6 @@ export type RegisterFileUploadResult = AxiosResponse<{
file: ReverseShareFile; file: ReverseShareFile;
}>; }>;
// Check Password
export type CheckReverseSharePasswordBody = { export type CheckReverseSharePasswordBody = {
password: string; password: string;
}; };
@@ -219,18 +209,15 @@ export type CheckReverseSharePasswordResult = AxiosResponse<{
valid: boolean; valid: boolean;
}>; }>;
// Download File
export type DownloadReverseShareFileResult = AxiosResponse<{ export type DownloadReverseShareFileResult = AxiosResponse<{
url: string; url: string;
expiresIn: number; expiresIn: number;
}>; }>;
// Delete File
export type DeleteReverseShareFileResult = AxiosResponse<{ export type DeleteReverseShareFileResult = AxiosResponse<{
file: ReverseShareFile; file: ReverseShareFile;
}>; }>;
// Shared Type
export type ReverseShareFile = { export type ReverseShareFile = {
id: string; id: string;
name: string; name: string;
@@ -244,7 +231,6 @@ export type ReverseShareFile = {
updatedAt: string; updatedAt: string;
}; };
// Activate Reverse Share
export type ActivateReverseShareResult = AxiosResponse<{ export type ActivateReverseShareResult = AxiosResponse<{
reverseShare: { reverseShare: {
id: string; id: string;
@@ -264,7 +250,6 @@ export type ActivateReverseShareResult = AxiosResponse<{
}; };
}>; }>;
// Deactivate Reverse Share
export type DeactivateReverseShareResult = AxiosResponse<{ export type DeactivateReverseShareResult = AxiosResponse<{
reverseShare: { reverseShare: {
id: string; id: string;
@@ -284,7 +269,6 @@ export type DeactivateReverseShareResult = AxiosResponse<{
}; };
}>; }>;
// Update Reverse Share File
export type UpdateReverseShareFileBody = { export type UpdateReverseShareFileBody = {
name?: string; name?: string;
description?: string | null; description?: string | null;

View File

@@ -7,8 +7,8 @@
*/ */
export type LoginBody = { export type LoginBody = {
/** User email */ /** User email or username */
email: string; emailOrUsername: string;
/** /**
* User password * User password
* @minLength 8 * @minLength 8

View File

@@ -1,5 +1,30 @@
import { import {
Icon, Icon,
IconApi,
IconAtom,
IconBook,
IconBrandCss3,
IconBrandDocker,
IconBrandGit,
IconBrandGolang,
IconBrandHtml5,
IconBrandJavascript,
IconBrandKotlin,
IconBrandNpm,
IconBrandPhp,
IconBrandPython,
IconBrandReact,
IconBrandRust,
IconBrandSass,
IconBrandSwift,
IconBrandTypescript,
IconBrandVue,
IconBrandYarn,
IconBug,
IconCloud,
IconCode,
IconDatabase,
IconDeviceDesktop,
IconFile, IconFile,
IconFileCode, IconFileCode,
IconFileDescription, IconFileDescription,
@@ -8,8 +33,16 @@ import {
IconFileText, IconFileText,
IconFileTypePdf, IconFileTypePdf,
IconFileZip, IconFileZip,
IconKey,
IconLock,
IconMarkdown,
IconMath,
IconPalette,
IconPhoto, IconPhoto,
IconPresentation, IconPresentation,
IconSettings,
IconTerminal,
IconTool,
IconVideo, IconVideo,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
@@ -20,56 +53,452 @@ interface FileIconMapping {
} }
const fileIcons: FileIconMapping[] = [ const fileIcons: FileIconMapping[] = [
// Images
{ {
extensions: ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"], extensions: ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg", "tiff", "ico", "heic", "avif"],
icon: IconPhoto, icon: IconPhoto,
color: "text-blue-500", color: "text-blue-500",
}, },
// Documents
{ {
extensions: ["pdf"], extensions: ["pdf"],
icon: IconFileTypePdf, icon: IconFileTypePdf,
color: "text-red-500", color: "text-red-500",
}, },
{ {
extensions: ["doc", "docx"], extensions: ["doc", "docx", "odt", "rtf"],
icon: IconFileText, icon: IconFileText,
color: "text-blue-600", color: "text-blue-600",
}, },
{ {
extensions: ["xls", "xlsx", "csv"], extensions: ["xls", "xlsx", "ods", "csv"],
icon: IconFileSpreadsheet, icon: IconFileSpreadsheet,
color: "text-green-600", color: "text-green-600",
}, },
{ {
extensions: ["ppt", "pptx"], extensions: ["ppt", "pptx", "odp"],
icon: IconPresentation, icon: IconPresentation,
color: "text-orange-500", color: "text-orange-500",
}, },
// Media
{ {
extensions: ["mp3", "wav", "ogg", "m4a"], extensions: ["mp3", "wav", "ogg", "m4a", "aac", "flac", "wma", "opus"],
icon: IconFileMusic, icon: IconFileMusic,
color: "text-purple-500", color: "text-purple-500",
}, },
{ {
extensions: ["mp4", "avi", "mov", "wmv", "mkv"], extensions: ["mp4", "avi", "mov", "wmv", "mkv", "webm", "flv", "m4v", "3gp"],
icon: IconVideo, icon: IconVideo,
color: "text-pink-500", color: "text-pink-500",
}, },
// Archives
{ {
extensions: ["zip", "rar", "7z", "tar", "gz"], extensions: ["zip", "rar", "7z", "tar", "gz", "bz2", "xz", "lz", "cab", "deb", "rpm"],
icon: IconFileZip, icon: IconFileZip,
color: "text-yellow-600", color: "text-yellow-600",
}, },
// JavaScript/TypeScript
{ {
extensions: ["html", "css", "js", "ts", "jsx", "tsx", "json", "xml"], extensions: ["js", "mjs", "cjs"],
icon: IconFileCode, icon: IconBrandJavascript,
color: "text-yellow-500",
},
{
extensions: ["ts", "tsx"],
icon: IconBrandTypescript,
color: "text-blue-600",
},
{
extensions: ["jsx"],
icon: IconBrandReact,
color: "text-cyan-500",
},
{
extensions: ["vue"],
icon: IconBrandVue,
color: "text-green-500",
},
// Web Technologies
{
extensions: ["html", "htm", "xhtml"],
icon: IconBrandHtml5,
color: "text-orange-600",
},
{
extensions: ["css"],
icon: IconBrandCss3,
color: "text-blue-600",
},
{
extensions: ["scss", "sass"],
icon: IconBrandSass,
color: "text-pink-600",
},
{
extensions: ["less", "stylus"],
icon: IconPalette,
color: "text-purple-600",
},
// Programming Languages
{
extensions: ["py", "pyw", "pyc", "pyo", "pyd"],
icon: IconBrandPython,
color: "text-yellow-600",
},
{
extensions: ["php", "phtml"],
icon: IconBrandPhp,
color: "text-purple-700",
},
{
extensions: ["go"],
icon: IconBrandGolang,
color: "text-cyan-600",
},
{
extensions: ["rs"],
icon: IconBrandRust,
color: "text-orange-700",
},
{
extensions: ["swift"],
icon: IconBrandSwift,
color: "text-orange-500",
},
{
extensions: ["kt", "kts"],
icon: IconBrandKotlin,
color: "text-purple-600",
},
{
extensions: ["java", "class", "jar"],
icon: IconCode,
color: "text-red-600",
},
{
extensions: ["c", "h"],
icon: IconCode,
color: "text-blue-700",
},
{
extensions: ["cpp", "cxx", "cc", "hpp", "hxx"],
icon: IconCode,
color: "text-blue-800",
},
{
extensions: ["cs"],
icon: IconCode,
color: "text-purple-700",
},
{
extensions: ["rb", "rbw", "rake"],
icon: IconCode,
color: "text-red-500",
},
{
extensions: ["scala", "sc"],
icon: IconCode,
color: "text-red-700",
},
{
extensions: ["clj", "cljs", "cljc", "edn"],
icon: IconCode,
color: "text-green-700",
},
{
extensions: ["hs", "lhs"],
icon: IconCode,
color: "text-purple-800",
},
{
extensions: ["elm"],
icon: IconCode,
color: "text-blue-700",
},
{
extensions: ["dart"],
icon: IconCode,
color: "text-blue-600",
},
{
extensions: ["lua"],
icon: IconCode,
color: "text-blue-800",
},
{
extensions: ["r", "rmd"],
icon: IconMath,
color: "text-blue-700",
},
{
extensions: ["matlab", "m"],
icon: IconMath,
color: "text-orange-600",
},
{
extensions: ["julia", "jl"],
icon: IconMath,
color: "text-purple-600",
},
// Shell Scripts
{
extensions: ["sh", "bash", "zsh", "fish"],
icon: IconTerminal,
color: "text-green-600",
},
{
extensions: ["ps1", "psm1", "psd1"],
icon: IconTerminal,
color: "text-blue-700",
},
{
extensions: ["bat", "cmd"],
icon: IconTerminal,
color: "text-gray-600",
},
// Database
{
extensions: ["sql", "mysql", "pgsql", "sqlite", "db"],
icon: IconDatabase,
color: "text-blue-700",
},
// Configuration Files
{
extensions: ["json", "json5"],
icon: IconCode,
color: "text-yellow-700",
},
{
extensions: ["yaml", "yml"],
icon: IconSettings,
color: "text-purple-600",
},
{
extensions: ["toml"],
icon: IconSettings,
color: "text-orange-600",
},
{
extensions: ["xml", "xsd", "xsl", "xslt"],
icon: IconCode,
color: "text-orange-700",
},
{
extensions: ["ini", "cfg", "conf", "config"],
icon: IconSettings,
color: "text-gray-600", color: "text-gray-600",
}, },
{ {
extensions: ["txt", "md", "rtf"], extensions: ["env", "dotenv"],
icon: IconKey,
color: "text-green-700",
},
{
extensions: ["properties"],
icon: IconSettings,
color: "text-blue-600",
},
// Docker & DevOps
{
extensions: ["dockerfile", "containerfile"],
icon: IconBrandDocker,
color: "text-blue-600",
},
{
extensions: ["tf", "tfvars", "hcl"],
icon: IconCloud,
color: "text-purple-600",
},
{
extensions: ["k8s", "kubernetes"],
icon: IconCloud,
color: "text-blue-700",
},
{
extensions: ["ansible", "playbook"],
icon: IconTool,
color: "text-red-600",
},
// Package Managers
{
extensions: ["package"],
icon: IconBrandNpm,
color: "text-red-600",
},
{
extensions: ["yarn"],
icon: IconBrandYarn,
color: "text-blue-600",
},
{
extensions: ["cargo"],
icon: IconBrandRust,
color: "text-orange-700",
},
{
extensions: ["gemfile"],
icon: IconCode,
color: "text-red-500",
},
{
extensions: ["composer"],
icon: IconBrandPhp,
color: "text-purple-700",
},
{
extensions: ["requirements", "pipfile", "poetry"],
icon: IconBrandPython,
color: "text-yellow-600",
},
{
extensions: ["gradle", "build.gradle"],
icon: IconTool,
color: "text-green-700",
},
{
extensions: ["pom"],
icon: IconCode,
color: "text-orange-600",
},
{
extensions: ["makefile", "cmake"],
icon: IconTool,
color: "text-blue-700",
},
// Git
{
extensions: ["gitignore", "gitattributes", "gitmodules", "gitconfig"],
icon: IconBrandGit,
color: "text-orange-600",
},
// Documentation
{
extensions: ["md", "markdown"],
icon: IconMarkdown,
color: "text-gray-700",
},
{
extensions: ["rst", "txt"],
icon: IconFileDescription, icon: IconFileDescription,
color: "text-gray-500", color: "text-gray-500",
}, },
{
extensions: ["adoc", "asciidoc"],
icon: IconBook,
color: "text-blue-600",
},
{
extensions: ["tex", "latex"],
icon: IconMath,
color: "text-green-700",
},
{
extensions: ["log"],
icon: IconBug,
color: "text-yellow-600",
},
// Templates
{
extensions: ["hbs", "handlebars", "mustache"],
icon: IconCode,
color: "text-orange-600",
},
{
extensions: ["twig"],
icon: IconCode,
color: "text-green-600",
},
{
extensions: ["liquid"],
icon: IconCode,
color: "text-blue-600",
},
{
extensions: ["ejs", "pug", "jade"],
icon: IconCode,
color: "text-brown-600",
},
// Data Formats
{
extensions: ["graphql", "gql"],
icon: IconApi,
color: "text-pink-600",
},
{
extensions: ["proto", "protobuf"],
icon: IconApi,
color: "text-blue-700",
},
// Security & Certificates
{
extensions: ["pem", "crt", "cer", "key", "p12", "pfx"],
icon: IconLock,
color: "text-green-800",
},
// Web Assembly
{
extensions: ["wasm", "wat"],
icon: IconAtom,
color: "text-purple-700",
},
// Shaders
{
extensions: ["glsl", "hlsl", "vert", "frag", "geom"],
icon: IconDeviceDesktop,
color: "text-cyan-700",
},
// Specialized
{
extensions: ["vim", "vimrc"],
icon: IconCode,
color: "text-green-800",
},
{
extensions: ["eslintrc", "prettierrc", "babelrc"],
icon: IconSettings,
color: "text-yellow-700",
},
{
extensions: ["tsconfig", "jsconfig"],
icon: IconSettings,
color: "text-blue-700",
},
{
extensions: ["webpack", "rollup", "vite"],
icon: IconTool,
color: "text-cyan-600",
},
{
extensions: ["lock", "sum"],
icon: IconLock,
color: "text-gray-600",
},
// Fallback for general text/code files
{
extensions: ["svelte", "astro", "erb", "haml", "slim"],
icon: IconFileCode,
color: "text-gray-600",
},
]; ];
export function getFileIcon(filename: string): { icon: Icon; color: string } { export function getFileIcon(filename: string): { icon: Icon; color: string } {

View File

@@ -0,0 +1,374 @@
export type FileType = "pdf" | "image" | "audio" | "video" | "text" | "other";
export function getFileType(fileName: string): FileType {
const extension = fileName.split(".").pop()?.toLowerCase();
if (extension === "pdf") return "pdf";
if (["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "tiff"].includes(extension || "")) {
return "image";
}
if (["mp3", "wav", "ogg", "m4a", "aac", "flac"].includes(extension || "")) {
return "audio";
}
if (["mp4", "webm", "ogg", "mov", "avi", "mkv", "wmv", "flv", "m4v"].includes(extension || "")) {
return "video";
}
const textExtensions = [
// Data formats
"json",
"json5",
"jsonp",
"txt",
"csv",
"xml",
"svg",
"toml",
"yaml",
"yml",
"ini",
"conf",
"config",
"env",
"properties",
// Documentation
"md",
"markdown",
"adoc",
"asciidoc",
"rst",
"textile",
"wiki",
"log",
// Web technologies
"html",
"htm",
"xhtml",
"css",
"scss",
"sass",
"less",
"stylus",
// JavaScript ecosystem
"js",
"jsx",
"ts",
"tsx",
"mjs",
"cjs",
"vue",
"svelte",
"coffee",
"coffeescript",
// Programming languages
"php",
"py",
"pyw",
"rb",
"java",
"kt",
"kts",
"scala",
"clj",
"cljs",
"cljc",
"hs",
"elm",
"f#",
"fs",
"fsx",
"vb",
"vba",
"c",
"cpp",
"cxx",
"cc",
"h",
"hpp",
"hxx",
"cs",
"go",
"rs",
"swift",
"dart",
"r",
"rmd",
"pl",
"pm",
// Shell scripts
"sh",
"bash",
"zsh",
"fish",
"ps1",
"bat",
"cmd",
// Database
"sql",
"plsql",
"psql",
"mysql",
"sqlite",
// Configuration files
"dockerfile",
"containerfile",
"gitignore",
"gitattributes",
"gitmodules",
"gitconfig",
"editorconfig",
"eslintrc",
"prettierrc",
"stylelintrc",
"babelrc",
"browserslistrc",
"tsconfig",
"jsconfig",
"webpack",
"rollup",
"vite",
"astro",
// Package managers
"package",
"composer",
"gemfile",
"podfile",
"pipfile",
"poetry",
"pyproject",
"requirements",
"cargo",
"go.mod",
"go.sum",
"sbt",
"build.gradle",
"build.sbt",
"pom",
"build",
// Build tools
"makefile",
"cmake",
"rakefile",
"gradle",
"gulpfile",
"gruntfile",
"justfile",
// Templates
"hbs",
"handlebars",
"mustache",
"twig",
"jinja",
"jinja2",
"liquid",
"ejs",
"pug",
"jade",
// Data serialization
"proto",
"protobuf",
"avro",
"thrift",
"graphql",
"gql",
// Markup & styling
"tex",
"latex",
"bibtex",
"rtf",
"org",
"pod",
// Specialized formats
"vim",
"vimrc",
"tmux",
"nginx",
"apache",
"htaccess",
"robots",
"sitemap",
"webmanifest",
"lock",
"sum",
"mod",
"workspace",
"solution",
"sln",
"csproj",
"vcxproj",
"xcodeproj",
// Additional programming languages
"lua",
"rb",
"php",
"asp",
"aspx",
"jsp",
"erb",
"haml",
"slim",
"perl",
"awk",
"sed",
"tcl",
"groovy",
"scala",
"rust",
"zig",
"nim",
"crystal",
"julia",
"matlab",
"octave",
"wolfram",
"mathematica",
"sage",
"maxima",
"fortran",
"cobol",
"ada",
"pascal",
"delphi",
"basic",
"vb6",
"assembly",
"asm",
"s",
"nasm",
"gas",
"lisp",
"scheme",
"racket",
"clojure",
"erlang",
"elixir",
"haskell",
"ocaml",
"fsharp",
"prolog",
"mercury",
"curry",
"clean",
"idris",
"agda",
"coq",
"lean",
"smalltalk",
"forth",
"factor",
"postscript",
"tcl",
"tk",
"expect",
"applescript",
"powershell",
"autohotkey",
"ahk",
"autoit",
"nsis",
// Web assembly and low level
"wasm",
"wat",
"wast",
"wit",
"wai",
// Shaders
"glsl",
"hlsl",
"cg",
"fx",
"fxh",
"vsh",
"fsh",
"vert",
"frag",
"geom",
"tesc",
"tese",
"comp",
// Game development
"gdscript",
"gd",
"cs",
"boo",
"unityscript",
"mel",
"maxscript",
"haxe",
"as",
"actionscript",
// DevOps & Infrastructure
"tf",
"tfvars",
"hcl",
"nomad",
"consul",
"vault",
"packer",
"ansible",
"puppet",
"chef",
"salt",
"k8s",
"kubernetes",
"helm",
"kustomize",
"skaffold",
"tilt",
"buildkite",
"circleci",
"travis",
"jenkins",
"github",
"gitlab",
"bitbucket",
"azure",
"aws",
"gcp",
"terraform",
"cloudformation",
// Documentation generators
"jsdoc",
"javadoc",
"godoc",
"rustdoc",
"sphinx",
"mkdocs",
"gitbook",
"jekyll",
"hugo",
"gatsby",
];
if (textExtensions.includes(extension || "")) {
return "text";
}
return "other";
}
export function getFileExtension(fileName: string): string {
return fileName.split(".").pop()?.toLowerCase() || "";
}