mirror of
				https://github.com/kyantech/Palmr.git
				synced 2025-11-03 21:43:20 +00:00 
			
		
		
		
	Compare commits
	
		
			32 Commits
		
	
	
		
			v3.0.0-bet
			...
			v3.0.0-bet
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					c265b8e08d | ||
| 
						 | 
					d0173a0bf9 | ||
| 
						 | 
					0d346b75cc | ||
| 
						 | 
					0a65917cbf | ||
| 
						 | 
					f651f50180 | ||
| 
						 | 
					1125665bb1 | ||
| 
						 | 
					b65aac3044 | ||
| 
						 | 
					a865aabed0 | ||
| 
						 | 
					561e8faf33 | ||
| 
						 | 
					6445b0ce3e | ||
| 
						 | 
					90cd3333cb | ||
| 
						 | 
					2ca0db70c3 | ||
| 
						 | 
					28697fa270 | ||
| 
						 | 
					d739c1b213 | ||
| 
						 | 
					25a0c39135 | ||
| 
						 | 
					185fa4c191 | ||
| 
						 | 
					9dfb034c2e | ||
| 
						 | 
					936a2b71c7 | ||
| 
						 | 
					cd14c28be1 | ||
| 
						 | 
					3c084a6686 | ||
| 
						 | 
					6a1381684b | ||
| 
						 | 
					dc20770fe6 | ||
| 
						 | 
					6e526f7f88 | ||
| 
						 | 
					858852c8cd | ||
| 
						 | 
					363dedbb2c | ||
| 
						 | 
					cd215c79b8 | ||
| 
						 | 
					98586efbcd | ||
| 
						 | 
					c724e644c7 | ||
| 
						 | 
					555ff18a87 | ||
| 
						 | 
					5100e1591b | ||
| 
						 | 
					6de29bbf07 | ||
| 
						 | 
					39c47be940 | 
@@ -137,6 +137,22 @@ The setup process varies depending on your chosen identity provider. Here are ex
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
### Zitadel
 | 
			
		||||
1. **Create New ProjectApp**: In your desired Zitadel project, create a new application
 | 
			
		||||
2. **Name and Type**: Give your application a name and choose **WEB** as the application type
 | 
			
		||||
3. **Authentication Method**: Choose Code
 | 
			
		||||
4. **Set Redirect URI**: Add your Palmr callback URL to valid redirect URIs
 | 
			
		||||
5. **Finish**: After reviewing the configuration create the application
 | 
			
		||||
6. **Copy the client ID and client Secrat**: Copy the client id paste it into the **Client ID** of your Palmr OIDC condiguration Form, repeat for the client secret and paste it into the **Client Secret** field
 | 
			
		||||
7. **Obtain your Provider URL**: In your Zitadel application go to **URLs** and copy the **Authorization Endpoint (remove the /authorize from that url)** e.g. https://auth.example.com/oauth/v2
 | 
			
		||||
 | 
			
		||||
**Configuration values:**
 | 
			
		||||
 | 
			
		||||
- **Issuer URL**: Depends on your Zitadel installation and project. Example: `https://auth.example.com/oauth/v2`
 | 
			
		||||
- **Scope**: `openid profile email`
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## Testing OIDC configuration
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								apps/docs/public/assets/v3/oidc/zitadel-provider-setup.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								apps/docs/public/assets/v3/oidc/zitadel-provider-setup.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 263 KiB  | 
@@ -6,40 +6,24 @@
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
export const timeoutConfig = {
 | 
			
		||||
  // Connection timeouts
 | 
			
		||||
  connection: {
 | 
			
		||||
    // How long to wait for initial connection (0 = disabled)
 | 
			
		||||
    timeout: 0,
 | 
			
		||||
 | 
			
		||||
    // Keep-alive timeout for long-running uploads/downloads
 | 
			
		||||
    // 20 hours should be enough for most large file operations
 | 
			
		||||
    keepAlive: 20 * 60 * 60 * 1000, // 20 hours in milliseconds
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  // Request timeouts
 | 
			
		||||
  request: {
 | 
			
		||||
    // Global request timeout (0 = disabled, let requests run indefinitely)
 | 
			
		||||
    timeout: 0,
 | 
			
		||||
 | 
			
		||||
    // Body parsing timeout for large files
 | 
			
		||||
    bodyTimeout: 0, // Disabled for large files
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  // File operation timeouts
 | 
			
		||||
  file: {
 | 
			
		||||
    // Maximum time to wait for file upload (0 = no limit)
 | 
			
		||||
    uploadTimeout: 0,
 | 
			
		||||
 | 
			
		||||
    // Maximum time to wait for file download (0 = no limit)
 | 
			
		||||
    downloadTimeout: 0,
 | 
			
		||||
 | 
			
		||||
    // Streaming chunk timeout (time between chunks)
 | 
			
		||||
    streamTimeout: 30 * 1000, // 30 seconds between chunks
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  // Token expiration (for filesystem storage)
 | 
			
		||||
  token: {
 | 
			
		||||
    // How long upload/download tokens remain valid
 | 
			
		||||
    expiration: 60 * 60 * 1000, // 1 hour in milliseconds
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
@@ -52,7 +36,6 @@ export function getTimeoutForFileSize(fileSizeBytes: number) {
 | 
			
		||||
  const fileSizeGB = fileSizeBytes / (1024 * 1024 * 1024);
 | 
			
		||||
 | 
			
		||||
  if (fileSizeGB > 100) {
 | 
			
		||||
    // For files larger than 100GB, extend token expiration
 | 
			
		||||
    return {
 | 
			
		||||
      ...timeoutConfig,
 | 
			
		||||
      token: {
 | 
			
		||||
@@ -62,7 +45,6 @@ export function getTimeoutForFileSize(fileSizeBytes: number) {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (fileSizeGB > 10) {
 | 
			
		||||
    // For files larger than 10GB, extend token expiration
 | 
			
		||||
    return {
 | 
			
		||||
      ...timeoutConfig,
 | 
			
		||||
      token: {
 | 
			
		||||
@@ -79,15 +61,12 @@ export function getTimeoutForFileSize(fileSizeBytes: number) {
 | 
			
		||||
 * You can set these in your .env file to override defaults
 | 
			
		||||
 */
 | 
			
		||||
export const envTimeoutOverrides = {
 | 
			
		||||
  // Override connection keep-alive if set in environment
 | 
			
		||||
  keepAliveTimeout: process.env.KEEP_ALIVE_TIMEOUT
 | 
			
		||||
    ? parseInt(process.env.KEEP_ALIVE_TIMEOUT)
 | 
			
		||||
    : timeoutConfig.connection.keepAlive,
 | 
			
		||||
 | 
			
		||||
  // Override request timeout if set in environment
 | 
			
		||||
  requestTimeout: process.env.REQUEST_TIMEOUT ? parseInt(process.env.REQUEST_TIMEOUT) : timeoutConfig.request.timeout,
 | 
			
		||||
 | 
			
		||||
  // Override token expiration if set in environment
 | 
			
		||||
  tokenExpiration: process.env.TOKEN_EXPIRATION
 | 
			
		||||
    ? parseInt(process.env.TOKEN_EXPIRATION)
 | 
			
		||||
    : timeoutConfig.token.expiration,
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,21 @@ import { FastifyReply, FastifyRequest } from "fastify";
 | 
			
		||||
import fs from "fs";
 | 
			
		||||
import path from "path";
 | 
			
		||||
 | 
			
		||||
const uploadsDir = path.join(process.cwd(), "uploads/logo");
 | 
			
		||||
const isDocker = (() => {
 | 
			
		||||
  try {
 | 
			
		||||
    require("fs").statSync("/.dockerenv");
 | 
			
		||||
    return true;
 | 
			
		||||
  } catch {
 | 
			
		||||
    try {
 | 
			
		||||
      return require("fs").readFileSync("/proc/self/cgroup", "utf8").includes("docker");
 | 
			
		||||
    } catch {
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
})();
 | 
			
		||||
 | 
			
		||||
const baseDir = isDocker ? "/app/server" : process.cwd();
 | 
			
		||||
const uploadsDir = path.join(baseDir, "uploads/logo");
 | 
			
		||||
if (!fs.existsSync(uploadsDir)) {
 | 
			
		||||
  fs.mkdirSync(uploadsDir, { recursive: true });
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
import { env } from "../../env";
 | 
			
		||||
import { LoginSchema, RequestPasswordResetSchema, createResetPasswordSchema } from "./dto";
 | 
			
		||||
import { AuthService } from "./service";
 | 
			
		||||
import { env } from "env";
 | 
			
		||||
import { FastifyReply, FastifyRequest } from "fastify";
 | 
			
		||||
 | 
			
		||||
export class AuthController {
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ export const createPasswordSchema = async () => {
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const LoginSchema = z.object({
 | 
			
		||||
  email: z.string().email("Invalid email").describe("User email"),
 | 
			
		||||
  emailOrUsername: z.string().min(1, "Email or username is required").describe("User email or username"),
 | 
			
		||||
  password: z.string().min(6, "Password must be at least 6 characters").describe("User password"),
 | 
			
		||||
});
 | 
			
		||||
export type LoginInput = z.infer<typeof LoginSchema>;
 | 
			
		||||
 
 | 
			
		||||
@@ -17,7 +17,7 @@ export async function authRoutes(app: FastifyInstance) {
 | 
			
		||||
 | 
			
		||||
  const passwordSchema = await createPasswordSchema();
 | 
			
		||||
  const loginSchema = z.object({
 | 
			
		||||
    email: z.string().email("Invalid email").describe("User email"),
 | 
			
		||||
    emailOrUsername: z.string().min(1, "Email or username is required").describe("User email or username"),
 | 
			
		||||
    password: passwordSchema,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -13,7 +13,7 @@ export class AuthService {
 | 
			
		||||
  private emailService = new EmailService();
 | 
			
		||||
 | 
			
		||||
  async login(data: LoginInput) {
 | 
			
		||||
    const user = await this.userRepository.findUserByEmail(data.email);
 | 
			
		||||
    const user = await this.userRepository.findUserByEmailOrUsername(data.emailOrUsername);
 | 
			
		||||
    if (!user) {
 | 
			
		||||
      throw new Error("Invalid credentials");
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,4 @@
 | 
			
		||||
import { ConfigService } from "../config/service";
 | 
			
		||||
import { env } from "env";
 | 
			
		||||
import nodemailer from "nodemailer";
 | 
			
		||||
 | 
			
		||||
export class EmailService {
 | 
			
		||||
@@ -14,7 +13,7 @@ export class EmailService {
 | 
			
		||||
    return nodemailer.createTransport({
 | 
			
		||||
      host: await this.configService.getValue("smtpHost"),
 | 
			
		||||
      port: Number(await this.configService.getValue("smtpPort")),
 | 
			
		||||
      secure: env.SECURE_SITE === "true" ? true : false,
 | 
			
		||||
      secure: false,
 | 
			
		||||
      auth: {
 | 
			
		||||
        user: await this.configService.getValue("smtpUser"),
 | 
			
		||||
        pass: await this.configService.getValue("smtpPass"),
 | 
			
		||||
 
 | 
			
		||||
@@ -98,23 +98,51 @@ export class FilesystemController {
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      try {
 | 
			
		||||
        await fs.promises.unlink(tempPath);
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.error("Error deleting temp file:", error);
 | 
			
		||||
      } catch (cleanupError) {
 | 
			
		||||
        console.error("Error deleting temp file:", cleanupError);
 | 
			
		||||
      }
 | 
			
		||||
      throw error;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async uploadSmallFile(request: FastifyRequest, provider: FilesystemStorageProvider, objectName: string) {
 | 
			
		||||
    const stream = request.body as any;
 | 
			
		||||
    const body = request.body as any;
 | 
			
		||||
 | 
			
		||||
    if (Buffer.isBuffer(body)) {
 | 
			
		||||
      if (body.length === 0) {
 | 
			
		||||
        throw new Error("No file data received");
 | 
			
		||||
      }
 | 
			
		||||
      await provider.uploadFile(objectName, body);
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (typeof body === "string") {
 | 
			
		||||
      const buffer = Buffer.from(body, "utf8");
 | 
			
		||||
      if (buffer.length === 0) {
 | 
			
		||||
        throw new Error("No file data received");
 | 
			
		||||
      }
 | 
			
		||||
      await provider.uploadFile(objectName, buffer);
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (typeof body === "object" && body !== null && !body.on) {
 | 
			
		||||
      const buffer = Buffer.from(JSON.stringify(body), "utf8");
 | 
			
		||||
      if (buffer.length === 0) {
 | 
			
		||||
        throw new Error("No file data received");
 | 
			
		||||
      }
 | 
			
		||||
      await provider.uploadFile(objectName, buffer);
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (body && typeof body.on === "function") {
 | 
			
		||||
      const chunks: Buffer[] = [];
 | 
			
		||||
 | 
			
		||||
      return new Promise<void>((resolve, reject) => {
 | 
			
		||||
      stream.on("data", (chunk: Buffer) => {
 | 
			
		||||
        body.on("data", (chunk: Buffer) => {
 | 
			
		||||
          chunks.push(chunk);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
      stream.on("end", async () => {
 | 
			
		||||
        body.on("end", async () => {
 | 
			
		||||
          try {
 | 
			
		||||
            const buffer = Buffer.concat(chunks);
 | 
			
		||||
 | 
			
		||||
@@ -130,13 +158,24 @@ export class FilesystemController {
 | 
			
		||||
          }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
      stream.on("error", (error: Error) => {
 | 
			
		||||
        body.on("error", (error: Error) => {
 | 
			
		||||
          console.error("Error reading upload stream:", error);
 | 
			
		||||
          reject(error);
 | 
			
		||||
        });
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      const buffer = Buffer.from(body);
 | 
			
		||||
      if (buffer.length === 0) {
 | 
			
		||||
        throw new Error("No file data received");
 | 
			
		||||
      }
 | 
			
		||||
      await provider.uploadFile(objectName, buffer);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      throw new Error(`Unsupported request body type: ${typeof body}. Expected stream, buffer, string, or object.`);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async download(request: FastifyRequest, reply: FastifyReply) {
 | 
			
		||||
    try {
 | 
			
		||||
      const { token } = request.params as { token: string };
 | 
			
		||||
 
 | 
			
		||||
@@ -9,6 +9,10 @@ export async function filesystemRoutes(app: FastifyInstance) {
 | 
			
		||||
    return payload;
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  app.addContentTypeParser("application/json", async (request: FastifyRequest, payload: any) => {
 | 
			
		||||
    return payload;
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  app.put(
 | 
			
		||||
    "/filesystem/upload/:token",
 | 
			
		||||
    {
 | 
			
		||||
 
 | 
			
		||||
@@ -6,7 +6,6 @@ import { z } from "zod";
 | 
			
		||||
export async function oidcRoutes(fastify: FastifyInstance) {
 | 
			
		||||
  const oidcController = new OIDCController();
 | 
			
		||||
 | 
			
		||||
  // Get OIDC configuration
 | 
			
		||||
  fastify.get(
 | 
			
		||||
    "/config",
 | 
			
		||||
    {
 | 
			
		||||
@@ -27,7 +26,6 @@ export async function oidcRoutes(fastify: FastifyInstance) {
 | 
			
		||||
    oidcController.getConfig.bind(oidcController)
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  // Initiate OIDC authorization
 | 
			
		||||
  fastify.get(
 | 
			
		||||
    "/authorize",
 | 
			
		||||
    {
 | 
			
		||||
@@ -54,7 +52,6 @@ export async function oidcRoutes(fastify: FastifyInstance) {
 | 
			
		||||
    oidcController.authorize.bind(oidcController)
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  // Handle OIDC callback
 | 
			
		||||
  fastify.get(
 | 
			
		||||
    "/callback",
 | 
			
		||||
    {
 | 
			
		||||
 
 | 
			
		||||
@@ -26,7 +26,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  // Create reverse share (authenticated)
 | 
			
		||||
  app.post(
 | 
			
		||||
    "/reverse-shares",
 | 
			
		||||
    {
 | 
			
		||||
@@ -50,7 +49,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
 | 
			
		||||
    reverseShareController.createReverseShare.bind(reverseShareController)
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  // List user's reverse shares (authenticated)
 | 
			
		||||
  app.get(
 | 
			
		||||
    "/reverse-shares",
 | 
			
		||||
    {
 | 
			
		||||
@@ -72,7 +70,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
 | 
			
		||||
    reverseShareController.listUserReverseShares.bind(reverseShareController)
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  // Get reverse share by ID (authenticated)
 | 
			
		||||
  app.get(
 | 
			
		||||
    "/reverse-shares/:id",
 | 
			
		||||
    {
 | 
			
		||||
@@ -98,7 +95,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
 | 
			
		||||
    reverseShareController.getReverseShare.bind(reverseShareController)
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  // Update reverse share (authenticated)
 | 
			
		||||
  app.put(
 | 
			
		||||
    "/reverse-shares",
 | 
			
		||||
    {
 | 
			
		||||
@@ -123,7 +119,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
 | 
			
		||||
    reverseShareController.updateReverseShare.bind(reverseShareController)
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  // Update reverse share password (authenticated)
 | 
			
		||||
  app.put(
 | 
			
		||||
    "/reverse-shares/:id/password",
 | 
			
		||||
    {
 | 
			
		||||
@@ -151,7 +146,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
 | 
			
		||||
    reverseShareController.updatePassword.bind(reverseShareController)
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  // Delete reverse share (authenticated)
 | 
			
		||||
  app.delete(
 | 
			
		||||
    "/reverse-shares/:id",
 | 
			
		||||
    {
 | 
			
		||||
@@ -177,7 +171,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
 | 
			
		||||
    reverseShareController.deleteReverseShare.bind(reverseShareController)
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  // Get reverse share for upload (public)
 | 
			
		||||
  app.get(
 | 
			
		||||
    "/reverse-shares/:id/upload",
 | 
			
		||||
    {
 | 
			
		||||
@@ -207,7 +200,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
 | 
			
		||||
    reverseShareController.getReverseShareForUpload.bind(reverseShareController)
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  // Get reverse share for upload by alias (public)
 | 
			
		||||
  app.get(
 | 
			
		||||
    "/reverse-shares/alias/:alias/upload",
 | 
			
		||||
    {
 | 
			
		||||
@@ -237,7 +229,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
 | 
			
		||||
    reverseShareController.getReverseShareForUploadByAlias.bind(reverseShareController)
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  // Get presigned URL for file upload (public)
 | 
			
		||||
  app.post(
 | 
			
		||||
    "/reverse-shares/:id/presigned-url",
 | 
			
		||||
    {
 | 
			
		||||
@@ -269,7 +260,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
 | 
			
		||||
    reverseShareController.getPresignedUrl.bind(reverseShareController)
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  // Get presigned URL for file upload by alias (public)
 | 
			
		||||
  app.post(
 | 
			
		||||
    "/reverse-shares/alias/:alias/presigned-url",
 | 
			
		||||
    {
 | 
			
		||||
@@ -301,7 +291,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
 | 
			
		||||
    reverseShareController.getPresignedUrlByAlias.bind(reverseShareController)
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  // Register file upload completion (public)
 | 
			
		||||
  app.post(
 | 
			
		||||
    "/reverse-shares/:id/register-file",
 | 
			
		||||
    {
 | 
			
		||||
@@ -333,7 +322,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
 | 
			
		||||
    reverseShareController.registerFileUpload.bind(reverseShareController)
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  // Register file upload completion by alias (public)
 | 
			
		||||
  app.post(
 | 
			
		||||
    "/reverse-shares/alias/:alias/register-file",
 | 
			
		||||
    {
 | 
			
		||||
@@ -365,7 +353,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
 | 
			
		||||
    reverseShareController.registerFileUploadByAlias.bind(reverseShareController)
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  // Check password (public)
 | 
			
		||||
  app.post(
 | 
			
		||||
    "/reverse-shares/:id/check-password",
 | 
			
		||||
    {
 | 
			
		||||
@@ -394,7 +381,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
 | 
			
		||||
    reverseShareController.checkPassword.bind(reverseShareController)
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  // Download file from reverse share (authenticated)
 | 
			
		||||
  app.get(
 | 
			
		||||
    "/reverse-shares/files/:fileId/download",
 | 
			
		||||
    {
 | 
			
		||||
@@ -421,7 +407,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
 | 
			
		||||
    reverseShareController.downloadFile.bind(reverseShareController)
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  // Delete file from reverse share (authenticated)
 | 
			
		||||
  app.delete(
 | 
			
		||||
    "/reverse-shares/files/:fileId",
 | 
			
		||||
    {
 | 
			
		||||
@@ -447,7 +432,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
 | 
			
		||||
    reverseShareController.deleteFile.bind(reverseShareController)
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  // Create or update reverse share alias (authenticated)
 | 
			
		||||
  app.post(
 | 
			
		||||
    "/reverse-shares/:reverseShareId/alias",
 | 
			
		||||
    {
 | 
			
		||||
@@ -486,7 +470,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
 | 
			
		||||
    reverseShareController.createOrUpdateAlias.bind(reverseShareController)
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  // Activate reverse share (authenticated)
 | 
			
		||||
  app.patch(
 | 
			
		||||
    "/reverse-shares/:id/activate",
 | 
			
		||||
    {
 | 
			
		||||
@@ -512,7 +495,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
 | 
			
		||||
    reverseShareController.activateReverseShare.bind(reverseShareController)
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  // Deactivate reverse share (authenticated)
 | 
			
		||||
  app.patch(
 | 
			
		||||
    "/reverse-shares/:id/deactivate",
 | 
			
		||||
    {
 | 
			
		||||
@@ -538,7 +520,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
 | 
			
		||||
    reverseShareController.deactivateReverseShare.bind(reverseShareController)
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  // Update file from reverse share (authenticated)
 | 
			
		||||
  app.put(
 | 
			
		||||
    "/reverse-shares/files/:fileId",
 | 
			
		||||
    {
 | 
			
		||||
 
 | 
			
		||||
@@ -168,7 +168,6 @@ export class ReverseShareService {
 | 
			
		||||
      throw new Error("Unauthorized to delete this reverse share");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Delete all files associated with this reverse share
 | 
			
		||||
    for (const file of reverseShare.files) {
 | 
			
		||||
      try {
 | 
			
		||||
        await this.fileService.deleteObject(file.objectName);
 | 
			
		||||
@@ -265,7 +264,6 @@ export class ReverseShareService {
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Check file count limit
 | 
			
		||||
    if (reverseShare.maxFiles) {
 | 
			
		||||
      const currentFileCount = await this.reverseShareRepository.countFilesByReverseShareId(reverseShareId);
 | 
			
		||||
      if (currentFileCount >= reverseShare.maxFiles) {
 | 
			
		||||
@@ -273,12 +271,10 @@ export class ReverseShareService {
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Check file size limit
 | 
			
		||||
    if (reverseShare.maxFileSize && BigInt(fileData.size) > reverseShare.maxFileSize) {
 | 
			
		||||
      throw new Error("File size exceeds limit");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Check allowed file types
 | 
			
		||||
    if (reverseShare.allowedFileTypes) {
 | 
			
		||||
      const allowedTypes = reverseShare.allowedFileTypes.split(",").map((type) => type.trim().toLowerCase());
 | 
			
		||||
      if (!allowedTypes.includes(fileData.extension.toLowerCase())) {
 | 
			
		||||
@@ -318,7 +314,6 @@ export class ReverseShareService {
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Check file count limit
 | 
			
		||||
    if (reverseShare.maxFiles) {
 | 
			
		||||
      const currentFileCount = await this.reverseShareRepository.countFilesByReverseShareId(reverseShare.id);
 | 
			
		||||
      if (currentFileCount >= reverseShare.maxFiles) {
 | 
			
		||||
@@ -326,12 +321,10 @@ export class ReverseShareService {
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Check file size limit
 | 
			
		||||
    if (reverseShare.maxFileSize && BigInt(fileData.size) > reverseShare.maxFileSize) {
 | 
			
		||||
      throw new Error("File size exceeds limit");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Check allowed file types
 | 
			
		||||
    if (reverseShare.allowedFileTypes) {
 | 
			
		||||
      const allowedTypes = reverseShare.allowedFileTypes.split(",").map((type) => type.trim().toLowerCase());
 | 
			
		||||
      if (!allowedTypes.includes(fileData.extension.toLowerCase())) {
 | 
			
		||||
@@ -372,10 +365,8 @@ export class ReverseShareService {
 | 
			
		||||
      throw new Error("Unauthorized to delete this file");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Delete from storage
 | 
			
		||||
    await this.fileService.deleteObject(file.objectName);
 | 
			
		||||
 | 
			
		||||
    // Delete from database
 | 
			
		||||
    const deletedFile = await this.reverseShareRepository.deleteFile(fileId);
 | 
			
		||||
    return this.formatFileResponse(deletedFile);
 | 
			
		||||
  }
 | 
			
		||||
@@ -473,7 +464,6 @@ export class ReverseShareService {
 | 
			
		||||
    data: { name?: string; description?: string | null },
 | 
			
		||||
    creatorId: string
 | 
			
		||||
  ) {
 | 
			
		||||
    // Verificar se o arquivo existe e se o usuário tem permissão
 | 
			
		||||
    const file = await this.reverseShareRepository.findFileById(fileId);
 | 
			
		||||
    if (!file) {
 | 
			
		||||
      throw new Error("File not found");
 | 
			
		||||
@@ -483,13 +473,10 @@ export class ReverseShareService {
 | 
			
		||||
      throw new Error("Unauthorized to edit this file");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Se o nome está sendo atualizado, preservar a extensão original
 | 
			
		||||
    const updateData = { ...data };
 | 
			
		||||
    if (data.name) {
 | 
			
		||||
      const originalExtension = file.extension;
 | 
			
		||||
      // Remove qualquer extensão que o usuário possa ter digitado
 | 
			
		||||
      const nameWithoutExtension = data.name.replace(/\.[^/.]+$/, "");
 | 
			
		||||
      // Adiciona a extensão original (garantindo que tenha o ponto)
 | 
			
		||||
      const extensionWithDot = originalExtension.startsWith(".") ? originalExtension : `.${originalExtension}`;
 | 
			
		||||
      updateData.name = `${nameWithoutExtension}${extensionWithDot}`;
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -22,7 +22,20 @@ export class StorageController {
 | 
			
		||||
      const diskSpace = await this.storageService.getDiskSpace(userId, isAdmin);
 | 
			
		||||
      return reply.send(diskSpace);
 | 
			
		||||
    } catch (error: any) {
 | 
			
		||||
      return reply.status(500).send({ error: error.message });
 | 
			
		||||
      console.error("Controller error in getDiskSpace:", error);
 | 
			
		||||
 | 
			
		||||
      if (error.message?.includes("Unable to determine actual disk space")) {
 | 
			
		||||
        return reply.status(503).send({
 | 
			
		||||
          error: "Disk space detection unavailable - system configuration issue",
 | 
			
		||||
          details: "Please check system permissions and available disk utilities",
 | 
			
		||||
          code: "DISK_SPACE_DETECTION_FAILED",
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return reply.status(500).send({
 | 
			
		||||
        error: "Failed to retrieve disk space information",
 | 
			
		||||
        details: error.message || "Unknown error occurred",
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,35 +1,134 @@
 | 
			
		||||
import { IS_RUNNING_IN_CONTAINER } from "../../utils/container-detection";
 | 
			
		||||
import { ConfigService } from "../config/service";
 | 
			
		||||
import { PrismaClient } from "@prisma/client";
 | 
			
		||||
import { exec } from "child_process";
 | 
			
		||||
import fs from "node:fs";
 | 
			
		||||
import { promisify } from "util";
 | 
			
		||||
import fs from 'node:fs';
 | 
			
		||||
 | 
			
		||||
const execAsync = promisify(exec);
 | 
			
		||||
const prisma = new PrismaClient();
 | 
			
		||||
 | 
			
		||||
export class StorageService {
 | 
			
		||||
  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 {
 | 
			
		||||
      fs.statSync('/.dockerenv');
 | 
			
		||||
      return true;
 | 
			
		||||
    } catch {
 | 
			
		||||
      return false;
 | 
			
		||||
      console.log(`Trying disk space command: ${command}`);
 | 
			
		||||
      const { stdout, stderr } = await execAsync(command);
 | 
			
		||||
 | 
			
		||||
      if (stderr) {
 | 
			
		||||
        console.warn(`Command stderr: ${stderr}`);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      console.log(`Command stdout: ${stdout}`);
 | 
			
		||||
 | 
			
		||||
      let total = 0;
 | 
			
		||||
      let available = 0;
 | 
			
		||||
 | 
			
		||||
      if (process.platform === "win32") {
 | 
			
		||||
        const lines = stdout.trim().split("\n").slice(1);
 | 
			
		||||
        for (const line of lines) {
 | 
			
		||||
          const parts = line.trim().split(/\s+/);
 | 
			
		||||
          if (parts.length >= 3) {
 | 
			
		||||
            const [, size, freespace] = parts;
 | 
			
		||||
            total += this._safeParseInt(size);
 | 
			
		||||
            available += this._safeParseInt(freespace);
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      } else if (process.platform === "darwin") {
 | 
			
		||||
        const lines = stdout.trim().split("\n");
 | 
			
		||||
        if (lines.length >= 2) {
 | 
			
		||||
          const parts = lines[1].trim().split(/\s+/);
 | 
			
		||||
          if (parts.length >= 4) {
 | 
			
		||||
            const [, size, , avail] = parts;
 | 
			
		||||
            total = this._safeParseInt(size) * 1024;
 | 
			
		||||
            available = this._safeParseInt(avail) * 1024;
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      } else {
 | 
			
		||||
        const lines = stdout.trim().split("\n");
 | 
			
		||||
        if (lines.length >= 2) {
 | 
			
		||||
          const parts = lines[1].trim().split(/\s+/);
 | 
			
		||||
          if (parts.length >= 4) {
 | 
			
		||||
            const [, size, , avail] = parts;
 | 
			
		||||
            if (command.includes("-B1")) {
 | 
			
		||||
              total = this._safeParseInt(size);
 | 
			
		||||
              available = this._safeParseInt(avail);
 | 
			
		||||
            } else {
 | 
			
		||||
              total = this._safeParseInt(size) * 1024;
 | 
			
		||||
              available = this._safeParseInt(avail) * 1024;
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
  private _hasDockerCGroup() {
 | 
			
		||||
      if (total > 0 && available >= 0) {
 | 
			
		||||
        console.log(`Successfully parsed disk space: ${total} bytes total, ${available} bytes available`);
 | 
			
		||||
        return { total, available };
 | 
			
		||||
      } else {
 | 
			
		||||
        console.warn(`Invalid values parsed: total=${total}, available=${available}`);
 | 
			
		||||
        return null;
 | 
			
		||||
      }
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.warn(`Command failed: ${command}`, error);
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async _getDiskSpaceMultiplePaths(): Promise<{ total: number; available: number } | null> {
 | 
			
		||||
    const pathsToTry = IS_RUNNING_IN_CONTAINER
 | 
			
		||||
      ? ["/app/server/uploads", "/app/server", "/app", "/"]
 | 
			
		||||
      : [".", "./uploads", process.cwd()];
 | 
			
		||||
 | 
			
		||||
    for (const pathToCheck of pathsToTry) {
 | 
			
		||||
      console.log(`Trying path: ${pathToCheck}`);
 | 
			
		||||
 | 
			
		||||
      if (pathToCheck.includes("uploads")) {
 | 
			
		||||
        try {
 | 
			
		||||
      return fs.readFileSync('/proc/self/cgroup', 'utf8').includes('docker');
 | 
			
		||||
    } catch {
 | 
			
		||||
      return false;
 | 
			
		||||
          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;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
  private _isDocker() {
 | 
			
		||||
    return this.isDockerCached ?? (this._hasDockerEnv() || this._hasDockerCGroup());
 | 
			
		||||
      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(
 | 
			
		||||
@@ -43,49 +142,40 @@ export class StorageService {
 | 
			
		||||
  }> {
 | 
			
		||||
    try {
 | 
			
		||||
      if (isAdmin) {
 | 
			
		||||
        const isDocker = this._isDocker();
 | 
			
		||||
        const pathToCheck = isDocker ? "/app/server/uploads" : ".";
 | 
			
		||||
        console.log(`Running in container: ${IS_RUNNING_IN_CONTAINER}`);
 | 
			
		||||
 | 
			
		||||
        const command = process.platform === "win32"
 | 
			
		||||
          ? "wmic logicaldisk get size,freespace,caption"
 | 
			
		||||
          : process.platform === "darwin"
 | 
			
		||||
            ? `df -k ${pathToCheck}`
 | 
			
		||||
            : `df -B1 ${pathToCheck}`;
 | 
			
		||||
        const diskInfo = await this._getDiskSpaceMultiplePaths();
 | 
			
		||||
 | 
			
		||||
        const { stdout } = await execAsync(command);
 | 
			
		||||
        let total = 0;
 | 
			
		||||
        let available = 0;
 | 
			
		||||
        if (!diskInfo) {
 | 
			
		||||
          console.error("❌ CRITICAL: Could not determine disk space using any method!");
 | 
			
		||||
          console.error("This indicates a serious system issue. Please check:");
 | 
			
		||||
          console.error("1. File system permissions");
 | 
			
		||||
          console.error("2. Available disk utilities (df, wmic)");
 | 
			
		||||
          console.error("3. Container/system configuration");
 | 
			
		||||
 | 
			
		||||
        if (process.platform === "win32") {
 | 
			
		||||
          const lines = stdout.trim().split("\n").slice(1);
 | 
			
		||||
          for (const line of lines) {
 | 
			
		||||
            const [, size, freespace] = line.trim().split(/\s+/);
 | 
			
		||||
            total += parseInt(size) || 0;
 | 
			
		||||
            available += parseInt(freespace) || 0;
 | 
			
		||||
          }
 | 
			
		||||
        } else if (process.platform === "darwin") {
 | 
			
		||||
          const lines = stdout.trim().split("\n");
 | 
			
		||||
          const [, size, , avail] = lines[1].trim().split(/\s+/);
 | 
			
		||||
          total = parseInt(size) * 1024;
 | 
			
		||||
          available = parseInt(avail) * 1024;
 | 
			
		||||
        } else {
 | 
			
		||||
          const lines = stdout.trim().split("\n");
 | 
			
		||||
          const [, size, , avail] = lines[1].trim().split(/\s+/);
 | 
			
		||||
          total = parseInt(size);
 | 
			
		||||
          available = parseInt(avail);
 | 
			
		||||
          throw new Error("Unable to determine actual disk space - system configuration issue");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const { total, available } = diskInfo;
 | 
			
		||||
        const used = total - available;
 | 
			
		||||
 | 
			
		||||
        const diskSizeGB = this._ensureNumber(total / (1024 * 1024 * 1024), 0);
 | 
			
		||||
        const diskUsedGB = this._ensureNumber(used / (1024 * 1024 * 1024), 0);
 | 
			
		||||
        const diskAvailableGB = this._ensureNumber(available / (1024 * 1024 * 1024), 0);
 | 
			
		||||
 | 
			
		||||
        console.log(
 | 
			
		||||
          `✅ Real disk space: ${diskSizeGB.toFixed(2)}GB total, ${diskUsedGB.toFixed(2)}GB used, ${diskAvailableGB.toFixed(2)}GB available`
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        return {
 | 
			
		||||
          diskSizeGB: Number((total / (1024 * 1024 * 1024)).toFixed(2)),
 | 
			
		||||
          diskUsedGB: Number((used / (1024 * 1024 * 1024)).toFixed(2)),
 | 
			
		||||
          diskAvailableGB: Number((available / (1024 * 1024 * 1024)).toFixed(2)),
 | 
			
		||||
          uploadAllowed: true,
 | 
			
		||||
          diskSizeGB: Number(diskSizeGB.toFixed(2)),
 | 
			
		||||
          diskUsedGB: Number(diskUsedGB.toFixed(2)),
 | 
			
		||||
          diskAvailableGB: Number(diskAvailableGB.toFixed(2)),
 | 
			
		||||
          uploadAllowed: diskAvailableGB > 0.1, // At least 100MB free
 | 
			
		||||
        };
 | 
			
		||||
      } else if (userId) {
 | 
			
		||||
        const maxTotalStorage = BigInt(await this.configService.getValue("maxTotalStoragePerUser"));
 | 
			
		||||
        const maxStorageGB = Number(maxTotalStorage) / (1024 * 1024 * 1024);
 | 
			
		||||
        const maxStorageGB = this._ensureNumber(Number(maxTotalStorage) / (1024 * 1024 * 1024), 10);
 | 
			
		||||
 | 
			
		||||
        const userFiles = await prisma.file.findMany({
 | 
			
		||||
          where: { userId },
 | 
			
		||||
@@ -94,21 +184,24 @@ export class StorageService {
 | 
			
		||||
 | 
			
		||||
        const totalUsedStorage = userFiles.reduce((acc, file) => acc + file.size, BigInt(0));
 | 
			
		||||
 | 
			
		||||
        const usedStorageGB = Number(totalUsedStorage) / (1024 * 1024 * 1024);
 | 
			
		||||
        const availableStorageGB = maxStorageGB - usedStorageGB;
 | 
			
		||||
        const usedStorageGB = this._ensureNumber(Number(totalUsedStorage) / (1024 * 1024 * 1024), 0);
 | 
			
		||||
        const availableStorageGB = this._ensureNumber(maxStorageGB - usedStorageGB, 0);
 | 
			
		||||
 | 
			
		||||
        return {
 | 
			
		||||
          diskSizeGB: maxStorageGB,
 | 
			
		||||
          diskUsedGB: usedStorageGB,
 | 
			
		||||
          diskAvailableGB: availableStorageGB,
 | 
			
		||||
          diskSizeGB: Number(maxStorageGB.toFixed(2)),
 | 
			
		||||
          diskUsedGB: Number(usedStorageGB.toFixed(2)),
 | 
			
		||||
          diskAvailableGB: Number(availableStorageGB.toFixed(2)),
 | 
			
		||||
          uploadAllowed: availableStorageGB > 0,
 | 
			
		||||
        };
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      throw new Error("User ID is required for non-admin users");
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error("Error getting disk space:", error);
 | 
			
		||||
      throw new Error("Failed to get disk space information");
 | 
			
		||||
      console.error("❌ Error getting disk space:", error);
 | 
			
		||||
 | 
			
		||||
      throw new Error(
 | 
			
		||||
        `Failed to get disk space information: ${error instanceof Error ? error.message : String(error)}`
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -7,6 +7,7 @@ export interface IUserRepository {
 | 
			
		||||
  findUserByEmail(email: string): Promise<User | null>;
 | 
			
		||||
  findUserById(id: string): Promise<User | null>;
 | 
			
		||||
  findUserByUsername(username: string): Promise<User | null>;
 | 
			
		||||
  findUserByEmailOrUsername(emailOrUsername: string): Promise<User | null>;
 | 
			
		||||
  listUsers(): Promise<User[]>;
 | 
			
		||||
  updateUser(data: UpdateUserInput & { password?: string }): Promise<User>;
 | 
			
		||||
  deleteUser(id: string): Promise<User>;
 | 
			
		||||
@@ -41,6 +42,14 @@ export class PrismaUserRepository implements IUserRepository {
 | 
			
		||||
    return prisma.user.findUnique({ where: { username } });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async findUserByEmailOrUsername(emailOrUsername: string): Promise<User | null> {
 | 
			
		||||
    return prisma.user.findFirst({
 | 
			
		||||
      where: {
 | 
			
		||||
        OR: [{ email: emailOrUsername }, { username: emailOrUsername }],
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async listUsers(): Promise<User[]> {
 | 
			
		||||
    return prisma.user.findMany();
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -14,6 +14,7 @@ export async function userRoutes(app: FastifyInstance) {
 | 
			
		||||
      const usersCount = await prisma.user.count();
 | 
			
		||||
 | 
			
		||||
      if (usersCount > 0) {
 | 
			
		||||
        try {
 | 
			
		||||
          await request.jwtVerify();
 | 
			
		||||
          if (!request.user.isAdmin) {
 | 
			
		||||
            return reply
 | 
			
		||||
@@ -21,14 +22,18 @@ export async function userRoutes(app: FastifyInstance) {
 | 
			
		||||
              .send({ error: "Access restricted to administrators" })
 | 
			
		||||
              .description("Access restricted to administrators");
 | 
			
		||||
          }
 | 
			
		||||
      }
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      console.error(err);
 | 
			
		||||
        } catch (authErr) {
 | 
			
		||||
          console.error(authErr);
 | 
			
		||||
          return reply
 | 
			
		||||
            .status(401)
 | 
			
		||||
            .send({ error: "Unauthorized: a valid token is required to access this resource." })
 | 
			
		||||
            .description("Unauthorized: a valid token is required to access this resource.");
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      console.error(err);
 | 
			
		||||
      return reply.status(500).send({ error: "Internal server error" }).description("Internal server error");
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const createRegisterSchema = async () => {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,6 @@
 | 
			
		||||
import { env } from "../env";
 | 
			
		||||
import { StorageProvider } from "../types/storage";
 | 
			
		||||
import { IS_RUNNING_IN_CONTAINER } from "../utils/container-detection";
 | 
			
		||||
import * as crypto from "crypto";
 | 
			
		||||
import * as fsSync from "fs";
 | 
			
		||||
import * as fs from "fs/promises";
 | 
			
		||||
@@ -9,12 +10,14 @@ import { pipeline } from "stream/promises";
 | 
			
		||||
 | 
			
		||||
export class FilesystemStorageProvider implements StorageProvider {
 | 
			
		||||
  private static instance: FilesystemStorageProvider;
 | 
			
		||||
  private uploadsDir = path.join(process.cwd(), "uploads");
 | 
			
		||||
  private uploadsDir: string;
 | 
			
		||||
  private encryptionKey = env.ENCRYPTION_KEY;
 | 
			
		||||
  private uploadTokens = new Map<string, { objectName: string; expiresAt: number }>();
 | 
			
		||||
  private downloadTokens = new Map<string, { objectName: string; expiresAt: number; fileName?: string }>();
 | 
			
		||||
 | 
			
		||||
  private constructor() {
 | 
			
		||||
    this.uploadsDir = IS_RUNNING_IN_CONTAINER ? "/app/server/uploads" : path.join(process.cwd(), "uploads");
 | 
			
		||||
 | 
			
		||||
    this.ensureUploadsDir();
 | 
			
		||||
    setInterval(() => this.cleanExpiredTokens(), 5 * 60 * 1000);
 | 
			
		||||
  }
 | 
			
		||||
@@ -214,8 +217,10 @@ export class FilesystemStorageProvider implements StorageProvider {
 | 
			
		||||
    if (encryptedBuffer.length > 16) {
 | 
			
		||||
      try {
 | 
			
		||||
        return this.decryptFileBuffer(encryptedBuffer);
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.warn("Failed to decrypt with new method, trying legacy format");
 | 
			
		||||
      } catch (error: unknown) {
 | 
			
		||||
        if (error instanceof Error) {
 | 
			
		||||
          console.warn("Failed to decrypt with new method, trying legacy format", error.message);
 | 
			
		||||
        }
 | 
			
		||||
        return this.decryptFileLegacy(encryptedBuffer);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,6 @@ import * as readline from "readline";
 | 
			
		||||
 | 
			
		||||
const prisma = new PrismaClient();
 | 
			
		||||
 | 
			
		||||
// Função para ler entrada do usuário de forma assíncrona
 | 
			
		||||
function createReadlineInterface() {
 | 
			
		||||
  return readline.createInterface({
 | 
			
		||||
    input: process.stdin,
 | 
			
		||||
@@ -17,15 +16,12 @@ function question(rl: readline.Interface, query: string): Promise<string> {
 | 
			
		||||
  return new Promise((resolve) => rl.question(query, resolve));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Função para validar formato de email básico
 | 
			
		||||
function isValidEmail(email: string): boolean {
 | 
			
		||||
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
 | 
			
		||||
  return emailRegex.test(email);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Função para validar senha com base nas regras do sistema
 | 
			
		||||
function isValidPassword(password: string): boolean {
 | 
			
		||||
  // Minimum length baseado na configuração padrão do sistema (8 caracteres)
 | 
			
		||||
  return password.length >= 8;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -38,7 +34,6 @@ async function resetUserPassword() {
 | 
			
		||||
    console.log("This script allows you to reset a user's password directly from the Docker terminal.");
 | 
			
		||||
    console.log("⚠️  WARNING: This bypasses normal security checks. Use only when necessary!\n");
 | 
			
		||||
 | 
			
		||||
    // Solicitar email do usuário
 | 
			
		||||
    let email: string;
 | 
			
		||||
    let user: any;
 | 
			
		||||
 | 
			
		||||
@@ -55,7 +50,6 @@ async function resetUserPassword() {
 | 
			
		||||
        continue;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Buscar usuário no banco de dados
 | 
			
		||||
      user = await prisma.user.findUnique({
 | 
			
		||||
        where: { email: email.toLowerCase() },
 | 
			
		||||
        select: {
 | 
			
		||||
@@ -83,7 +77,6 @@ async function resetUserPassword() {
 | 
			
		||||
      break;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Mostrar informações do usuário encontrado
 | 
			
		||||
    console.log("\n✅ User found:");
 | 
			
		||||
    console.log(`   Name: ${user.firstName} ${user.lastName}`);
 | 
			
		||||
    console.log(`   Username: ${user.username}`);
 | 
			
		||||
@@ -91,14 +84,12 @@ async function resetUserPassword() {
 | 
			
		||||
    console.log(`   Status: ${user.isActive ? "Active" : "Inactive"}`);
 | 
			
		||||
    console.log(`   Admin: ${user.isAdmin ? "Yes" : "No"}\n`);
 | 
			
		||||
 | 
			
		||||
    // Confirmar se deseja prosseguir
 | 
			
		||||
    const confirm = await question(rl, "Do you want to reset the password for this user? (y/n): ");
 | 
			
		||||
    if (confirm.toLowerCase() !== "y") {
 | 
			
		||||
      console.log("\n👋 Operation cancelled.");
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Solicitar nova senha
 | 
			
		||||
    let newPassword: string;
 | 
			
		||||
    while (true) {
 | 
			
		||||
      console.log("\n🔑 Enter new password requirements:");
 | 
			
		||||
@@ -126,18 +117,15 @@ async function resetUserPassword() {
 | 
			
		||||
      break;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Hash da senha usando bcrypt (mesmo método usado pelo sistema)
 | 
			
		||||
    console.log("\n🔄 Hashing password...");
 | 
			
		||||
    const hashedPassword = await bcrypt.hash(newPassword, 10);
 | 
			
		||||
 | 
			
		||||
    // Atualizar senha no banco de dados
 | 
			
		||||
    console.log("💾 Updating password in database...");
 | 
			
		||||
    await prisma.user.update({
 | 
			
		||||
      where: { id: user.id },
 | 
			
		||||
      data: { password: hashedPassword },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Limpar tokens de reset de senha existentes para este usuário
 | 
			
		||||
    console.log("🧹 Cleaning up existing password reset tokens...");
 | 
			
		||||
    await prisma.passwordReset.deleteMany({
 | 
			
		||||
      where: {
 | 
			
		||||
@@ -159,7 +147,6 @@ async function resetUserPassword() {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Função para listar usuários (funcionalidade auxiliar)
 | 
			
		||||
async function listUsers() {
 | 
			
		||||
  try {
 | 
			
		||||
    console.log("\n👥 Registered Users:");
 | 
			
		||||
@@ -198,7 +185,6 @@ async function listUsers() {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Main function
 | 
			
		||||
async function main() {
 | 
			
		||||
  const args = process.argv.slice(2);
 | 
			
		||||
 | 
			
		||||
@@ -227,7 +213,6 @@ async function main() {
 | 
			
		||||
  await resetUserPassword();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Handle process termination
 | 
			
		||||
process.on("SIGINT", async () => {
 | 
			
		||||
  console.log("\n\n👋 Goodbye!");
 | 
			
		||||
  await prisma.$disconnect();
 | 
			
		||||
@@ -239,7 +224,6 @@ process.on("SIGTERM", async () => {
 | 
			
		||||
  process.exit(0);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Run the script
 | 
			
		||||
if (require.main === module) {
 | 
			
		||||
  main().catch(console.error);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -11,6 +11,7 @@ import { reverseShareRoutes } from "./modules/reverse-share/routes";
 | 
			
		||||
import { shareRoutes } from "./modules/share/routes";
 | 
			
		||||
import { storageRoutes } from "./modules/storage/routes";
 | 
			
		||||
import { userRoutes } from "./modules/user/routes";
 | 
			
		||||
import { IS_RUNNING_IN_CONTAINER } from "./utils/container-detection";
 | 
			
		||||
import fastifyMultipart from "@fastify/multipart";
 | 
			
		||||
import fastifyStatic from "@fastify/static";
 | 
			
		||||
import * as fs from "fs/promises";
 | 
			
		||||
@@ -26,21 +27,22 @@ if (typeof global.crypto === "undefined") {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function ensureDirectories() {
 | 
			
		||||
  const uploadsDir = path.join(process.cwd(), "uploads");
 | 
			
		||||
  const tempChunksDir = path.join(process.cwd(), "temp-chunks");
 | 
			
		||||
  const baseDir = IS_RUNNING_IN_CONTAINER ? "/app/server" : process.cwd();
 | 
			
		||||
  const uploadsDir = path.join(baseDir, "uploads");
 | 
			
		||||
  const tempChunksDir = path.join(baseDir, "temp-chunks");
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    await fs.access(uploadsDir);
 | 
			
		||||
  } catch {
 | 
			
		||||
    await fs.mkdir(uploadsDir, { recursive: true });
 | 
			
		||||
    console.log("📁 Created uploads directory");
 | 
			
		||||
    console.log(`📁 Created uploads directory: ${uploadsDir}`);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    await fs.access(tempChunksDir);
 | 
			
		||||
  } catch {
 | 
			
		||||
    await fs.mkdir(tempChunksDir, { recursive: true });
 | 
			
		||||
    console.log("📁 Created temp-chunks directory");
 | 
			
		||||
    console.log(`📁 Created temp-chunks directory: ${tempChunksDir}`);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -62,8 +64,11 @@ async function startServer() {
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (env.ENABLE_S3 !== "true") {
 | 
			
		||||
    const baseDir = IS_RUNNING_IN_CONTAINER ? "/app/server" : process.cwd();
 | 
			
		||||
    const uploadsPath = path.join(baseDir, "uploads");
 | 
			
		||||
 | 
			
		||||
    await app.register(fastifyStatic, {
 | 
			
		||||
      root: path.join(process.cwd(), "uploads"),
 | 
			
		||||
      root: uploadsPath,
 | 
			
		||||
      prefix: "/uploads/",
 | 
			
		||||
      decorateReply: false,
 | 
			
		||||
    });
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										45
									
								
								apps/server/src/utils/container-detection.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								apps/server/src/utils/container-detection.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,45 @@
 | 
			
		||||
import * as fsSync from "fs";
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Determines if the application is running inside a container environment.
 | 
			
		||||
 * Checks common container indicators like /.dockerenv and cgroup file patterns.
 | 
			
		||||
 *
 | 
			
		||||
 * This function caches its result after the first call for performance.
 | 
			
		||||
 *
 | 
			
		||||
 * @returns {boolean} True if running in a container, false otherwise.
 | 
			
		||||
 */
 | 
			
		||||
function isRunningInContainer(): boolean {
 | 
			
		||||
  try {
 | 
			
		||||
    if (fsSync.existsSync("/.dockerenv")) {
 | 
			
		||||
      return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const cgroupContent = fsSync.readFileSync("/proc/self/cgroup", "utf8");
 | 
			
		||||
    const containerPatterns = [
 | 
			
		||||
      "docker",
 | 
			
		||||
      "containerd",
 | 
			
		||||
      "lxc",
 | 
			
		||||
      "kubepods",
 | 
			
		||||
      "pod",
 | 
			
		||||
      "/containers/",
 | 
			
		||||
      "system.slice/container-",
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    for (const pattern of containerPatterns) {
 | 
			
		||||
      if (cgroupContent.includes(pattern)) {
 | 
			
		||||
        return true;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (fsSync.existsSync("/.well-known/container")) {
 | 
			
		||||
      return true;
 | 
			
		||||
    }
 | 
			
		||||
  } catch (e: unknown) {
 | 
			
		||||
    if (e instanceof Error) {
 | 
			
		||||
      console.warn("Could not perform full container detection:", e.message);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  return false;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const IS_RUNNING_IN_CONTAINER = isRunningInContainer();
 | 
			
		||||
@@ -202,6 +202,8 @@
 | 
			
		||||
  "login": {
 | 
			
		||||
    "welcome": "مرحبا بك",
 | 
			
		||||
    "signInToContinue": "قم بتسجيل الدخول للمتابعة",
 | 
			
		||||
    "emailOrUsernameLabel": "البريد الإلكتروني أو اسم المستخدم",
 | 
			
		||||
    "emailOrUsernamePlaceholder": "أدخل بريدك الإلكتروني أو اسم المستخدم",
 | 
			
		||||
    "emailLabel": "البريد الإلكتروني",
 | 
			
		||||
    "emailPlaceholder": "أدخل بريدك الإلكتروني",
 | 
			
		||||
    "passwordLabel": "كلمة المرور",
 | 
			
		||||
@@ -730,7 +732,15 @@
 | 
			
		||||
    "title": "استخدام التخزين",
 | 
			
		||||
    "ariaLabel": "شريط تقدم استخدام التخزين",
 | 
			
		||||
    "used": "المستخدمة",
 | 
			
		||||
    "available": "المتاحة"
 | 
			
		||||
    "available": "متاح",
 | 
			
		||||
    "loading": "جارٍ التحميل...",
 | 
			
		||||
    "retry": "إعادة المحاولة",
 | 
			
		||||
    "errors": {
 | 
			
		||||
      "title": "معلومات التخزين غير متوفرة",
 | 
			
		||||
      "detectionFailed": "تعذر اكتشاف مساحة القرص. قد يكون هذا بسبب مشاكل في إعدادات النظام أو صلاحيات غير كافية.",
 | 
			
		||||
      "serverError": "حدث خطأ في الخادم أثناء استرجاع معلومات التخزين. يرجى المحاولة مرة أخرى لاحقاً.",
 | 
			
		||||
      "unknown": "حدث خطأ غير متوقع أثناء تحميل معلومات التخزين."
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "theme": {
 | 
			
		||||
    "toggle": "تبديل السمة",
 | 
			
		||||
@@ -748,6 +758,7 @@
 | 
			
		||||
    "uploadProgress": "تقدم الرفع",
 | 
			
		||||
    "upload": "رفع",
 | 
			
		||||
    "startUploads": "بدء الرفع",
 | 
			
		||||
    "retry": "إعادة المحاولة",
 | 
			
		||||
    "finish": "إنهاء",
 | 
			
		||||
    "success": "تم رفع الملف بنجاح",
 | 
			
		||||
    "allSuccess": "{count, plural, =1 {تم رفع الملف بنجاح} other {تم رفع # ملف بنجاح}}",
 | 
			
		||||
@@ -844,6 +855,7 @@
 | 
			
		||||
    "passwordLength": "يجب أن تحتوي كلمة المرور على 8 أحرف على الأقل",
 | 
			
		||||
    "passwordsMatch": "كلمتا المرور غير متطابقتين",
 | 
			
		||||
    "emailRequired": "البريد الإلكتروني مطلوب",
 | 
			
		||||
    "emailOrUsernameRequired": "البريد الإلكتروني أو اسم المستخدم مطلوب",
 | 
			
		||||
    "passwordRequired": "كلمة المرور مطلوبة",
 | 
			
		||||
    "nameRequired": "الاسم مطلوب",
 | 
			
		||||
    "required": "هذا الحقل مطلوب"
 | 
			
		||||
@@ -1269,6 +1281,7 @@
 | 
			
		||||
        "linkInactive": "هذا الرابط غير نشط.",
 | 
			
		||||
        "linkExpired": "هذا الرابط منتهي الصلاحية.",
 | 
			
		||||
        "uploadFailed": "خطأ في رفع الملف",
 | 
			
		||||
        "retry": "إعادة المحاولة",
 | 
			
		||||
        "fileTooLarge": "الملف كبير جداً. الحجم الأقصى: {maxSize}",
 | 
			
		||||
        "fileTypeNotAllowed": "نوع الملف غير مسموح به. الأنواع المقبولة: {allowedTypes}",
 | 
			
		||||
        "maxFilesExceeded": "الحد الأقصى المسموح به هو {maxFiles} ملف/ملفات",
 | 
			
		||||
 
 | 
			
		||||
@@ -202,6 +202,8 @@
 | 
			
		||||
  "login": {
 | 
			
		||||
    "welcome": "Willkommen zu",
 | 
			
		||||
    "signInToContinue": "Melden Sie sich an, um fortzufahren",
 | 
			
		||||
    "emailOrUsernameLabel": "E-Mail-Adresse oder Benutzername",
 | 
			
		||||
    "emailOrUsernamePlaceholder": "Geben Sie Ihre E-Mail-Adresse oder Benutzernamen ein",
 | 
			
		||||
    "emailLabel": "E-Mail-Adresse",
 | 
			
		||||
    "emailPlaceholder": "Geben Sie Ihre E-Mail-Adresse ein",
 | 
			
		||||
    "passwordLabel": "Passwort",
 | 
			
		||||
@@ -730,7 +732,15 @@
 | 
			
		||||
    "title": "Speichernutzung",
 | 
			
		||||
    "ariaLabel": "Fortschrittsbalken der Speichernutzung",
 | 
			
		||||
    "used": "genutzt",
 | 
			
		||||
    "available": "verfügbar"
 | 
			
		||||
    "available": "verfügbar",
 | 
			
		||||
    "loading": "Wird geladen...",
 | 
			
		||||
    "retry": "Wiederholen",
 | 
			
		||||
    "errors": {
 | 
			
		||||
      "title": "Speicherinformationen nicht verfügbar",
 | 
			
		||||
      "detectionFailed": "Speicherplatz konnte nicht erkannt werden. Dies kann an Systemkonfigurationsproblemen oder unzureichenden Berechtigungen liegen.",
 | 
			
		||||
      "serverError": "Serverfehler beim Abrufen der Speicherinformationen. Bitte versuchen Sie es später erneut.",
 | 
			
		||||
      "unknown": "Ein unerwarteter Fehler ist beim Laden der Speicherinformationen aufgetreten."
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "theme": {
 | 
			
		||||
    "toggle": "Design umschalten",
 | 
			
		||||
@@ -748,6 +758,7 @@
 | 
			
		||||
    "uploadProgress": "Upload-Fortschritt",
 | 
			
		||||
    "upload": "Hochladen",
 | 
			
		||||
    "startUploads": "Uploads Starten",
 | 
			
		||||
    "retry": "Wiederholen",
 | 
			
		||||
    "finish": "Beenden",
 | 
			
		||||
    "success": "Datei erfolgreich hochgeladen",
 | 
			
		||||
    "allSuccess": "{count, plural, =1 {Datei erfolgreich hochgeladen} other {# Dateien erfolgreich hochgeladen}}",
 | 
			
		||||
@@ -844,6 +855,7 @@
 | 
			
		||||
    "passwordLength": "Das Passwort muss mindestens 8 Zeichen lang sein",
 | 
			
		||||
    "passwordsMatch": "Die Passwörter stimmen nicht überein",
 | 
			
		||||
    "emailRequired": "E-Mail ist erforderlich",
 | 
			
		||||
    "emailOrUsernameRequired": "E-Mail oder Benutzername ist erforderlich",
 | 
			
		||||
    "passwordRequired": "Passwort ist erforderlich",
 | 
			
		||||
    "nameRequired": "Name ist erforderlich",
 | 
			
		||||
    "required": "Dieses Feld ist erforderlich"
 | 
			
		||||
@@ -1269,6 +1281,7 @@
 | 
			
		||||
        "linkInactive": "Dieser Link ist inaktiv.",
 | 
			
		||||
        "linkExpired": "Dieser Link ist abgelaufen.",
 | 
			
		||||
        "uploadFailed": "Fehler beim Hochladen der Datei",
 | 
			
		||||
        "retry": "Wiederholen",
 | 
			
		||||
        "fileTooLarge": "Datei zu groß. Maximale Größe: {maxSize}",
 | 
			
		||||
        "fileTypeNotAllowed": "Dateityp nicht erlaubt. Erlaubte Typen: {allowedTypes}",
 | 
			
		||||
        "maxFilesExceeded": "Maximal {maxFiles} Dateien erlaubt",
 | 
			
		||||
 
 | 
			
		||||
@@ -202,6 +202,8 @@
 | 
			
		||||
  "login": {
 | 
			
		||||
    "welcome": "Welcome to",
 | 
			
		||||
    "signInToContinue": "Sign in to continue",
 | 
			
		||||
    "emailOrUsernameLabel": "Email or Username",
 | 
			
		||||
    "emailOrUsernamePlaceholder": "Enter your email or username",
 | 
			
		||||
    "emailLabel": "Email Address",
 | 
			
		||||
    "emailPlaceholder": "Enter your email",
 | 
			
		||||
    "passwordLabel": "Password",
 | 
			
		||||
@@ -788,7 +790,15 @@
 | 
			
		||||
    "title": "Storage Usage",
 | 
			
		||||
    "ariaLabel": "Storage usage progress bar",
 | 
			
		||||
    "used": "used",
 | 
			
		||||
    "available": "available"
 | 
			
		||||
    "available": "available",
 | 
			
		||||
    "loading": "Loading...",
 | 
			
		||||
    "retry": "Retry",
 | 
			
		||||
    "errors": {
 | 
			
		||||
      "title": "Storage information unavailable",
 | 
			
		||||
      "detectionFailed": "Unable to detect disk space. This may be due to system configuration issues or insufficient permissions.",
 | 
			
		||||
      "serverError": "Server error occurred while retrieving storage information. Please try again later.",
 | 
			
		||||
      "unknown": "An unexpected error occurred while loading storage information."
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "theme": {
 | 
			
		||||
    "toggle": "Toggle theme",
 | 
			
		||||
@@ -806,6 +816,7 @@
 | 
			
		||||
    "uploadProgress": "Upload progress",
 | 
			
		||||
    "upload": "Upload",
 | 
			
		||||
    "startUploads": "Start Uploads",
 | 
			
		||||
    "retry": "Retry",
 | 
			
		||||
    "finish": "Finish",
 | 
			
		||||
    "success": "File uploaded successfully",
 | 
			
		||||
    "allSuccess": "{count, plural, =1 {File uploaded successfully} other {# files uploaded successfully}}",
 | 
			
		||||
@@ -901,6 +912,7 @@
 | 
			
		||||
    "passwordLength": "Password must be at least 8 characters long",
 | 
			
		||||
    "passwordsMatch": "Passwords must match",
 | 
			
		||||
    "emailRequired": "Email is required",
 | 
			
		||||
    "emailOrUsernameRequired": "Email or username is required",
 | 
			
		||||
    "passwordRequired": "Password is required",
 | 
			
		||||
    "passwordMinLength": "Password must be at least 6 characters",
 | 
			
		||||
    "nameRequired": "Name is required",
 | 
			
		||||
@@ -1269,6 +1281,7 @@
 | 
			
		||||
        "linkInactive": "This link is inactive.",
 | 
			
		||||
        "linkExpired": "This link has expired.",
 | 
			
		||||
        "uploadFailed": "Error uploading file",
 | 
			
		||||
        "retry": "Retry",
 | 
			
		||||
        "fileTooLarge": "File too large. Maximum size: {maxSize}",
 | 
			
		||||
        "fileTypeNotAllowed": "File type not allowed. Accepted types: {allowedTypes}",
 | 
			
		||||
        "maxFilesExceeded": "Maximum of {maxFiles} files allowed",
 | 
			
		||||
 
 | 
			
		||||
@@ -202,6 +202,8 @@
 | 
			
		||||
  "login": {
 | 
			
		||||
    "welcome": "Bienvenido a",
 | 
			
		||||
    "signInToContinue": "Inicia sesión para continuar",
 | 
			
		||||
    "emailOrUsernameLabel": "Correo electrónico o nombre de usuario",
 | 
			
		||||
    "emailOrUsernamePlaceholder": "Introduce tu correo electrónico o nombre de usuario",
 | 
			
		||||
    "emailLabel": "Dirección de correo electrónico",
 | 
			
		||||
    "emailPlaceholder": "Introduce tu correo electrónico",
 | 
			
		||||
    "passwordLabel": "Contraseña",
 | 
			
		||||
@@ -730,7 +732,15 @@
 | 
			
		||||
    "title": "Uso de almacenamiento",
 | 
			
		||||
    "ariaLabel": "Barra de progreso del uso de almacenamiento",
 | 
			
		||||
    "used": "usados",
 | 
			
		||||
    "available": "disponibles"
 | 
			
		||||
    "available": "disponible",
 | 
			
		||||
    "loading": "Cargando...",
 | 
			
		||||
    "retry": "Reintentar",
 | 
			
		||||
    "errors": {
 | 
			
		||||
      "title": "Información de almacenamiento no disponible",
 | 
			
		||||
      "detectionFailed": "No se pudo detectar el espacio en disco. Esto puede deberse a problemas de configuración del sistema o permisos insuficientes.",
 | 
			
		||||
      "serverError": "Ocurrió un error del servidor al recuperar la información de almacenamiento. Por favor, inténtelo de nuevo más tarde.",
 | 
			
		||||
      "unknown": "Ocurrió un error inesperado al cargar la información de almacenamiento."
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "theme": {
 | 
			
		||||
    "toggle": "Cambiar tema",
 | 
			
		||||
@@ -748,6 +758,7 @@
 | 
			
		||||
    "uploadProgress": "Progreso de la subida",
 | 
			
		||||
    "upload": "Subir",
 | 
			
		||||
    "startUploads": "Iniciar Subidas",
 | 
			
		||||
    "retry": "Reintentar",
 | 
			
		||||
    "finish": "Finalizar",
 | 
			
		||||
    "success": "Archivo subido exitosamente",
 | 
			
		||||
    "allSuccess": "{count, plural, =1 {Archivo subido exitosamente} other {# archivos subidos exitosamente}}",
 | 
			
		||||
@@ -844,6 +855,7 @@
 | 
			
		||||
    "passwordLength": "La contraseña debe tener al menos 8 caracteres",
 | 
			
		||||
    "passwordsMatch": "Las contraseñas no coinciden",
 | 
			
		||||
    "emailRequired": "Se requiere el correo electrónico",
 | 
			
		||||
    "emailOrUsernameRequired": "Se requiere el correo electrónico o nombre de usuario",
 | 
			
		||||
    "passwordRequired": "Se requiere la contraseña",
 | 
			
		||||
    "nameRequired": "El nombre es obligatorio",
 | 
			
		||||
    "required": "Este campo es obligatorio"
 | 
			
		||||
@@ -1269,6 +1281,7 @@
 | 
			
		||||
        "linkInactive": "Este enlace está inactivo.",
 | 
			
		||||
        "linkExpired": "Este enlace ha expirado.",
 | 
			
		||||
        "uploadFailed": "Error al subir archivo",
 | 
			
		||||
        "retry": "Reintentar",
 | 
			
		||||
        "fileTooLarge": "Archivo demasiado grande. Tamaño máximo: {maxSize}",
 | 
			
		||||
        "fileTypeNotAllowed": "Tipo de archivo no permitido. Tipos aceptados: {allowedTypes}",
 | 
			
		||||
        "maxFilesExceeded": "Máximo de {maxFiles} archivos permitidos",
 | 
			
		||||
 
 | 
			
		||||
@@ -202,6 +202,8 @@
 | 
			
		||||
  "login": {
 | 
			
		||||
    "welcome": "Bienvenue à",
 | 
			
		||||
    "signInToContinue": "Connectez-vous pour continuer",
 | 
			
		||||
    "emailOrUsernameLabel": "Email ou Nom d'utilisateur",
 | 
			
		||||
    "emailOrUsernamePlaceholder": "Entrez votre email ou nom d'utilisateur",
 | 
			
		||||
    "emailLabel": "Adresse e-mail",
 | 
			
		||||
    "emailPlaceholder": "Entrez votre e-mail",
 | 
			
		||||
    "passwordLabel": "Mot de passe",
 | 
			
		||||
@@ -730,7 +732,15 @@
 | 
			
		||||
    "title": "Utilisation du Stockage",
 | 
			
		||||
    "ariaLabel": "Barre de progression de l'utilisation du stockage",
 | 
			
		||||
    "used": "utilisé",
 | 
			
		||||
    "available": "disponible"
 | 
			
		||||
    "available": "disponible",
 | 
			
		||||
    "loading": "Chargement...",
 | 
			
		||||
    "retry": "Réessayer",
 | 
			
		||||
    "errors": {
 | 
			
		||||
      "title": "Informations de stockage non disponibles",
 | 
			
		||||
      "detectionFailed": "Impossible de détecter l'espace disque. Cela peut être dû à des problèmes de configuration système ou à des permissions insuffisantes.",
 | 
			
		||||
      "serverError": "Une erreur serveur s'est produite lors de la récupération des informations de stockage. Veuillez réessayer plus tard.",
 | 
			
		||||
      "unknown": "Une erreur inattendue s'est produite lors du chargement des informations de stockage."
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "theme": {
 | 
			
		||||
    "toggle": "Changer le thème",
 | 
			
		||||
@@ -748,6 +758,7 @@
 | 
			
		||||
    "uploadProgress": "Progression du téléchargement",
 | 
			
		||||
    "upload": "Télécharger",
 | 
			
		||||
    "startUploads": "Commencer les Téléchargements",
 | 
			
		||||
    "retry": "Réessayer",
 | 
			
		||||
    "finish": "Terminer",
 | 
			
		||||
    "success": "Fichier téléchargé avec succès",
 | 
			
		||||
    "allSuccess": "{count, plural, =1 {Fichier téléchargé avec succès} other {# fichiers téléchargés avec succès}}",
 | 
			
		||||
@@ -844,6 +855,7 @@
 | 
			
		||||
    "passwordLength": "Le mot de passe doit contenir au moins 8 caractères",
 | 
			
		||||
    "passwordsMatch": "Les mots de passe ne correspondent pas",
 | 
			
		||||
    "emailRequired": "L'email est requis",
 | 
			
		||||
    "emailOrUsernameRequired": "L'email ou le nom d'utilisateur est requis",
 | 
			
		||||
    "passwordRequired": "Le mot de passe est requis",
 | 
			
		||||
    "nameRequired": "Nome é obrigatório",
 | 
			
		||||
    "required": "Este campo é obrigatório"
 | 
			
		||||
@@ -1269,6 +1281,7 @@
 | 
			
		||||
        "linkInactive": "Ce lien est inactif.",
 | 
			
		||||
        "linkExpired": "Ce lien a expiré.",
 | 
			
		||||
        "uploadFailed": "Erreur lors de l'envoi du fichier",
 | 
			
		||||
        "retry": "Réessayer",
 | 
			
		||||
        "fileTooLarge": "Fichier trop volumineux. Taille maximale : {maxSize}",
 | 
			
		||||
        "fileTypeNotAllowed": "Type de fichier non autorisé. Types acceptés : {allowedTypes}",
 | 
			
		||||
        "maxFilesExceeded": "Maximum de {maxFiles} fichiers autorisés",
 | 
			
		||||
 
 | 
			
		||||
@@ -202,6 +202,8 @@
 | 
			
		||||
  "login": {
 | 
			
		||||
    "welcome": "स्वागत है में",
 | 
			
		||||
    "signInToContinue": "जारी रखने के लिए साइन इन करें",
 | 
			
		||||
    "emailOrUsernameLabel": "ईमेल या उपयोगकर्ता नाम",
 | 
			
		||||
    "emailOrUsernamePlaceholder": "अपना ईमेल या उपयोगकर्ता नाम दर्ज करें",
 | 
			
		||||
    "emailLabel": "ईमेल पता",
 | 
			
		||||
    "emailPlaceholder": "अपना ईमेल दर्ज करें",
 | 
			
		||||
    "passwordLabel": "पासवर्ड",
 | 
			
		||||
@@ -730,7 +732,15 @@
 | 
			
		||||
    "title": "स्टोरेज उपयोग",
 | 
			
		||||
    "ariaLabel": "स्टोरेज उपयोग प्रगति पट्टी",
 | 
			
		||||
    "used": "उपयोग किया गया",
 | 
			
		||||
    "available": "उपलब्ध"
 | 
			
		||||
    "available": "उपलब्ध",
 | 
			
		||||
    "loading": "लोड हो रहा है...",
 | 
			
		||||
    "retry": "पुनः प्रयास करें",
 | 
			
		||||
    "errors": {
 | 
			
		||||
      "title": "स्टोरेज जानकारी अनुपलब्ध",
 | 
			
		||||
      "detectionFailed": "डिस्क स्पेस का पता लगाने में असमर्थ। यह सिस्टम कॉन्फ़िगरेशन समस्याओं या अपर्याप्त अनुमतियों के कारण हो सकता है।",
 | 
			
		||||
      "serverError": "स्टोरेज जानकारी प्राप्त करते समय सर्वर त्रुटि हुई। कृपया बाद में पुनः प्रयास करें।",
 | 
			
		||||
      "unknown": "स्टोरेज जानकारी लोड करते समय एक अनपेक्षित त्रुटि हुई।"
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "theme": {
 | 
			
		||||
    "toggle": "थीम टॉगल करें",
 | 
			
		||||
@@ -748,6 +758,7 @@
 | 
			
		||||
    "uploadProgress": "अपलोड प्रगति",
 | 
			
		||||
    "upload": "अपलोड",
 | 
			
		||||
    "startUploads": "अपलोड शुरू करें",
 | 
			
		||||
    "retry": "पुनः प्रयास करें",
 | 
			
		||||
    "finish": "समाप्त",
 | 
			
		||||
    "success": "फ़ाइल सफलतापूर्वक अपलोड की गई",
 | 
			
		||||
    "allSuccess": "{count, plural, =1 {फ़ाइल सफलतापूर्वक अपलोड की गई} other {# फ़ाइलें सफलतापूर्वक अपलोड की गईं}}",
 | 
			
		||||
@@ -844,6 +855,7 @@
 | 
			
		||||
    "passwordLength": "पासवर्ड कम से कम 8 अक्षर का होना चाहिए",
 | 
			
		||||
    "passwordsMatch": "पासवर्ड मेल नहीं खाते",
 | 
			
		||||
    "emailRequired": "ईमेल आवश्यक है",
 | 
			
		||||
    "emailOrUsernameRequired": "ईमेल या उपयोगकर्ता नाम आवश्यक है",
 | 
			
		||||
    "passwordRequired": "पासवर्ड आवश्यक है",
 | 
			
		||||
    "nameRequired": "नाम आवश्यक है",
 | 
			
		||||
    "required": "यह फ़ील्ड आवश्यक है"
 | 
			
		||||
@@ -1269,6 +1281,7 @@
 | 
			
		||||
        "linkInactive": "यह लिंक निष्क्रिय है।",
 | 
			
		||||
        "linkExpired": "यह लिंक समाप्त हो गया है।",
 | 
			
		||||
        "uploadFailed": "फ़ाइल अपलोड करने में त्रुटि",
 | 
			
		||||
        "retry": "पुनः प्रयास करें",
 | 
			
		||||
        "fileTooLarge": "फ़ाइल बहुत बड़ी है। अधिकतम आकार: {maxSize}",
 | 
			
		||||
        "fileTypeNotAllowed": "फ़ाइल प्रकार अनुमत नहीं है। स्वीकृत प्रकार: {allowedTypes}",
 | 
			
		||||
        "maxFilesExceeded": "अधिकतम {maxFiles} फ़ाइलें अनुमत हैं",
 | 
			
		||||
 
 | 
			
		||||
@@ -202,6 +202,8 @@
 | 
			
		||||
  "login": {
 | 
			
		||||
    "welcome": "Benvenuto in",
 | 
			
		||||
    "signInToContinue": "Accedi per continuare",
 | 
			
		||||
    "emailOrUsernameLabel": "Email o Nome utente",
 | 
			
		||||
    "emailOrUsernamePlaceholder": "Inserisci la tua email o nome utente",
 | 
			
		||||
    "emailLabel": "Indirizzo Email",
 | 
			
		||||
    "emailPlaceholder": "Inserisci la tua email",
 | 
			
		||||
    "passwordLabel": "Parola d'accesso",
 | 
			
		||||
@@ -730,7 +732,15 @@
 | 
			
		||||
    "title": "Utilizzo Archiviazione",
 | 
			
		||||
    "ariaLabel": "Barra di progresso utilizzo archiviazione",
 | 
			
		||||
    "used": "utilizzato",
 | 
			
		||||
    "available": "disponibile"
 | 
			
		||||
    "available": "disponibile",
 | 
			
		||||
    "loading": "Caricamento...",
 | 
			
		||||
    "retry": "Riprova",
 | 
			
		||||
    "errors": {
 | 
			
		||||
      "title": "Informazioni di archiviazione non disponibili",
 | 
			
		||||
      "detectionFailed": "Impossibile rilevare lo spazio su disco. Ciò potrebbe essere dovuto a problemi di configurazione del sistema o permessi insufficienti.",
 | 
			
		||||
      "serverError": "Si è verificato un errore del server durante il recupero delle informazioni di archiviazione. Riprova più tardi.",
 | 
			
		||||
      "unknown": "Si è verificato un errore imprevisto durante il caricamento delle informazioni di archiviazione."
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "theme": {
 | 
			
		||||
    "toggle": "Cambia tema",
 | 
			
		||||
@@ -748,6 +758,7 @@
 | 
			
		||||
    "uploadProgress": "Progresso caricamento",
 | 
			
		||||
    "upload": "Carica",
 | 
			
		||||
    "startUploads": "Inizia Caricamenti",
 | 
			
		||||
    "retry": "Riprova",
 | 
			
		||||
    "finish": "Termina",
 | 
			
		||||
    "success": "File caricato con successo",
 | 
			
		||||
    "allSuccess": "{count, plural, =1 {File caricato con successo} other {# file caricati con successo}}",
 | 
			
		||||
@@ -843,6 +854,7 @@
 | 
			
		||||
    "passwordLength": "La parola d'accesso deve essere di almeno 8 caratteri",
 | 
			
		||||
    "passwordsMatch": "Le parole d'accesso devono corrispondere",
 | 
			
		||||
    "emailRequired": "L'indirizzo email è obbligatorio",
 | 
			
		||||
    "emailOrUsernameRequired": "L'indirizzo email o il nome utente è obbligatorio",
 | 
			
		||||
    "passwordRequired": "La parola d'accesso è obbligatoria",
 | 
			
		||||
    "passwordMinLength": "La password deve contenere almeno 6 caratteri",
 | 
			
		||||
    "nameRequired": "Il nome è obbligatorio",
 | 
			
		||||
@@ -1269,6 +1281,7 @@
 | 
			
		||||
        "linkInactive": "Questo link è inattivo.",
 | 
			
		||||
        "linkExpired": "Questo link è scaduto.",
 | 
			
		||||
        "uploadFailed": "Errore durante l'invio del file",
 | 
			
		||||
        "retry": "Riprova",
 | 
			
		||||
        "fileTooLarge": "File troppo grande. Dimensione massima: {maxSize}",
 | 
			
		||||
        "fileTypeNotAllowed": "Tipo di file non consentito. Tipi accettati: {allowedTypes}",
 | 
			
		||||
        "maxFilesExceeded": "Massimo {maxFiles} file consentiti",
 | 
			
		||||
 
 | 
			
		||||
@@ -202,6 +202,8 @@
 | 
			
		||||
  "login": {
 | 
			
		||||
    "welcome": "ようこそへ",
 | 
			
		||||
    "signInToContinue": "続行するにはサインインしてください",
 | 
			
		||||
    "emailOrUsernameLabel": "メールアドレスまたはユーザー名",
 | 
			
		||||
    "emailOrUsernamePlaceholder": "メールアドレスまたはユーザー名を入力してください",
 | 
			
		||||
    "emailLabel": "メールアドレス",
 | 
			
		||||
    "emailPlaceholder": "メールアドレスを入力してください",
 | 
			
		||||
    "passwordLabel": "パスワード",
 | 
			
		||||
@@ -730,7 +732,15 @@
 | 
			
		||||
    "title": "ストレージ使用量",
 | 
			
		||||
    "ariaLabel": "ストレージ使用状況のプログレスバー",
 | 
			
		||||
    "used": "使用済み",
 | 
			
		||||
    "available": "利用可能"
 | 
			
		||||
    "available": "利用可能",
 | 
			
		||||
    "loading": "読み込み中...",
 | 
			
		||||
    "retry": "再試行",
 | 
			
		||||
    "errors": {
 | 
			
		||||
      "title": "ストレージ情報が利用できません",
 | 
			
		||||
      "detectionFailed": "ディスク容量を検出できません。システム設定の問題または権限が不足している可能性があります。",
 | 
			
		||||
      "serverError": "ストレージ情報の取得中にサーバーエラーが発生しました。後でもう一度お試しください。",
 | 
			
		||||
      "unknown": "ストレージ情報の読み込み中に予期せぬエラーが発生しました。"
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "theme": {
 | 
			
		||||
    "toggle": "テーマを切り替える",
 | 
			
		||||
@@ -761,6 +771,7 @@
 | 
			
		||||
    },
 | 
			
		||||
    "multipleTitle": "複数ファイルをアップロード",
 | 
			
		||||
    "startUploads": "アップロードを開始",
 | 
			
		||||
    "retry": "再試行",
 | 
			
		||||
    "allSuccess": "{count, plural, =1 {ファイルがアップロードされました} other {#個のファイルがアップロードされました}}",
 | 
			
		||||
    "partialSuccess": "{success}個のファイルがアップロードされ、{error}個が失敗しました",
 | 
			
		||||
    "dragAndDrop": "またはここにファイルをドラッグ&ドロップ"
 | 
			
		||||
@@ -844,6 +855,7 @@
 | 
			
		||||
    "passwordLength": "パスワードは最低8文字必要です",
 | 
			
		||||
    "passwordsMatch": "パスワードが一致しません",
 | 
			
		||||
    "emailRequired": "メールアドレスは必須です",
 | 
			
		||||
    "emailOrUsernameRequired": "メールアドレスまたはユーザー名は必須です",
 | 
			
		||||
    "passwordRequired": "パスワードは必須です",
 | 
			
		||||
    "nameRequired": "名前は必須です",
 | 
			
		||||
    "required": "このフィールドは必須です"
 | 
			
		||||
@@ -1269,6 +1281,7 @@
 | 
			
		||||
        "linkInactive": "このリンクは無効です。",
 | 
			
		||||
        "linkExpired": "このリンクは期限切れです。",
 | 
			
		||||
        "uploadFailed": "ファイルのアップロードに失敗しました",
 | 
			
		||||
        "retry": "再試行",
 | 
			
		||||
        "fileTooLarge": "ファイルが大きすぎます。最大サイズ: {maxSize}",
 | 
			
		||||
        "fileTypeNotAllowed": "このファイル形式は許可されていません。許可される形式: {allowedTypes}",
 | 
			
		||||
        "maxFilesExceeded": "最大 {maxFiles} ファイルまで許可されています",
 | 
			
		||||
 
 | 
			
		||||
@@ -202,6 +202,8 @@
 | 
			
		||||
  "login": {
 | 
			
		||||
    "welcome": "에 오신 것을 환영합니다",
 | 
			
		||||
    "signInToContinue": "계속하려면 로그인하세요",
 | 
			
		||||
    "emailOrUsernameLabel": "이메일 또는 사용자 이름",
 | 
			
		||||
    "emailOrUsernamePlaceholder": "이메일 또는 사용자 이름을 입력하세요",
 | 
			
		||||
    "emailLabel": "이메일 주소",
 | 
			
		||||
    "emailPlaceholder": "이메일을 입력하세요",
 | 
			
		||||
    "passwordLabel": "비밀번호",
 | 
			
		||||
@@ -730,7 +732,15 @@
 | 
			
		||||
    "title": "스토리지 사용량",
 | 
			
		||||
    "ariaLabel": "스토리지 사용량 진행 바",
 | 
			
		||||
    "used": "사용됨",
 | 
			
		||||
    "available": "사용 가능"
 | 
			
		||||
    "available": "사용 가능",
 | 
			
		||||
    "loading": "로딩 중...",
 | 
			
		||||
    "retry": "다시 시도",
 | 
			
		||||
    "errors": {
 | 
			
		||||
      "title": "스토리지 정보를 사용할 수 없음",
 | 
			
		||||
      "detectionFailed": "디스크 공간을 감지할 수 없습니다. 시스템 구성 문제 또는 권한이 부족한 것이 원인일 수 있습니다.",
 | 
			
		||||
      "serverError": "스토리지 정보를 검색하는 중에 서버 오류가 발생했습니다. 나중에 다시 시도해 주세요.",
 | 
			
		||||
      "unknown": "스토리지 정보를 로드하는 중에 예기치 않은 오류가 발생했습니다."
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "theme": {
 | 
			
		||||
    "toggle": "테마 전환",
 | 
			
		||||
@@ -748,6 +758,7 @@
 | 
			
		||||
    "uploadProgress": "업로드 진행률",
 | 
			
		||||
    "upload": "업로드",
 | 
			
		||||
    "startUploads": "업로드 시작",
 | 
			
		||||
    "retry": "다시 시도",
 | 
			
		||||
    "finish": "완료",
 | 
			
		||||
    "success": "파일이 성공적으로 업로드되었습니다",
 | 
			
		||||
    "allSuccess": "{count, plural, =1 {파일이 성공적으로 업로드되었습니다} other {# 개 파일이 성공적으로 업로드되었습니다}}",
 | 
			
		||||
@@ -844,6 +855,7 @@
 | 
			
		||||
    "passwordLength": "비밀번호는 최소 8자 이상이어야 합니다",
 | 
			
		||||
    "passwordsMatch": "비밀번호가 일치하지 않습니다",
 | 
			
		||||
    "emailRequired": "이메일은 필수입니다",
 | 
			
		||||
    "emailOrUsernameRequired": "이메일 또는 사용자 이름은 필수입니다",
 | 
			
		||||
    "passwordRequired": "비밀번호는 필수입니다",
 | 
			
		||||
    "nameRequired": "이름은 필수입니다",
 | 
			
		||||
    "required": "이 필드는 필수입니다"
 | 
			
		||||
@@ -1269,6 +1281,7 @@
 | 
			
		||||
        "linkInactive": "이 링크는 비활성 상태입니다.",
 | 
			
		||||
        "linkExpired": "이 링크는 만료되었습니다.",
 | 
			
		||||
        "uploadFailed": "파일 업로드 오류",
 | 
			
		||||
        "retry": "다시 시도",
 | 
			
		||||
        "fileTooLarge": "파일이 너무 큽니다. 최대 크기: {maxSize}",
 | 
			
		||||
        "fileTypeNotAllowed": "허용되지 않는 파일 유형입니다. 허용된 유형: {allowedTypes}",
 | 
			
		||||
        "maxFilesExceeded": "최대 {maxFiles}개의 파일만 허용됩니다",
 | 
			
		||||
 
 | 
			
		||||
@@ -202,6 +202,8 @@
 | 
			
		||||
  "login": {
 | 
			
		||||
    "welcome": "Welkom bij",
 | 
			
		||||
    "signInToContinue": "Log in om door te gaan",
 | 
			
		||||
    "emailOrUsernameLabel": "E-mail of Gebruikersnaam",
 | 
			
		||||
    "emailOrUsernamePlaceholder": "Voer je e-mail of gebruikersnaam in",
 | 
			
		||||
    "emailLabel": "E-mailadres",
 | 
			
		||||
    "emailPlaceholder": "Voer je e-mail in",
 | 
			
		||||
    "passwordLabel": "Wachtwoord",
 | 
			
		||||
@@ -730,7 +732,15 @@
 | 
			
		||||
    "title": "Opslaggebruik",
 | 
			
		||||
    "ariaLabel": "Opslaggebruik voortgangsbalk",
 | 
			
		||||
    "used": "gebruikt",
 | 
			
		||||
    "available": "beschikbaar"
 | 
			
		||||
    "available": "beschikbaar",
 | 
			
		||||
    "loading": "Laden...",
 | 
			
		||||
    "retry": "Opnieuw proberen",
 | 
			
		||||
    "errors": {
 | 
			
		||||
      "title": "Opslaginformatie niet beschikbaar",
 | 
			
		||||
      "detectionFailed": "Kan schijfruimte niet detecteren. Dit kan komen door systeemconfiguratieproblemen of onvoldoende rechten.",
 | 
			
		||||
      "serverError": "Er is een serverfout opgetreden bij het ophalen van opslaginformatie. Probeer het later opnieuw.",
 | 
			
		||||
      "unknown": "Er is een onverwachte fout opgetreden bij het laden van opslaginformatie."
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "theme": {
 | 
			
		||||
    "toggle": "Thema wisselen",
 | 
			
		||||
@@ -748,6 +758,7 @@
 | 
			
		||||
    "uploadProgress": "Upload voortgang",
 | 
			
		||||
    "upload": "Uploaden",
 | 
			
		||||
    "startUploads": "Uploads Starten",
 | 
			
		||||
    "retry": "Opnieuw Proberen",
 | 
			
		||||
    "finish": "Voltooien",
 | 
			
		||||
    "success": "Bestand succesvol geüpload",
 | 
			
		||||
    "allSuccess": "{count, plural, =1 {Bestand succesvol geüpload} other {# bestanden succesvol geüpload}}",
 | 
			
		||||
@@ -843,6 +854,7 @@
 | 
			
		||||
    "passwordLength": "Wachtwoord moet minimaal 8 tekens zijn",
 | 
			
		||||
    "passwordsMatch": "Wachtwoorden moeten overeenkomen",
 | 
			
		||||
    "emailRequired": "E-mail is verplicht",
 | 
			
		||||
    "emailOrUsernameRequired": "E-mail of gebruikersnaam is verplicht",
 | 
			
		||||
    "passwordRequired": "Wachtwoord is verplicht",
 | 
			
		||||
    "passwordMinLength": "Wachtwoord moet minimaal 6 tekens bevatten",
 | 
			
		||||
    "nameRequired": "Naam is verplicht",
 | 
			
		||||
@@ -1269,6 +1281,7 @@
 | 
			
		||||
        "linkInactive": "Deze link is inactief.",
 | 
			
		||||
        "linkExpired": "Deze link is verlopen.",
 | 
			
		||||
        "uploadFailed": "Fout bij uploaden bestand",
 | 
			
		||||
        "retry": "Opnieuw Proberen",
 | 
			
		||||
        "fileTooLarge": "Bestand te groot. Maximum grootte: {maxSize}",
 | 
			
		||||
        "fileTypeNotAllowed": "Bestandstype niet toegestaan. Toegestane types: {allowedTypes}",
 | 
			
		||||
        "maxFilesExceeded": "Maximum van {maxFiles} bestanden toegestaan",
 | 
			
		||||
 
 | 
			
		||||
@@ -202,6 +202,8 @@
 | 
			
		||||
  "login": {
 | 
			
		||||
    "welcome": "Witaj w",
 | 
			
		||||
    "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",
 | 
			
		||||
    "emailPlaceholder": "Wprowadź swój adres e-mail",
 | 
			
		||||
    "passwordLabel": "Hasło",
 | 
			
		||||
@@ -788,7 +790,15 @@
 | 
			
		||||
    "title": "Użycie pamięci",
 | 
			
		||||
    "ariaLabel": "Pasek postępu użycia pamięci",
 | 
			
		||||
    "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": {
 | 
			
		||||
    "toggle": "Przełącz motyw",
 | 
			
		||||
@@ -806,6 +816,7 @@
 | 
			
		||||
    "uploadProgress": "Postęp przesyłania",
 | 
			
		||||
    "upload": "Prześlij",
 | 
			
		||||
    "startUploads": "Rozpocznij przesyłanie",
 | 
			
		||||
    "retry": "Spróbuj Ponownie",
 | 
			
		||||
    "finish": "Zakończ",
 | 
			
		||||
    "success": "Plik przesłany 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",
 | 
			
		||||
    "passwordsMatch": "Hasła muszą być zgodne",
 | 
			
		||||
    "emailRequired": "E-mail jest wymagany",
 | 
			
		||||
    "emailOrUsernameRequired": "E-mail lub nazwa użytkownika jest wymagana",
 | 
			
		||||
    "passwordRequired": "Hasło jest wymagane",
 | 
			
		||||
    "passwordMinLength": "Hasło musi mieć co najmniej 6 znaków",
 | 
			
		||||
    "nameRequired": "Nazwa jest wymagana",
 | 
			
		||||
@@ -1269,6 +1281,7 @@
 | 
			
		||||
        "linkInactive": "Ten link jest nieaktywny.",
 | 
			
		||||
        "linkExpired": "Ten link wygasł.",
 | 
			
		||||
        "uploadFailed": "Błąd przesyłania pliku",
 | 
			
		||||
        "retry": "Spróbuj Ponownie",
 | 
			
		||||
        "fileTooLarge": "Plik za duży. Maksymalny rozmiar: {maxSize}",
 | 
			
		||||
        "fileTypeNotAllowed": "Typ pliku niedozwolony. Akceptowane typy: {allowedTypes}",
 | 
			
		||||
        "maxFilesExceeded": "Dozwolono maksymalnie {maxFiles} plików",
 | 
			
		||||
 
 | 
			
		||||
@@ -18,17 +18,17 @@
 | 
			
		||||
    "click": "Clique para"
 | 
			
		||||
  },
 | 
			
		||||
  "createShare": {
 | 
			
		||||
    "title": "Criar Compartilhamento",
 | 
			
		||||
    "nameLabel": "Nome do Compartilhamento",
 | 
			
		||||
    "title": "Criar compartilhamento",
 | 
			
		||||
    "nameLabel": "Nome do compartilhamento",
 | 
			
		||||
    "descriptionLabel": "Descrição",
 | 
			
		||||
    "descriptionPlaceholder": "Digite uma descrição (opcional)",
 | 
			
		||||
    "expirationLabel": "Data de Expiração",
 | 
			
		||||
    "expirationLabel": "Data de expiração",
 | 
			
		||||
    "expirationPlaceholder": "DD/MM/AAAA HH:MM",
 | 
			
		||||
    "maxViewsLabel": "Máximo de Visualizações",
 | 
			
		||||
    "maxViewsLabel": "Máximo de visualizações",
 | 
			
		||||
    "maxViewsPlaceholder": "Deixe vazio para ilimitado",
 | 
			
		||||
    "passwordProtection": "Protegido por Senha",
 | 
			
		||||
    "passwordLabel": "Senha",
 | 
			
		||||
    "create": "Criar Compartilhamento",
 | 
			
		||||
    "create": "Criar compartilhamento",
 | 
			
		||||
    "success": "Compartilhamento criado com sucesso",
 | 
			
		||||
    "error": "Falha ao criar compartilhamento"
 | 
			
		||||
  },
 | 
			
		||||
@@ -44,7 +44,7 @@
 | 
			
		||||
  },
 | 
			
		||||
  "emptyState": {
 | 
			
		||||
    "noFiles": "Nenhum arquivo enviado ainda",
 | 
			
		||||
    "uploadFile": "Enviar Arquivo"
 | 
			
		||||
    "uploadFile": "Enviar arquivo"
 | 
			
		||||
  },
 | 
			
		||||
  "errors": {
 | 
			
		||||
    "invalidCredentials": "E-mail ou senha inválidos",
 | 
			
		||||
@@ -53,13 +53,13 @@
 | 
			
		||||
    "unexpectedError": "Ocorreu um erro inesperado. Por favor, tente novamente"
 | 
			
		||||
  },
 | 
			
		||||
  "fileActions": {
 | 
			
		||||
    "editFile": "Editar Arquivo",
 | 
			
		||||
    "editFile": "Editar arquivo",
 | 
			
		||||
    "nameLabel": "Nome",
 | 
			
		||||
    "namePlaceholder": "Digite o novo nome",
 | 
			
		||||
    "extension": "Extensão",
 | 
			
		||||
    "descriptionLabel": "Descrição",
 | 
			
		||||
    "descriptionPlaceholder": "Digite a descrição do arquivo",
 | 
			
		||||
    "deleteFile": "Excluir Arquivo",
 | 
			
		||||
    "deleteFile": "Excluir arquivo",
 | 
			
		||||
    "deleteConfirmation": "Tem certeza que deseja excluir ?",
 | 
			
		||||
    "deleteWarning": "Esta ação não pode ser desfeita."
 | 
			
		||||
  },
 | 
			
		||||
@@ -154,9 +154,9 @@
 | 
			
		||||
    "bulkActions": {
 | 
			
		||||
      "selected": "{count, plural, =1 {1 arquivo selecionado} other {# arquivos selecionados}}",
 | 
			
		||||
      "actions": "Ações",
 | 
			
		||||
      "download": "Baixar Selecionados",
 | 
			
		||||
      "share": "Compartilhar Selecionados",
 | 
			
		||||
      "delete": "Excluir Selecionados"
 | 
			
		||||
      "download": "Baixar selecionados",
 | 
			
		||||
      "share": "Compartilhar selecionados",
 | 
			
		||||
      "delete": "Excluir selecionados"
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "footer": {
 | 
			
		||||
@@ -175,23 +175,23 @@
 | 
			
		||||
    "pageTitle": "Esqueceu a Senha"
 | 
			
		||||
  },
 | 
			
		||||
  "generateShareLink": {
 | 
			
		||||
    "generateTitle": "Gerar Link de Compartilhamento",
 | 
			
		||||
    "updateTitle": "Atualizar Link de Compartilhamento",
 | 
			
		||||
    "generateTitle": "Gerar link de compartilhamento",
 | 
			
		||||
    "updateTitle": "Atualizar link de compartilhamento",
 | 
			
		||||
    "generateDescription": "Gere um link para compartilhar seus arquivos",
 | 
			
		||||
    "updateDescription": "Atualize o alias deste link de compartilhamento",
 | 
			
		||||
    "aliasPlaceholder": "Digite o alias",
 | 
			
		||||
    "linkReady": "Seu link de compartilhamento está pronto:",
 | 
			
		||||
    "generateButton": "Gerar Link",
 | 
			
		||||
    "updateButton": "Atualizar Link",
 | 
			
		||||
    "copyButton": "Copiar Link",
 | 
			
		||||
    "generateButton": "Gerar link",
 | 
			
		||||
    "updateButton": "Atualizar link",
 | 
			
		||||
    "copyButton": "Copiar link",
 | 
			
		||||
    "success": "Link gerado com sucesso",
 | 
			
		||||
    "error": "Erro ao gerar link",
 | 
			
		||||
    "copied": "Link copiado para a área de transferência"
 | 
			
		||||
  },
 | 
			
		||||
  "shareFile": {
 | 
			
		||||
    "title": "Compartilhar Arquivo",
 | 
			
		||||
    "linkTitle": "Gerar Link",
 | 
			
		||||
    "nameLabel": "Nome do Compartilhamento",
 | 
			
		||||
    "title": "Compartilhar arquivo",
 | 
			
		||||
    "linkTitle": "Gerar link",
 | 
			
		||||
    "nameLabel": "Nome do compartilhamento",
 | 
			
		||||
    "namePlaceholder": "Digite o nome do compartilhamento",
 | 
			
		||||
    "descriptionLabel": "Descrição",
 | 
			
		||||
    "descriptionPlaceholder": "Digite uma descrição (opcional)",
 | 
			
		||||
@@ -199,16 +199,16 @@
 | 
			
		||||
    "expirationPlaceholder": "DD/MM/AAAA HH:MM",
 | 
			
		||||
    "maxViewsLabel": "Máximo de Visualizações",
 | 
			
		||||
    "maxViewsPlaceholder": "Deixe vazio para ilimitado",
 | 
			
		||||
    "passwordProtection": "Protegido por Senha",
 | 
			
		||||
    "passwordProtection": "Protegido por senha",
 | 
			
		||||
    "passwordLabel": "Senha",
 | 
			
		||||
    "passwordPlaceholder": "Digite a senha",
 | 
			
		||||
    "linkDescription": "Gere um link personalizado para compartilhar o arquivo",
 | 
			
		||||
    "aliasLabel": "Alias do Link",
 | 
			
		||||
    "aliasLabel": "Alias do link",
 | 
			
		||||
    "aliasPlaceholder": "Digite um alias personalizado",
 | 
			
		||||
    "linkReady": "Seu link de compartilhamento está pronto:",
 | 
			
		||||
    "createShare": "Criar Compartilhamento",
 | 
			
		||||
    "generateLink": "Gerar Link",
 | 
			
		||||
    "copyLink": "Copiar Link"
 | 
			
		||||
    "createShare": "Criar compartilhamento",
 | 
			
		||||
    "generateLink": "Gerar link",
 | 
			
		||||
    "copyLink": "Copiar link"
 | 
			
		||||
  },
 | 
			
		||||
  "home": {
 | 
			
		||||
    "description": "A alternativa open-source ao WeTransfer. Compartilhe arquivos com segurança, sem rastreamento ou limitações.",
 | 
			
		||||
@@ -223,7 +223,9 @@
 | 
			
		||||
  },
 | 
			
		||||
  "login": {
 | 
			
		||||
    "welcome": "Bem-vindo ao",
 | 
			
		||||
    "signInToContinue": "Entre para continuar",
 | 
			
		||||
    "signInToContinue": "Faça login para continuar",
 | 
			
		||||
    "emailOrUsernameLabel": "E-mail ou Nome de Usuário",
 | 
			
		||||
    "emailOrUsernamePlaceholder": "Digite seu e-mail ou nome de usuário",
 | 
			
		||||
    "emailLabel": "Endereço de E-mail",
 | 
			
		||||
    "emailPlaceholder": "Digite seu e-mail",
 | 
			
		||||
    "passwordLabel": "Senha",
 | 
			
		||||
@@ -231,18 +233,18 @@
 | 
			
		||||
    "signIn": "Entrar",
 | 
			
		||||
    "signingIn": "Entrando...",
 | 
			
		||||
    "forgotPassword": "Esqueceu a senha?",
 | 
			
		||||
    "pageTitle": "Entrar",
 | 
			
		||||
    "pageTitle": "Login",
 | 
			
		||||
    "or": "ou",
 | 
			
		||||
    "continueWithSSO": "Continuar com SSO",
 | 
			
		||||
    "processing": "Processando autenticação..."
 | 
			
		||||
  },
 | 
			
		||||
  "logo": {
 | 
			
		||||
    "labels": {
 | 
			
		||||
      "appLogo": "Logo do Aplicativo"
 | 
			
		||||
      "appLogo": "Logo do aplicativo"
 | 
			
		||||
    },
 | 
			
		||||
    "buttons": {
 | 
			
		||||
      "upload": "Enviar Logo",
 | 
			
		||||
      "remove": "Remover Logo"
 | 
			
		||||
      "upload": "Enviar logo",
 | 
			
		||||
      "remove": "Remover logo"
 | 
			
		||||
    },
 | 
			
		||||
    "messages": {
 | 
			
		||||
      "uploadSuccess": "Logo enviado com sucesso",
 | 
			
		||||
@@ -254,11 +256,11 @@
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "navbar": {
 | 
			
		||||
    "logoAlt": "Logo do Aplicativo",
 | 
			
		||||
    "logoAlt": "Logo do aplicativo",
 | 
			
		||||
    "profileMenu": "Menu do Perfil",
 | 
			
		||||
    "profile": "Perfil",
 | 
			
		||||
    "settings": "Configurações",
 | 
			
		||||
    "usersManagement": "Gerenciar Usuários",
 | 
			
		||||
    "usersManagement": "Gerenciar usuários",
 | 
			
		||||
    "logout": "Sair"
 | 
			
		||||
  },
 | 
			
		||||
  "navigation": {
 | 
			
		||||
@@ -752,7 +754,15 @@
 | 
			
		||||
    "title": "Uso de Armazenamento",
 | 
			
		||||
    "ariaLabel": "Barra de progresso do uso de armazenamento",
 | 
			
		||||
    "used": "usado",
 | 
			
		||||
    "available": "disponível"
 | 
			
		||||
    "available": "disponível",
 | 
			
		||||
    "loading": "Carregando...",
 | 
			
		||||
    "retry": "Tentar novamente",
 | 
			
		||||
    "errors": {
 | 
			
		||||
      "title": "Informações de armazenamento indisponíveis",
 | 
			
		||||
      "detectionFailed": "Não foi possível detectar o espaço em disco. Isso pode ser devido a problemas de configuração do sistema ou permissões insuficientes.",
 | 
			
		||||
      "serverError": "Ocorreu um erro no servidor ao recuperar as informações de armazenamento. Por favor, tente novamente mais tarde.",
 | 
			
		||||
      "unknown": "Ocorreu um erro inesperado ao carregar as informações de armazenamento."
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "theme": {
 | 
			
		||||
    "toggle": "Alternar tema",
 | 
			
		||||
@@ -770,6 +780,7 @@
 | 
			
		||||
    "uploadProgress": "Progresso do upload",
 | 
			
		||||
    "upload": "Enviar",
 | 
			
		||||
    "startUploads": "Iniciar Uploads",
 | 
			
		||||
    "retry": "Tentar Novamente",
 | 
			
		||||
    "finish": "Concluir",
 | 
			
		||||
    "success": "Arquivo enviado com sucesso",
 | 
			
		||||
    "allSuccess": "{count, plural, =1 {Arquivo enviado com sucesso} other {# arquivos enviados com sucesso}}",
 | 
			
		||||
@@ -857,18 +868,15 @@
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "validation": {
 | 
			
		||||
    "invalidEmail": "Endereço de email inválido",
 | 
			
		||||
    "passwordMinLength": "A senha deve ter pelo menos 6 caracteres",
 | 
			
		||||
    "firstNameRequired": "Nome é obrigatório",
 | 
			
		||||
    "lastNameRequired": "Sobrenome é obrigatório",
 | 
			
		||||
    "usernameLength": "Nome de usuário deve ter pelo menos 3 caracteres",
 | 
			
		||||
    "usernameSpaces": "Nome de usuário não pode conter espaços",
 | 
			
		||||
    "invalidEmail": "Por favor, insira um endereço de e-mail válido",
 | 
			
		||||
    "passwordLength": "A senha deve ter pelo menos 8 caracteres",
 | 
			
		||||
    "passwordsMatch": "As senhas não coincidem",
 | 
			
		||||
    "passwordsMatch": "As senhas devem coincidir",
 | 
			
		||||
    "emailRequired": "Email é obrigatório",
 | 
			
		||||
    "emailOrUsernameRequired": "E-mail ou nome de usuário é obrigatório",
 | 
			
		||||
    "passwordRequired": "Senha é obrigatória",
 | 
			
		||||
    "required": "Este campo é obrigatório",
 | 
			
		||||
    "nameRequired": "Nome é obrigatório"
 | 
			
		||||
    "passwordMinLength": "A senha deve ter pelo menos 6 caracteres",
 | 
			
		||||
    "nameRequired": "Nome é obrigatório",
 | 
			
		||||
    "required": "Este campo é obrigatório"
 | 
			
		||||
  },
 | 
			
		||||
  "bulkDownload": {
 | 
			
		||||
    "title": "Download em Lote",
 | 
			
		||||
@@ -937,8 +945,8 @@
 | 
			
		||||
      "noExpiration": "Este compartilhamento nunca expirará e permanecerá acessível indefinidamente.",
 | 
			
		||||
      "title": "Sobre expiração:"
 | 
			
		||||
    },
 | 
			
		||||
    "enableExpiration": "Habilitar Expiração",
 | 
			
		||||
    "title": "Configurações de Expiração do Compartilhamento",
 | 
			
		||||
    "enableExpiration": "Habilitar expiração",
 | 
			
		||||
    "title": "Configurações de expiração do compartilhamento",
 | 
			
		||||
    "subtitle": "Configurar quando este compartilhamento expirará",
 | 
			
		||||
    "validation": {
 | 
			
		||||
      "dateMustBeFuture": "A data de expiração deve estar no futuro",
 | 
			
		||||
@@ -949,7 +957,7 @@
 | 
			
		||||
      "updateFailed": "Falha ao atualizar configurações de expiração"
 | 
			
		||||
    },
 | 
			
		||||
    "expires": "Expira:",
 | 
			
		||||
    "expirationDate": "Data de Expiração"
 | 
			
		||||
    "expirationDate": "Data de expiração"
 | 
			
		||||
  },
 | 
			
		||||
  "auth": {
 | 
			
		||||
    "errors": {
 | 
			
		||||
@@ -961,10 +969,10 @@
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "reverseShares": {
 | 
			
		||||
    "pageTitle": "Receber Arquivos",
 | 
			
		||||
    "pageTitle": "Receber arquivos",
 | 
			
		||||
    "search": {
 | 
			
		||||
      "title": "Gerenciar Links de Recebimento",
 | 
			
		||||
      "createButton": "Criar Link",
 | 
			
		||||
      "title": "Gerenciar links de recebimento",
 | 
			
		||||
      "createButton": "Criar link",
 | 
			
		||||
      "placeholder": "Buscar links de recebimento...",
 | 
			
		||||
      "results": "Encontrados {filtered} de {total} links de recebimento"
 | 
			
		||||
    },
 | 
			
		||||
@@ -974,13 +982,13 @@
 | 
			
		||||
      "status": "status",
 | 
			
		||||
      "access": "acesso",
 | 
			
		||||
      "description": "Descrição",
 | 
			
		||||
      "pageLayout": "Layout da Página",
 | 
			
		||||
      "pageLayout": "Layout da página",
 | 
			
		||||
      "security": "Segurança & Status",
 | 
			
		||||
      "limits": "Limites",
 | 
			
		||||
      "maxFiles": "Máximo de Arquivos",
 | 
			
		||||
      "maxFileSize": "Tamanho Máximo",
 | 
			
		||||
      "allowedTypes": "Tipos Permitidos",
 | 
			
		||||
      "filesReceived": "Arquivos Recebidos",
 | 
			
		||||
      "maxFiles": "Máximo de arquivos",
 | 
			
		||||
      "maxFileSize": "Tamanho máximo",
 | 
			
		||||
      "allowedTypes": "Tipos permitidos",
 | 
			
		||||
      "filesReceived": "Arquivos recebidos",
 | 
			
		||||
      "fileLimit": "Limite de Arquivos",
 | 
			
		||||
      "noLimit": "Sem limite",
 | 
			
		||||
      "noLinkCreated": "Nenhum link criado",
 | 
			
		||||
@@ -1269,6 +1277,7 @@
 | 
			
		||||
        "linkInactive": "Este link está inativo.",
 | 
			
		||||
        "linkExpired": "Este link expirou.",
 | 
			
		||||
        "uploadFailed": "Erro ao enviar arquivo",
 | 
			
		||||
        "retry": "Tentar Novamente",
 | 
			
		||||
        "fileTooLarge": "Arquivo muito grande. Tamanho máximo: {maxSize}",
 | 
			
		||||
        "fileTypeNotAllowed": "Tipo de arquivo não permitido. Tipos aceitos: {allowedTypes}",
 | 
			
		||||
        "maxFilesExceeded": "Máximo de {maxFiles} arquivos permitidos",
 | 
			
		||||
 
 | 
			
		||||
@@ -202,6 +202,8 @@
 | 
			
		||||
  "login": {
 | 
			
		||||
    "welcome": "Добро пожаловать в",
 | 
			
		||||
    "signInToContinue": "Войдите, чтобы продолжить",
 | 
			
		||||
    "emailOrUsernameLabel": "Электронная почта или имя пользователя",
 | 
			
		||||
    "emailOrUsernamePlaceholder": "Введите электронную почту или имя пользователя",
 | 
			
		||||
    "emailLabel": "Адрес электронной почты",
 | 
			
		||||
    "emailPlaceholder": "Введите вашу электронную почту",
 | 
			
		||||
    "passwordLabel": "Пароль",
 | 
			
		||||
@@ -730,7 +732,15 @@
 | 
			
		||||
    "title": "Использование хранилища",
 | 
			
		||||
    "ariaLabel": "Индикатор использования хранилища",
 | 
			
		||||
    "used": "Использовано",
 | 
			
		||||
    "available": "Доступно"
 | 
			
		||||
    "available": "Доступно",
 | 
			
		||||
    "loading": "Загрузка...",
 | 
			
		||||
    "retry": "Повторить",
 | 
			
		||||
    "errors": {
 | 
			
		||||
      "title": "Информация о хранилище недоступна",
 | 
			
		||||
      "detectionFailed": "Не удалось определить свободное место на диске. Это может быть связано с проблемами конфигурации системы или недостаточными правами доступа.",
 | 
			
		||||
      "serverError": "Произошла ошибка сервера при получении информации о хранилище. Пожалуйста, повторите попытку позже.",
 | 
			
		||||
      "unknown": "Произошла непредвиденная ошибка при загрузке информации о хранилище."
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "theme": {
 | 
			
		||||
    "toggle": "Переключить тему",
 | 
			
		||||
@@ -748,6 +758,7 @@
 | 
			
		||||
    "uploadProgress": "Прогресс загрузки",
 | 
			
		||||
    "upload": "Загрузить",
 | 
			
		||||
    "startUploads": "Начать Загрузку",
 | 
			
		||||
    "retry": "Повторить",
 | 
			
		||||
    "finish": "Завершить",
 | 
			
		||||
    "success": "Файл успешно загружен",
 | 
			
		||||
    "allSuccess": "{count, plural, =1 {Файл успешно загружен} other {# файлов успешно загружено}}",
 | 
			
		||||
@@ -844,6 +855,7 @@
 | 
			
		||||
    "passwordLength": "Пароль должен содержать не менее 8 символов",
 | 
			
		||||
    "passwordsMatch": "Пароли не совпадают",
 | 
			
		||||
    "emailRequired": "Требуется электронная почта",
 | 
			
		||||
    "emailOrUsernameRequired": "Электронная почта или имя пользователя обязательно",
 | 
			
		||||
    "passwordRequired": "Требуется пароль",
 | 
			
		||||
    "nameRequired": "Требуется имя",
 | 
			
		||||
    "required": "Это поле обязательно"
 | 
			
		||||
@@ -1269,6 +1281,7 @@
 | 
			
		||||
        "linkInactive": "Эта ссылка неактивна.",
 | 
			
		||||
        "linkExpired": "Срок действия этой ссылки истек.",
 | 
			
		||||
        "uploadFailed": "Ошибка при загрузке файла",
 | 
			
		||||
        "retry": "Повторить",
 | 
			
		||||
        "fileTooLarge": "Файл слишком большой. Максимальный размер: {maxSize}",
 | 
			
		||||
        "fileTypeNotAllowed": "Тип файла не разрешен. Разрешенные типы: {allowedTypes}",
 | 
			
		||||
        "maxFilesExceeded": "Максимально разрешено {maxFiles} файлов",
 | 
			
		||||
 
 | 
			
		||||
@@ -202,6 +202,8 @@
 | 
			
		||||
  "login": {
 | 
			
		||||
    "welcome": "Hoş geldiniz'e",
 | 
			
		||||
    "signInToContinue": "Devam etmek için oturum açın",
 | 
			
		||||
    "emailOrUsernameLabel": "E-posta veya Kullanıcı Adı",
 | 
			
		||||
    "emailOrUsernamePlaceholder": "E-posta veya kullanıcı adınızı girin",
 | 
			
		||||
    "emailLabel": "E-posta Adresi",
 | 
			
		||||
    "emailPlaceholder": "E-posta adresinizi girin",
 | 
			
		||||
    "passwordLabel": "Şifre",
 | 
			
		||||
@@ -730,7 +732,15 @@
 | 
			
		||||
    "title": "Depolama Kullanımı",
 | 
			
		||||
    "ariaLabel": "Depolama kullanım ilerleme çubuğu",
 | 
			
		||||
    "used": "kullanıldı",
 | 
			
		||||
    "available": "kullanılabilir"
 | 
			
		||||
    "available": "kullanılabilir",
 | 
			
		||||
    "loading": "Yükleniyor...",
 | 
			
		||||
    "retry": "Tekrar Dene",
 | 
			
		||||
    "errors": {
 | 
			
		||||
      "title": "Depolama bilgisi kullanılamıyor",
 | 
			
		||||
      "detectionFailed": "Disk alanı tespit edilemiyor. Bu, sistem yapılandırma sorunlarından veya yetersiz izinlerden kaynaklanıyor olabilir.",
 | 
			
		||||
      "serverError": "Depolama bilgisi alınırken sunucu hatası oluştu. Lütfen daha sonra tekrar deneyin.",
 | 
			
		||||
      "unknown": "Depolama bilgisi yüklenirken beklenmeyen bir hata oluştu."
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "theme": {
 | 
			
		||||
    "toggle": "Temayı değiştir",
 | 
			
		||||
@@ -748,6 +758,7 @@
 | 
			
		||||
    "uploadProgress": "Yükleme ilerlemesi",
 | 
			
		||||
    "upload": "Yükle",
 | 
			
		||||
    "startUploads": "Yüklemeleri Başlat",
 | 
			
		||||
    "retry": "Tekrar Dene",
 | 
			
		||||
    "finish": "Bitir",
 | 
			
		||||
    "success": "Dosya başarıyla yüklendi",
 | 
			
		||||
    "allSuccess": "{count, plural, =1 {Dosya başarıyla yüklendi} other {# dosya başarıyla yüklendi}}",
 | 
			
		||||
@@ -844,6 +855,7 @@
 | 
			
		||||
    "passwordLength": "Şifre en az 8 karakter olmalıdır",
 | 
			
		||||
    "passwordsMatch": "Şifreler eşleşmiyor",
 | 
			
		||||
    "emailRequired": "E-posta gerekli",
 | 
			
		||||
    "emailOrUsernameRequired": "E-posta veya kullanıcı adı gereklidir",
 | 
			
		||||
    "passwordRequired": "Şifre gerekli",
 | 
			
		||||
    "nameRequired": "İsim gereklidir",
 | 
			
		||||
    "required": "Bu alan zorunludur"
 | 
			
		||||
@@ -1269,6 +1281,7 @@
 | 
			
		||||
        "linkInactive": "Bu bağlantı pasif durumda.",
 | 
			
		||||
        "linkExpired": "Bu bağlantının süresi doldu.",
 | 
			
		||||
        "uploadFailed": "Dosya yüklenirken hata oluştu",
 | 
			
		||||
        "retry": "Tekrar Dene",
 | 
			
		||||
        "fileTooLarge": "Dosya çok büyük. Maksimum boyut: {maxSize}",
 | 
			
		||||
        "fileTypeNotAllowed": "Dosya türüne izin verilmiyor. İzin verilen türler: {allowedTypes}",
 | 
			
		||||
        "maxFilesExceeded": "Maksimum {maxFiles} dosyaya izin veriliyor",
 | 
			
		||||
 
 | 
			
		||||
@@ -202,6 +202,8 @@
 | 
			
		||||
  "login": {
 | 
			
		||||
    "welcome": "欢迎您",
 | 
			
		||||
    "signInToContinue": "请登录以继续",
 | 
			
		||||
    "emailOrUsernameLabel": "电子邮件或用户名",
 | 
			
		||||
    "emailOrUsernamePlaceholder": "请输入您的电子邮件或用户名",
 | 
			
		||||
    "emailLabel": "电子邮件地址",
 | 
			
		||||
    "emailPlaceholder": "请输入您的电子邮件",
 | 
			
		||||
    "passwordLabel": "密码",
 | 
			
		||||
@@ -730,7 +732,15 @@
 | 
			
		||||
    "title": "存储使用情况",
 | 
			
		||||
    "ariaLabel": "存储使用进度条",
 | 
			
		||||
    "used": "已使用:",
 | 
			
		||||
    "available": "可用:"
 | 
			
		||||
    "available": "可用",
 | 
			
		||||
    "loading": "加载中...",
 | 
			
		||||
    "retry": "重试",
 | 
			
		||||
    "errors": {
 | 
			
		||||
      "title": "存储信息不可用",
 | 
			
		||||
      "detectionFailed": "无法检测磁盘空间。这可能是由于系统配置问题或权限不足。",
 | 
			
		||||
      "serverError": "检索存储信息时发生服务器错误。请稍后重试。",
 | 
			
		||||
      "unknown": "加载存储信息时发生意外错误。"
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "theme": {
 | 
			
		||||
    "toggle": "切换主题",
 | 
			
		||||
@@ -748,6 +758,7 @@
 | 
			
		||||
    "uploadProgress": "上传进度",
 | 
			
		||||
    "upload": "上传",
 | 
			
		||||
    "startUploads": "开始上传",
 | 
			
		||||
    "retry": "重试",
 | 
			
		||||
    "finish": "完成",
 | 
			
		||||
    "success": "文件上传成功",
 | 
			
		||||
    "allSuccess": "{count, plural, =1 {文件上传成功} other {# 个文件上传成功}}",
 | 
			
		||||
@@ -844,6 +855,7 @@
 | 
			
		||||
    "passwordLength": "密码至少需要8个字符",
 | 
			
		||||
    "passwordsMatch": "密码不匹配",
 | 
			
		||||
    "emailRequired": "电子邮件为必填项",
 | 
			
		||||
    "emailOrUsernameRequired": "电子邮件或用户名是必填项",
 | 
			
		||||
    "passwordRequired": "密码为必填项",
 | 
			
		||||
    "nameRequired": "名称为必填项",
 | 
			
		||||
    "required": "此字段为必填项"
 | 
			
		||||
@@ -1269,6 +1281,7 @@
 | 
			
		||||
        "linkInactive": "此链接已停用。",
 | 
			
		||||
        "linkExpired": "此链接已过期。",
 | 
			
		||||
        "uploadFailed": "上传文件时出错",
 | 
			
		||||
        "retry": "重试",
 | 
			
		||||
        "fileTooLarge": "文件太大。最大大小:{maxSize}",
 | 
			
		||||
        "fileTypeNotAllowed": "不允许的文件类型。允许的类型:{allowedTypes}",
 | 
			
		||||
        "maxFilesExceeded": "最多允许 {maxFiles} 个文件",
 | 
			
		||||
 
 | 
			
		||||
@@ -156,13 +156,11 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
 | 
			
		||||
    const { file } = fileWithProgress;
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      // Start upload
 | 
			
		||||
      updateFileStatus(index, {
 | 
			
		||||
        status: FILE_STATUS.UPLOADING,
 | 
			
		||||
        progress: UPLOAD_PROGRESS.INITIAL,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      // Generate object name and get presigned URL
 | 
			
		||||
      const objectName = generateObjectName(file.name);
 | 
			
		||||
      const presignedResponse = await getPresignedUrlForUploadByAlias(
 | 
			
		||||
        alias,
 | 
			
		||||
@@ -170,16 +168,12 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
 | 
			
		||||
        password ? { password } : undefined
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      // Upload to storage
 | 
			
		||||
      await uploadFileToStorage(file, presignedResponse.data.url);
 | 
			
		||||
 | 
			
		||||
      // Update progress
 | 
			
		||||
      updateFileStatus(index, { progress: UPLOAD_PROGRESS.COMPLETE });
 | 
			
		||||
 | 
			
		||||
      // Register file upload
 | 
			
		||||
      await registerUploadedFile(file, objectName);
 | 
			
		||||
 | 
			
		||||
      // Mark as successful
 | 
			
		||||
      updateFileStatus(index, { status: FILE_STATUS.SUCCESS });
 | 
			
		||||
    } catch (error: any) {
 | 
			
		||||
      console.error("Upload error:", error);
 | 
			
		||||
@@ -243,7 +237,6 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
 | 
			
		||||
  );
 | 
			
		||||
  const hasSuccessfulUploads = files.some((file) => file.status === FILE_STATUS.SUCCESS);
 | 
			
		||||
 | 
			
		||||
  // Call onUploadSuccess when all files are processed and there are successful uploads
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (allFilesProcessed && hasSuccessfulUploads && files.length > 0) {
 | 
			
		||||
      onUploadSuccess?.();
 | 
			
		||||
@@ -266,7 +259,6 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const renderFileRestrictions = () => {
 | 
			
		||||
    // Calculate remaining files that can be uploaded
 | 
			
		||||
    const calculateRemainingFiles = (): number => {
 | 
			
		||||
      if (!reverseShare.maxFiles) return 0;
 | 
			
		||||
      const currentTotal = reverseShare.currentFileCount + files.length;
 | 
			
		||||
@@ -339,13 +331,34 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
 | 
			
		||||
            <IconX className="h-4 w-4" />
 | 
			
		||||
          </Button>
 | 
			
		||||
        )}
 | 
			
		||||
        {fileWithProgress.status === FILE_STATUS.ERROR && (
 | 
			
		||||
          <div className="flex gap-1">
 | 
			
		||||
            <Button
 | 
			
		||||
              size="sm"
 | 
			
		||||
              variant="ghost"
 | 
			
		||||
              onClick={() => {
 | 
			
		||||
                setFiles((prev) =>
 | 
			
		||||
                  prev.map((file, i) =>
 | 
			
		||||
                    i === index ? { ...file, status: FILE_STATUS.PENDING, error: undefined } : file
 | 
			
		||||
                  )
 | 
			
		||||
                );
 | 
			
		||||
              }}
 | 
			
		||||
              disabled={isUploading}
 | 
			
		||||
              title={t("reverseShares.upload.retry")}
 | 
			
		||||
            >
 | 
			
		||||
              <IconUpload className="h-4 w-4" />
 | 
			
		||||
            </Button>
 | 
			
		||||
            <Button size="sm" variant="ghost" onClick={() => removeFile(index)} disabled={isUploading}>
 | 
			
		||||
              <IconX className="h-4 w-4" />
 | 
			
		||||
            </Button>
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="space-y-6">
 | 
			
		||||
      {/* File Drop Zone */}
 | 
			
		||||
      <div {...getRootProps()} className={getDropzoneStyles()}>
 | 
			
		||||
        <input {...getInputProps()} />
 | 
			
		||||
        <IconUpload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
 | 
			
		||||
@@ -357,7 +370,6 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
 | 
			
		||||
        {renderFileRestrictions()}
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      {/* File List */}
 | 
			
		||||
      {files.length > 0 && (
 | 
			
		||||
        <div className="space-y-2">
 | 
			
		||||
          <h4 className="font-medium text-gray-900 dark:text-white">{t("reverseShares.upload.fileList.title")}</h4>
 | 
			
		||||
@@ -365,7 +377,6 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {/* User Information */}
 | 
			
		||||
      <div className="space-y-4">
 | 
			
		||||
        <div className="grid grid-cols-1 gap-4">
 | 
			
		||||
          <div className="space-y-2">
 | 
			
		||||
@@ -409,14 +420,12 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      {/* Upload Button */}
 | 
			
		||||
      <Button onClick={handleUpload} disabled={!canUpload} className="w-full text-white" size="lg" variant="default">
 | 
			
		||||
        {isUploading
 | 
			
		||||
          ? t("reverseShares.upload.form.uploading")
 | 
			
		||||
          : t("reverseShares.upload.form.uploadButton", { count: files.length })}
 | 
			
		||||
      </Button>
 | 
			
		||||
 | 
			
		||||
      {/* Success Message */}
 | 
			
		||||
      {allFilesProcessed && hasSuccessfulUploads && (
 | 
			
		||||
        <div className="text-center p-4 bg-green-50 dark:bg-green-950/20 rounded-lg">
 | 
			
		||||
          <p className="text-green-800 dark:text-green-200 font-medium">{t("reverseShares.upload.success.title")}</p>
 | 
			
		||||
 
 | 
			
		||||
@@ -66,7 +66,6 @@ export function WeTransferStatusMessage({
 | 
			
		||||
}: WeTransferStatusMessageProps) {
 | 
			
		||||
  const t = useTranslations();
 | 
			
		||||
 | 
			
		||||
  // Map message types to variants
 | 
			
		||||
  const getVariant = (): "success" | "warning" | "error" | "info" | "neutral" => {
 | 
			
		||||
    switch (type) {
 | 
			
		||||
      case MESSAGE_TYPES.SUCCESS:
 | 
			
		||||
 
 | 
			
		||||
@@ -12,13 +12,11 @@ import { FileUploadSection } from "./file-upload-section";
 | 
			
		||||
import { WeTransferStatusMessage } from "./shared/status-message";
 | 
			
		||||
import { TransparentFooter } from "./transparent-footer";
 | 
			
		||||
 | 
			
		||||
// Função para escolher uma imagem aleatória
 | 
			
		||||
const getRandomBackgroundImage = (): string => {
 | 
			
		||||
  const randomIndex = Math.floor(Math.random() * BACKGROUND_IMAGES.length);
 | 
			
		||||
  return BACKGROUND_IMAGES[randomIndex];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Hook para gerenciar a imagem de background
 | 
			
		||||
const useBackgroundImage = () => {
 | 
			
		||||
  const [selectedImage, setSelectedImage] = useState<string>("");
 | 
			
		||||
  const [imageLoaded, setImageLoaded] = useState(false);
 | 
			
		||||
@@ -42,7 +40,6 @@ const useBackgroundImage = () => {
 | 
			
		||||
  return { selectedImage, imageLoaded };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Componente para controles do header
 | 
			
		||||
const HeaderControls = () => (
 | 
			
		||||
  <div className="absolute top-4 right-4 md:top-6 md:right-6 z-40 flex items-center gap-2">
 | 
			
		||||
    <div className="bg-white/10 dark:bg-black/20 backdrop-blur-xs border border-white/20 dark:border-white/10 rounded-lg p-1">
 | 
			
		||||
@@ -54,7 +51,6 @@ const HeaderControls = () => (
 | 
			
		||||
  </div>
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
// Componente para o fundo com imagem
 | 
			
		||||
const BackgroundLayer = ({ selectedImage, imageLoaded }: { selectedImage: string; imageLoaded: boolean }) => (
 | 
			
		||||
  <>
 | 
			
		||||
    <div className="absolute inset-0 z-0 bg-background" />
 | 
			
		||||
@@ -162,18 +158,15 @@ export function WeTransferLayout({
 | 
			
		||||
      <BackgroundLayer selectedImage={selectedImage} imageLoaded={imageLoaded} />
 | 
			
		||||
      <HeaderControls />
 | 
			
		||||
 | 
			
		||||
      {/* Loading indicator */}
 | 
			
		||||
      {!imageLoaded && (
 | 
			
		||||
        <div className="absolute inset-0 z-30 flex items-center justify-center">
 | 
			
		||||
          <div className="animate-pulse text-white/70 text-sm">{t("reverseShares.upload.layout.loading")}</div>
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {/* Main Content */}
 | 
			
		||||
      <div className="relative z-30 min-h-screen flex items-center justify-start p-4 md:p-8 lg:p-12 xl:p-16">
 | 
			
		||||
        <div className="w-full max-w-md lg:max-w-lg xl:max-w-xl">
 | 
			
		||||
          <div className="bg-white dark:bg-black rounded-2xl shadow-2xl p-6 md:p-8 backdrop-blur-sm border border-white/20">
 | 
			
		||||
            {/* Header */}
 | 
			
		||||
            <div className="text-left mb-6 md:mb-8">
 | 
			
		||||
              <h1 className="text-xl md:text-2xl lg:text-3xl font-bold text-gray-900 dark:text-white mb-2">
 | 
			
		||||
                {reverseShare?.name || t("reverseShares.upload.layout.defaultTitle")}
 | 
			
		||||
@@ -183,7 +176,6 @@ export function WeTransferLayout({
 | 
			
		||||
              )}
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            {/* Upload Section */}
 | 
			
		||||
            {getUploadSectionContent()}
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,3 @@
 | 
			
		||||
// HTTP Status Constants
 | 
			
		||||
export const HTTP_STATUS = {
 | 
			
		||||
  UNAUTHORIZED: 401,
 | 
			
		||||
  FORBIDDEN: 403,
 | 
			
		||||
@@ -6,13 +5,11 @@ export const HTTP_STATUS = {
 | 
			
		||||
  GONE: 410,
 | 
			
		||||
} as const;
 | 
			
		||||
 | 
			
		||||
// Error Messages
 | 
			
		||||
export const ERROR_MESSAGES = {
 | 
			
		||||
  PASSWORD_REQUIRED: "Password required",
 | 
			
		||||
  INVALID_PASSWORD: "Invalid password",
 | 
			
		||||
} as const;
 | 
			
		||||
 | 
			
		||||
// Error types
 | 
			
		||||
export type ErrorType = "inactive" | "notFound" | "expired" | "generic" | null;
 | 
			
		||||
 | 
			
		||||
export const STATUS_VARIANTS = {
 | 
			
		||||
 
 | 
			
		||||
@@ -17,7 +17,6 @@ export function useReverseShareUpload({ alias }: UseReverseShareUploadProps) {
 | 
			
		||||
  const router = useRouter();
 | 
			
		||||
  const t = useTranslations();
 | 
			
		||||
 | 
			
		||||
  // States
 | 
			
		||||
  const [reverseShare, setReverseShare] = useState<ReverseShareInfo | null>(null);
 | 
			
		||||
  const [isLoading, setIsLoading] = useState(true);
 | 
			
		||||
  const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false);
 | 
			
		||||
@@ -25,7 +24,6 @@ export function useReverseShareUpload({ alias }: UseReverseShareUploadProps) {
 | 
			
		||||
  const [hasUploadedSuccessfully, setHasUploadedSuccessfully] = useState(false);
 | 
			
		||||
  const [error, setError] = useState<{ type: ErrorType }>({ type: null });
 | 
			
		||||
 | 
			
		||||
  // Utility functions
 | 
			
		||||
  const redirectToHome = () => router.push("/");
 | 
			
		||||
 | 
			
		||||
  const checkIfMaxFilesReached = (reverseShareData: ReverseShareInfo): boolean => {
 | 
			
		||||
@@ -109,23 +107,19 @@ export function useReverseShareUpload({ alias }: UseReverseShareUploadProps) {
 | 
			
		||||
    }
 | 
			
		||||
  }, [alias]);
 | 
			
		||||
 | 
			
		||||
  // Computed values
 | 
			
		||||
  const isMaxFilesReached = reverseShare ? checkIfMaxFilesReached(reverseShare) : false;
 | 
			
		||||
  const isWeTransferLayout = reverseShare?.pageLayout === "WETRANSFER";
 | 
			
		||||
  const hasError = error.type !== null || (!reverseShare && !isLoading && !isPasswordModalOpen);
 | 
			
		||||
 | 
			
		||||
  // Error state booleans for backward compatibility
 | 
			
		||||
  const isLinkInactive = error.type === "inactive";
 | 
			
		||||
  const isLinkNotFound = error.type === "notFound" || (!reverseShare && !isLoading && !isPasswordModalOpen);
 | 
			
		||||
  const isLinkExpired = error.type === "expired";
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    // Data
 | 
			
		||||
    reverseShare,
 | 
			
		||||
    currentPassword,
 | 
			
		||||
    alias,
 | 
			
		||||
 | 
			
		||||
    // States
 | 
			
		||||
    isLoading,
 | 
			
		||||
    isPasswordModalOpen,
 | 
			
		||||
    hasUploadedSuccessfully,
 | 
			
		||||
@@ -134,12 +128,10 @@ export function useReverseShareUpload({ alias }: UseReverseShareUploadProps) {
 | 
			
		||||
    isWeTransferLayout,
 | 
			
		||||
    hasError,
 | 
			
		||||
 | 
			
		||||
    // Error states (for backward compatibility)
 | 
			
		||||
    isLinkInactive,
 | 
			
		||||
    isLinkNotFound,
 | 
			
		||||
    isLinkExpired,
 | 
			
		||||
 | 
			
		||||
    // Actions
 | 
			
		||||
    handlePasswordSubmit,
 | 
			
		||||
    handlePasswordModalClose,
 | 
			
		||||
    handleUploadSuccess,
 | 
			
		||||
 
 | 
			
		||||
@@ -27,19 +27,16 @@ export default function ReverseShareUploadPage() {
 | 
			
		||||
    handleUploadSuccess,
 | 
			
		||||
  } = useReverseShareUpload({ alias: shareAlias });
 | 
			
		||||
 | 
			
		||||
  // Loading state
 | 
			
		||||
  if (isLoading) {
 | 
			
		||||
    return <LoadingScreen />;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Password required state
 | 
			
		||||
  if (isPasswordModalOpen) {
 | 
			
		||||
    return (
 | 
			
		||||
      <PasswordModal isOpen={isPasswordModalOpen} onSubmit={handlePasswordSubmit} onClose={handlePasswordModalClose} />
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Error states or missing data - always use DefaultLayout for simplicity
 | 
			
		||||
  if (hasError) {
 | 
			
		||||
    return (
 | 
			
		||||
      <DefaultLayout
 | 
			
		||||
@@ -56,7 +53,6 @@ export default function ReverseShareUploadPage() {
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Render appropriate layout for normal states
 | 
			
		||||
  if (isWeTransferLayout) {
 | 
			
		||||
    return (
 | 
			
		||||
      <WeTransferLayout
 | 
			
		||||
 
 | 
			
		||||
@@ -37,20 +37,12 @@ import { ReverseShare } from "../hooks/use-reverse-shares";
 | 
			
		||||
import { FileSizeInput } from "./file-size-input";
 | 
			
		||||
import { FileTypesTagsInput } from "./file-types-tags-input";
 | 
			
		||||
 | 
			
		||||
// Constants
 | 
			
		||||
const DEFAULT_VALUES = {
 | 
			
		||||
  EMPTY_STRING: "",
 | 
			
		||||
  ZERO_STRING: "0",
 | 
			
		||||
  PAGE_LAYOUT: "DEFAULT" as const,
 | 
			
		||||
} as const;
 | 
			
		||||
 | 
			
		||||
const FORM_SECTIONS = {
 | 
			
		||||
  BASIC_INFO: "basicInfo",
 | 
			
		||||
  EXPIRATION: "expiration",
 | 
			
		||||
  FILE_LIMITS: "fileLimits",
 | 
			
		||||
  PASSWORD: "password",
 | 
			
		||||
} as const;
 | 
			
		||||
 | 
			
		||||
interface EditReverseShareFormData {
 | 
			
		||||
  name: string;
 | 
			
		||||
  description?: string;
 | 
			
		||||
@@ -168,7 +160,6 @@ export function EditReverseShareModal({
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Helper functions
 | 
			
		||||
function getFormDefaultValues(): EditReverseShareFormData {
 | 
			
		||||
  return {
 | 
			
		||||
    name: DEFAULT_VALUES.EMPTY_STRING,
 | 
			
		||||
@@ -224,19 +215,16 @@ function buildUpdatePayload(data: EditReverseShareFormData, id: string): UpdateR
 | 
			
		||||
    isActive: data.isActive,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  // Add optional fields
 | 
			
		||||
  if (data.description?.trim()) {
 | 
			
		||||
    payload.description = data.description.trim();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Handle expiration
 | 
			
		||||
  if (data.hasExpiration && data.expiration) {
 | 
			
		||||
    payload.expiration = new Date(data.expiration).toISOString();
 | 
			
		||||
  } else if (!data.hasExpiration) {
 | 
			
		||||
    payload.expiration = undefined;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Handle file limits
 | 
			
		||||
  if (data.hasFileLimits) {
 | 
			
		||||
    payload.maxFiles = parsePositiveIntegerOrNull(data.maxFiles);
 | 
			
		||||
    payload.maxFileSize = parsePositiveIntegerOrNull(data.maxFileSize);
 | 
			
		||||
@@ -245,10 +233,8 @@ function buildUpdatePayload(data: EditReverseShareFormData, id: string): UpdateR
 | 
			
		||||
    payload.maxFileSize = null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Handle allowed file types
 | 
			
		||||
  payload.allowedFileTypes = data.allowedFileTypes?.trim() || null;
 | 
			
		||||
 | 
			
		||||
  // Handle password
 | 
			
		||||
  if (data.hasPassword && data.password) {
 | 
			
		||||
    payload.password = data.password;
 | 
			
		||||
  } else if (!data.hasPassword) {
 | 
			
		||||
@@ -289,7 +275,6 @@ function createLimitCheckbox(id: string, checked: boolean, onChange: (checked: b
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Section Components
 | 
			
		||||
function BasicInfoSection({ form, t }: { form: any; t: any }) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="space-y-4">
 | 
			
		||||
@@ -442,7 +427,6 @@ function FileLimitsSection({
 | 
			
		||||
 | 
			
		||||
      {hasFileLimits && (
 | 
			
		||||
        <div className="space-y-4">
 | 
			
		||||
          {/* Max Files Field */}
 | 
			
		||||
          <FormField
 | 
			
		||||
            control={form.control}
 | 
			
		||||
            name="maxFiles"
 | 
			
		||||
@@ -479,7 +463,6 @@ function FileLimitsSection({
 | 
			
		||||
            )}
 | 
			
		||||
          />
 | 
			
		||||
 | 
			
		||||
          {/* Max File Size Field */}
 | 
			
		||||
          <FormField
 | 
			
		||||
            control={form.control}
 | 
			
		||||
            name="maxFileSize"
 | 
			
		||||
@@ -515,7 +498,6 @@ function FileLimitsSection({
 | 
			
		||||
            )}
 | 
			
		||||
          />
 | 
			
		||||
 | 
			
		||||
          {/* Allowed File Types Field */}
 | 
			
		||||
          <FormField
 | 
			
		||||
            control={form.control}
 | 
			
		||||
            name="allowedFileTypes"
 | 
			
		||||
 
 | 
			
		||||
@@ -34,10 +34,8 @@ function bytesToHumanReadable(bytes: string): { value: string; unit: Unit } {
 | 
			
		||||
    const value = numBytes / multiplier;
 | 
			
		||||
 | 
			
		||||
    if (value >= 1) {
 | 
			
		||||
      // Se o valor é >= 1 nesta unidade, usar ela
 | 
			
		||||
      const rounded = Math.round(value * 100) / 100; // Arredonda para 2 casas decimais
 | 
			
		||||
      const rounded = Math.round(value * 100) / 100;
 | 
			
		||||
 | 
			
		||||
      // Se está muito próximo de um inteiro, usar inteiro
 | 
			
		||||
      if (Math.abs(rounded - Math.round(rounded)) < 0.01) {
 | 
			
		||||
        return { value: Math.round(rounded).toString(), unit };
 | 
			
		||||
      } else {
 | 
			
		||||
@@ -46,7 +44,6 @@ function bytesToHumanReadable(bytes: string): { value: string; unit: Unit } {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Fallback para MB
 | 
			
		||||
  const mbValue = numBytes / UNIT_MULTIPLIERS.MB;
 | 
			
		||||
  return { value: mbValue.toFixed(2), unit: "MB" as Unit };
 | 
			
		||||
}
 | 
			
		||||
@@ -92,7 +89,6 @@ export function FileSizeInput({ value, onChange, disabled = false, error, placeh
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleUnitChange = (newUnit: Unit) => {
 | 
			
		||||
    // Ignorar valores vazios ou inválidos que podem vir do Select quando atualizado programaticamente
 | 
			
		||||
    if (!newUnit || !["MB", "GB", "TB", "PB"].includes(newUnit)) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -24,7 +24,6 @@ export function FileTypesTagsInput({
 | 
			
		||||
  const [inputValue, setInputValue] = useState("");
 | 
			
		||||
 | 
			
		||||
  const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
 | 
			
		||||
    // Separadores: Enter, espaço, vírgula, pipe, traço
 | 
			
		||||
    if (e.key === "Enter" || e.key === " " || e.key === "," || e.key === "|" || e.key === "-") {
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      addTag();
 | 
			
		||||
@@ -32,7 +31,6 @@ export function FileTypesTagsInput({
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      removeTag(value.length - 1);
 | 
			
		||||
    } else if (e.key === ".") {
 | 
			
		||||
      // Impedir pontos
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
@@ -51,7 +49,6 @@ export function FileTypesTagsInput({
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
 | 
			
		||||
    // Remover pontos e forçar minúsculo
 | 
			
		||||
    const sanitizedValue = e.target.value.replace(/\./g, "").toLowerCase();
 | 
			
		||||
    setInputValue(sanitizedValue);
 | 
			
		||||
  };
 | 
			
		||||
 
 | 
			
		||||
@@ -54,14 +54,11 @@ export function GenerateAliasModal({
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  // Atualiza o valor padrão quando o reverseShare muda
 | 
			
		||||
  React.useEffect(() => {
 | 
			
		||||
    if (reverseShare) {
 | 
			
		||||
      if (reverseShare.alias?.alias) {
 | 
			
		||||
        // Se já tem alias, usa o existente
 | 
			
		||||
        form.setValue("alias", reverseShare.alias.alias);
 | 
			
		||||
      } else {
 | 
			
		||||
        // Se não tem alias, gera um novo valor padrão
 | 
			
		||||
        form.setValue("alias", generateDefaultAlias());
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
@@ -75,7 +72,6 @@ export function GenerateAliasModal({
 | 
			
		||||
      await onCreateAlias(reverseShare.id, data.alias);
 | 
			
		||||
      onClose();
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      // Erro já é tratado no hook
 | 
			
		||||
    } finally {
 | 
			
		||||
      setIsSubmitting(false);
 | 
			
		||||
    }
 | 
			
		||||
@@ -150,17 +146,15 @@ export function GenerateAliasModal({
 | 
			
		||||
                      className="max-w-full"
 | 
			
		||||
                      {...field}
 | 
			
		||||
                      onChange={(e) => {
 | 
			
		||||
                        // Converter espaços em hífens e remover caracteres não permitidos
 | 
			
		||||
                        const value = e.target.value
 | 
			
		||||
                          .replace(/\s+/g, "-") // espaços viram hífens
 | 
			
		||||
                          .replace(/[^a-zA-Z0-9-_]/g, "") // remove caracteres não permitidos
 | 
			
		||||
                          .toLowerCase(); // converte para minúsculo
 | 
			
		||||
                          .replace(/\s+/g, "-")
 | 
			
		||||
                          .replace(/[^a-zA-Z0-9-_]/g, "")
 | 
			
		||||
                          .toLowerCase();
 | 
			
		||||
                        field.onChange(value);
 | 
			
		||||
                      }}
 | 
			
		||||
                    />
 | 
			
		||||
                  </FormControl>
 | 
			
		||||
 | 
			
		||||
                  {/* Preview do link */}
 | 
			
		||||
                  {field.value && field.value.length >= 3 && (
 | 
			
		||||
                    <div className="mt-2 p-2 bg-primary/5 border border-primary/20 rounded-md overflow-hidden">
 | 
			
		||||
                      <label className="text-xs text-muted-foreground block mb-1">
 | 
			
		||||
 
 | 
			
		||||
@@ -25,7 +25,6 @@ import { getFileIcon } from "@/utils/file-icons";
 | 
			
		||||
import { ReverseShare } from "../hooks/use-reverse-shares";
 | 
			
		||||
import { ReverseShareFilePreviewModal } from "./reverse-share-file-preview-modal";
 | 
			
		||||
 | 
			
		||||
// Types
 | 
			
		||||
interface EditingState {
 | 
			
		||||
  fileId: string;
 | 
			
		||||
  field: string;
 | 
			
		||||
@@ -36,7 +35,6 @@ interface HoverState {
 | 
			
		||||
  field: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Custom Hooks
 | 
			
		||||
function useFileEdit() {
 | 
			
		||||
  const [editingFile, setEditingFile] = useState<EditingState | null>(null);
 | 
			
		||||
  const [editValue, setEditValue] = useState("");
 | 
			
		||||
@@ -74,7 +72,6 @@ function useFileEdit() {
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Utility Functions
 | 
			
		||||
const formatFileSize = (sizeString: string) => {
 | 
			
		||||
  const sizeInBytes = parseInt(sizeString);
 | 
			
		||||
  if (sizeInBytes === 0) return "0 B";
 | 
			
		||||
@@ -122,7 +119,6 @@ const getSenderInitials = (file: ReverseShareFile) => {
 | 
			
		||||
  return "?";
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Components
 | 
			
		||||
interface EditableFieldProps {
 | 
			
		||||
  file: ReverseShareFile;
 | 
			
		||||
  field: "name" | "description";
 | 
			
		||||
 
 | 
			
		||||
@@ -103,7 +103,6 @@ export function ReverseShareCard({
 | 
			
		||||
    const { field } = editingField;
 | 
			
		||||
    let processedValue: string | number | null | boolean = editValue;
 | 
			
		||||
 | 
			
		||||
    // Processar valores específicos
 | 
			
		||||
    if (field === "isActive") {
 | 
			
		||||
      processedValue = editValue === "true";
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -18,7 +18,6 @@ import type {
 | 
			
		||||
  UpdateReverseShareBody,
 | 
			
		||||
} from "@/http/endpoints/reverse-shares/types";
 | 
			
		||||
 | 
			
		||||
// Tipo baseado na resposta da API
 | 
			
		||||
export type ReverseShare = ListUserReverseSharesResult["data"]["reverseShares"][0];
 | 
			
		||||
 | 
			
		||||
export function useReverseShares() {
 | 
			
		||||
@@ -62,7 +61,6 @@ export function useReverseShares() {
 | 
			
		||||
 | 
			
		||||
      setReverseShares(sortedReverseShares);
 | 
			
		||||
 | 
			
		||||
      // Atualiza o reverseShare específico que está sendo visualizado
 | 
			
		||||
      const updatedReverseShare = allReverseShares.find((rs) => rs.id === id);
 | 
			
		||||
      if (updatedReverseShare) {
 | 
			
		||||
        if (reverseShareToViewFiles && reverseShareToViewFiles.id === id) {
 | 
			
		||||
@@ -83,13 +81,11 @@ export function useReverseShares() {
 | 
			
		||||
      const response = await createReverseShare(data);
 | 
			
		||||
      const newReverseShare = response.data.reverseShare;
 | 
			
		||||
 | 
			
		||||
      // Adiciona ao estado local
 | 
			
		||||
      setReverseShares((prev) => [newReverseShare as ReverseShare, ...prev]);
 | 
			
		||||
 | 
			
		||||
      toast.success(t("reverseShares.messages.createSuccess"));
 | 
			
		||||
      setIsCreateModalOpen(false);
 | 
			
		||||
 | 
			
		||||
      // Automaticamente abre o modal de alias para o reverse share criado
 | 
			
		||||
      setReverseShareToGenerateLink(newReverseShare as ReverseShare);
 | 
			
		||||
 | 
			
		||||
      return newReverseShare;
 | 
			
		||||
@@ -113,7 +109,6 @@ export function useReverseShares() {
 | 
			
		||||
        updatedAt: new Date().toISOString(),
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      // Atualiza o estado local
 | 
			
		||||
      setReverseShares((prev) =>
 | 
			
		||||
        prev.map((rs) =>
 | 
			
		||||
          rs.id === reverseShareId
 | 
			
		||||
@@ -125,7 +120,6 @@ export function useReverseShares() {
 | 
			
		||||
        )
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      // Atualiza o reverseShare que está sendo visualizado no modal de detalhes
 | 
			
		||||
      if (reverseShareToViewDetails && reverseShareToViewDetails.id === reverseShareId) {
 | 
			
		||||
        setReverseShareToViewDetails({
 | 
			
		||||
          ...reverseShareToViewDetails,
 | 
			
		||||
@@ -145,7 +139,6 @@ export function useReverseShares() {
 | 
			
		||||
    try {
 | 
			
		||||
      await deleteReverseShare(reverseShare.id);
 | 
			
		||||
 | 
			
		||||
      // Remove do estado local
 | 
			
		||||
      setReverseShares((prev) => prev.filter((rs) => rs.id !== reverseShare.id));
 | 
			
		||||
 | 
			
		||||
      toast.success(t("reverseShares.messages.deleteSuccess"));
 | 
			
		||||
@@ -163,7 +156,6 @@ export function useReverseShares() {
 | 
			
		||||
      const response = await updateReverseShare(data);
 | 
			
		||||
      const updatedReverseShare = response.data.reverseShare;
 | 
			
		||||
 | 
			
		||||
      // Atualiza o estado local
 | 
			
		||||
      setReverseShares((prev) =>
 | 
			
		||||
        prev.map((rs) => (rs.id === data.id ? ({ ...rs, ...updatedReverseShare } as ReverseShare) : rs))
 | 
			
		||||
      );
 | 
			
		||||
@@ -186,12 +178,10 @@ export function useReverseShares() {
 | 
			
		||||
      const response = await updateReverseSharePassword(id, payload);
 | 
			
		||||
      const updatedReverseShare = response.data.reverseShare;
 | 
			
		||||
 | 
			
		||||
      // Atualiza o estado local
 | 
			
		||||
      setReverseShares((prev) =>
 | 
			
		||||
        prev.map((rs) => (rs.id === id ? ({ ...rs, ...updatedReverseShare } as ReverseShare) : rs))
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      // Atualiza o reverseShare que está sendo visualizado no modal de detalhes
 | 
			
		||||
      if (reverseShareToViewDetails && reverseShareToViewDetails.id === id) {
 | 
			
		||||
        setReverseShareToViewDetails({ ...reverseShareToViewDetails, ...updatedReverseShare } as ReverseShare);
 | 
			
		||||
      }
 | 
			
		||||
@@ -208,12 +198,10 @@ export function useReverseShares() {
 | 
			
		||||
      const response = await updateReverseShare(payload);
 | 
			
		||||
      const updatedReverseShare = response.data.reverseShare;
 | 
			
		||||
 | 
			
		||||
      // Atualiza o estado local
 | 
			
		||||
      setReverseShares((prev) =>
 | 
			
		||||
        prev.map((rs) => (rs.id === id ? ({ ...rs, ...updatedReverseShare } as ReverseShare) : rs))
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      // Atualiza o reverseShare que está sendo visualizado no modal de detalhes
 | 
			
		||||
      if (reverseShareToViewDetails && reverseShareToViewDetails.id === id) {
 | 
			
		||||
        setReverseShareToViewDetails({ ...reverseShareToViewDetails, ...updatedReverseShare } as ReverseShare);
 | 
			
		||||
      }
 | 
			
		||||
@@ -232,12 +220,10 @@ export function useReverseShares() {
 | 
			
		||||
      const response = await updateReverseShare(payload);
 | 
			
		||||
      const updatedReverseShare = response.data.reverseShare;
 | 
			
		||||
 | 
			
		||||
      // Atualiza o estado local
 | 
			
		||||
      setReverseShares((prev) =>
 | 
			
		||||
        prev.map((rs) => (rs.id === id ? ({ ...rs, ...updatedReverseShare } as ReverseShare) : rs))
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      // Atualiza o reverseShare que está sendo visualizado no modal de detalhes
 | 
			
		||||
      if (reverseShareToViewDetails && reverseShareToViewDetails.id === id) {
 | 
			
		||||
        setReverseShareToViewDetails({ ...reverseShareToViewDetails, ...updatedReverseShare } as ReverseShare);
 | 
			
		||||
      }
 | 
			
		||||
@@ -256,7 +242,6 @@ export function useReverseShares() {
 | 
			
		||||
    loadReverseShares();
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  // Sincroniza o reverseShareToViewDetails com a lista atualizada
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (reverseShareToViewDetails) {
 | 
			
		||||
      const updatedReverseShare = reverseShares.find((rs) => rs.id === reverseShareToViewDetails.id);
 | 
			
		||||
@@ -266,7 +251,6 @@ export function useReverseShares() {
 | 
			
		||||
    }
 | 
			
		||||
  }, [reverseShares, reverseShareToViewDetails?.id]);
 | 
			
		||||
 | 
			
		||||
  // Sincroniza o reverseShareToViewFiles com a lista atualizada
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (reverseShareToViewFiles) {
 | 
			
		||||
      const updatedReverseShare = reverseShares.find((rs) => rs.id === reverseShareToViewFiles.id);
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,6 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ obje
 | 
			
		||||
  const { objectPath } = await params;
 | 
			
		||||
  const cookieHeader = req.headers.get("cookie");
 | 
			
		||||
 | 
			
		||||
  // Reconstruct the full objectName from the path segments
 | 
			
		||||
  const objectName = objectPath.join("/");
 | 
			
		||||
 | 
			
		||||
  const url = `${process.env.API_BASE_URL}/files/${encodeURIComponent(objectName)}/download`;
 | 
			
		||||
 
 | 
			
		||||
@@ -3,12 +3,15 @@ import { NextRequest, NextResponse } from "next/server";
 | 
			
		||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ shareId: string }> }) {
 | 
			
		||||
  const cookieHeader = req.headers.get("cookie");
 | 
			
		||||
  const { shareId } = await params;
 | 
			
		||||
  const body = await req.text();
 | 
			
		||||
 | 
			
		||||
  const apiRes = await fetch(`${process.env.API_BASE_URL}/shares/${shareId}/recipients/notify`, {
 | 
			
		||||
  const apiRes = await fetch(`${process.env.API_BASE_URL}/shares/${shareId}/notify`, {
 | 
			
		||||
    method: "POST",
 | 
			
		||||
    headers: {
 | 
			
		||||
      "Content-Type": "application/json",
 | 
			
		||||
      cookie: cookieHeader || "",
 | 
			
		||||
    },
 | 
			
		||||
    body: body,
 | 
			
		||||
    redirect: "manual",
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,14 +1,76 @@
 | 
			
		||||
import { IconDatabaseCog } from "@tabler/icons-react";
 | 
			
		||||
import { IconAlertCircle, IconDatabaseCog, IconRefresh } from "@tabler/icons-react";
 | 
			
		||||
import { useTranslations } from "next-intl";
 | 
			
		||||
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { Card, CardContent } from "@/components/ui/card";
 | 
			
		||||
import { Progress } from "@/components/ui/progress";
 | 
			
		||||
import type { StorageUsageProps } from "../types";
 | 
			
		||||
import { formatStorageSize } from "../utils/format-storage-size";
 | 
			
		||||
 | 
			
		||||
export function StorageUsage({ diskSpace }: StorageUsageProps) {
 | 
			
		||||
export function StorageUsage({ diskSpace, diskSpaceError, onRetry }: StorageUsageProps) {
 | 
			
		||||
  const t = useTranslations();
 | 
			
		||||
 | 
			
		||||
  const getErrorMessage = (error: string) => {
 | 
			
		||||
    switch (error) {
 | 
			
		||||
      case "disk_detection_failed":
 | 
			
		||||
        return t("storageUsage.errors.detectionFailed");
 | 
			
		||||
      case "server_error":
 | 
			
		||||
        return t("storageUsage.errors.serverError");
 | 
			
		||||
      default:
 | 
			
		||||
        return t("storageUsage.errors.unknown");
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  if (diskSpaceError) {
 | 
			
		||||
    return (
 | 
			
		||||
      <Card className="w-full">
 | 
			
		||||
        <CardContent className="">
 | 
			
		||||
          <div className="flex flex-col gap-4">
 | 
			
		||||
            <h2 className="text-xl font-semibold flex items-center gap-2">
 | 
			
		||||
              <IconDatabaseCog className="text-gray-500" size={24} />
 | 
			
		||||
              {t("storageUsage.title")}
 | 
			
		||||
            </h2>
 | 
			
		||||
            <div className="flex flex-col gap-3 py-4">
 | 
			
		||||
              <div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
 | 
			
		||||
                <IconAlertCircle size={20} />
 | 
			
		||||
                <span className="text-sm font-medium">{t("storageUsage.errors.title")}</span>
 | 
			
		||||
              </div>
 | 
			
		||||
              <p className="text-sm text-muted-foreground">{getErrorMessage(diskSpaceError)}</p>
 | 
			
		||||
              {onRetry && (
 | 
			
		||||
                <Button variant="outline" size="sm" onClick={onRetry} className="w-fit">
 | 
			
		||||
                  <IconRefresh size={16} className="mr-2" />
 | 
			
		||||
                  {t("storageUsage.retry")}
 | 
			
		||||
                </Button>
 | 
			
		||||
              )}
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </CardContent>
 | 
			
		||||
      </Card>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (!diskSpace) {
 | 
			
		||||
    return (
 | 
			
		||||
      <Card className="w-full">
 | 
			
		||||
        <CardContent className="">
 | 
			
		||||
          <div className="flex flex-col gap-4">
 | 
			
		||||
            <h2 className="text-xl font-semibold flex items-center gap-2">
 | 
			
		||||
              <IconDatabaseCog className="text-gray-500" size={24} />
 | 
			
		||||
              {t("storageUsage.title")}
 | 
			
		||||
            </h2>
 | 
			
		||||
            <div className="flex flex-col gap-2">
 | 
			
		||||
              <div className="h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
 | 
			
		||||
              <div className="flex justify-between text-sm text-muted-foreground">
 | 
			
		||||
                <span>{t("storageUsage.loading")}</span>
 | 
			
		||||
                <span>{t("storageUsage.loading")}</span>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </CardContent>
 | 
			
		||||
      </Card>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Card className="w-full">
 | 
			
		||||
      <CardContent className="">
 | 
			
		||||
 
 | 
			
		||||
@@ -18,6 +18,7 @@ export function useDashboard() {
 | 
			
		||||
    diskAvailableGB: number;
 | 
			
		||||
    uploadAllowed: boolean;
 | 
			
		||||
  } | null>(null);
 | 
			
		||||
  const [diskSpaceError, setDiskSpaceError] = useState<string | null>(null);
 | 
			
		||||
  const [recentFiles, setRecentFiles] = useState<any[]>([]);
 | 
			
		||||
  const [recentShares, setRecentShares] = useState<any[]>([]);
 | 
			
		||||
  const [isLoading, setIsLoading] = useState(true);
 | 
			
		||||
@@ -34,24 +35,44 @@ export function useDashboard() {
 | 
			
		||||
 | 
			
		||||
  const loadDashboardData = async () => {
 | 
			
		||||
    try {
 | 
			
		||||
      const [diskSpaceRes, filesRes, sharesRes] = await Promise.all([getDiskSpace(), listFiles(), listUserShares()]);
 | 
			
		||||
 | 
			
		||||
      const loadDiskSpace = async () => {
 | 
			
		||||
        try {
 | 
			
		||||
          const diskSpaceRes = await getDiskSpace();
 | 
			
		||||
          setDiskSpace(diskSpaceRes.data);
 | 
			
		||||
          setDiskSpaceError(null);
 | 
			
		||||
        } catch (error: any) {
 | 
			
		||||
          console.warn("Failed to load disk space:", error);
 | 
			
		||||
          setDiskSpace(null);
 | 
			
		||||
 | 
			
		||||
          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 loadFilesAndShares = async () => {
 | 
			
		||||
        const [filesRes, sharesRes] = await Promise.all([listFiles(), listUserShares()]);
 | 
			
		||||
 | 
			
		||||
        const allFiles = filesRes.data.files || [];
 | 
			
		||||
        const sortedFiles = [...allFiles].sort(
 | 
			
		||||
          (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        setRecentFiles(sortedFiles.slice(0, 5));
 | 
			
		||||
 | 
			
		||||
        const allShares = sharesRes.data.shares || [];
 | 
			
		||||
        const sortedShares = [...allShares].sort(
 | 
			
		||||
          (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        setRecentShares(sortedShares.slice(0, 5));
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      await Promise.allSettled([loadDiskSpace(), loadFilesAndShares()]);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error("Critical dashboard error:", error);
 | 
			
		||||
      toast.error(t("dashboard.loadError"));
 | 
			
		||||
    } finally {
 | 
			
		||||
      setIsLoading(false);
 | 
			
		||||
@@ -76,6 +97,7 @@ export function useDashboard() {
 | 
			
		||||
  return {
 | 
			
		||||
    isLoading,
 | 
			
		||||
    diskSpace,
 | 
			
		||||
    diskSpaceError,
 | 
			
		||||
    recentFiles,
 | 
			
		||||
    recentShares,
 | 
			
		||||
    modals: {
 | 
			
		||||
 
 | 
			
		||||
@@ -19,6 +19,7 @@ export default function DashboardPage() {
 | 
			
		||||
  const {
 | 
			
		||||
    isLoading,
 | 
			
		||||
    diskSpace,
 | 
			
		||||
    diskSpaceError,
 | 
			
		||||
    recentFiles,
 | 
			
		||||
    recentShares,
 | 
			
		||||
    modals,
 | 
			
		||||
@@ -32,6 +33,10 @@ export default function DashboardPage() {
 | 
			
		||||
    return <LoadingScreen />;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const handleRetryDiskSpace = async () => {
 | 
			
		||||
    await loadDashboardData();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <ProtectedRoute>
 | 
			
		||||
      <FileManagerLayout
 | 
			
		||||
@@ -40,7 +45,7 @@ export default function DashboardPage() {
 | 
			
		||||
        showBreadcrumb={false}
 | 
			
		||||
        title={t("dashboard.pageTitle")}
 | 
			
		||||
      >
 | 
			
		||||
        <StorageUsage diskSpace={diskSpace} />
 | 
			
		||||
        <StorageUsage diskSpace={diskSpace} diskSpaceError={diskSpaceError} onRetry={handleRetryDiskSpace} />
 | 
			
		||||
        <QuickAccessCards />
 | 
			
		||||
 | 
			
		||||
        <div className="flex flex-col gap-6">
 | 
			
		||||
 
 | 
			
		||||
@@ -24,6 +24,8 @@ export interface StorageUsageProps {
 | 
			
		||||
    diskAvailableGB: number;
 | 
			
		||||
    uploadAllowed: boolean;
 | 
			
		||||
  } | null;
 | 
			
		||||
  diskSpaceError?: string | null;
 | 
			
		||||
  onRetry?: () => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface DashboardModalsProps {
 | 
			
		||||
 
 | 
			
		||||
@@ -23,7 +23,7 @@ export function LoginForm({ error, isVisible, onToggleVisibility, onSubmit }: Lo
 | 
			
		||||
  const form = useForm<LoginFormValues>({
 | 
			
		||||
    resolver: zodResolver(loginSchema),
 | 
			
		||||
    defaultValues: {
 | 
			
		||||
      email: "",
 | 
			
		||||
      emailOrUsername: "",
 | 
			
		||||
      password: "",
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
@@ -37,18 +37,18 @@ export function LoginForm({ error, isVisible, onToggleVisibility, onSubmit }: Lo
 | 
			
		||||
      </p>
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
  const renderEmailField = () => (
 | 
			
		||||
  const renderEmailOrUsernameField = () => (
 | 
			
		||||
    <FormField
 | 
			
		||||
      control={form.control}
 | 
			
		||||
      name="email"
 | 
			
		||||
      name="emailOrUsername"
 | 
			
		||||
      render={({ field }) => (
 | 
			
		||||
        <FormItem>
 | 
			
		||||
          <FormLabel>{t("login.emailLabel")}</FormLabel>
 | 
			
		||||
          <FormLabel>{t("login.emailOrUsernameLabel")}</FormLabel>
 | 
			
		||||
          <FormControl className="-mb-1">
 | 
			
		||||
            <Input
 | 
			
		||||
              {...field}
 | 
			
		||||
              type="email"
 | 
			
		||||
              placeholder={t("login.emailPlaceholder")}
 | 
			
		||||
              type="text"
 | 
			
		||||
              placeholder={t("login.emailOrUsernamePlaceholder")}
 | 
			
		||||
              disabled={isSubmitting}
 | 
			
		||||
              className="bg-transparent backdrop-blur-md"
 | 
			
		||||
            />
 | 
			
		||||
@@ -89,7 +89,7 @@ export function LoginForm({ error, isVisible, onToggleVisibility, onSubmit }: Lo
 | 
			
		||||
      {renderErrorMessage()}
 | 
			
		||||
      <Form {...form}>
 | 
			
		||||
        <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
 | 
			
		||||
          {renderEmailField()}
 | 
			
		||||
          {renderEmailOrUsernameField()}
 | 
			
		||||
          {renderPasswordField()}
 | 
			
		||||
          <Button className="w-full mt-4 cursor-pointer" variant="default" size="lg" type="submit">
 | 
			
		||||
            {isSubmitting ? t("login.signingIn") : t("login.signIn")}
 | 
			
		||||
 
 | 
			
		||||
@@ -12,7 +12,7 @@ import { getCurrentUser, login } from "@/http/endpoints";
 | 
			
		||||
import { LoginFormValues } from "../schemas/schema";
 | 
			
		||||
 | 
			
		||||
export const loginSchema = z.object({
 | 
			
		||||
  email: z.string(),
 | 
			
		||||
  emailOrUsername: z.string(),
 | 
			
		||||
  password: z.string(),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,7 @@ type TFunction = ReturnType<typeof useTranslations>;
 | 
			
		||||
 | 
			
		||||
export const createLoginSchema = (t: TFunction) =>
 | 
			
		||||
  z.object({
 | 
			
		||||
    email: z.string().min(1, t("validation.emailRequired")).email(t("validation.invalidEmail")),
 | 
			
		||||
    emailOrUsername: z.string().min(1, t("validation.emailOrUsernameRequired")),
 | 
			
		||||
    password: z.string().min(1, t("validation.passwordRequired")),
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,17 +1,13 @@
 | 
			
		||||
"use client";
 | 
			
		||||
 | 
			
		||||
import { useEffect, useState } from "react";
 | 
			
		||||
import { IconDownload } from "@tabler/icons-react";
 | 
			
		||||
import { useTranslations } from "next-intl";
 | 
			
		||||
import { toast } from "sonner";
 | 
			
		||||
 | 
			
		||||
import { CustomAudioPlayer } from "@/components/audio/custom-audio-player";
 | 
			
		||||
import { AspectRatio } from "@/components/ui/aspect-ratio";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
 | 
			
		||||
import { ScrollArea } from "@/components/ui/scroll-area";
 | 
			
		||||
import { getDownloadUrl } from "@/http/endpoints";
 | 
			
		||||
import { useFilePreview } from "@/hooks/use-file-preview";
 | 
			
		||||
import { getFileIcon } from "@/utils/file-icons";
 | 
			
		||||
import { FilePreviewRenderer } from "./previews";
 | 
			
		||||
 | 
			
		||||
interface FilePreviewModalProps {
 | 
			
		||||
  isOpen: boolean;
 | 
			
		||||
@@ -25,299 +21,7 @@ interface FilePreviewModalProps {
 | 
			
		||||
 | 
			
		||||
export function FilePreviewModal({ isOpen, onClose, file }: FilePreviewModalProps) {
 | 
			
		||||
  const t = useTranslations();
 | 
			
		||||
  const [previewUrl, setPreviewUrl] = useState<string | null>(null);
 | 
			
		||||
  const [isLoading, setIsLoading] = useState(true);
 | 
			
		||||
  const [videoBlob, setVideoBlob] = useState<string | null>(null);
 | 
			
		||||
  const [pdfAsBlob, setPdfAsBlob] = useState(false);
 | 
			
		||||
  const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
 | 
			
		||||
  const [pdfLoadFailed, setPdfLoadFailed] = useState(false);
 | 
			
		||||
  const [isLoadingPreview, setIsLoadingPreview] = useState(false);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (isOpen && file.objectName && !isLoadingPreview) {
 | 
			
		||||
      setIsLoading(true);
 | 
			
		||||
      setPreviewUrl(null);
 | 
			
		||||
      setVideoBlob(null);
 | 
			
		||||
      setPdfAsBlob(false);
 | 
			
		||||
      setDownloadUrl(null);
 | 
			
		||||
      setPdfLoadFailed(false);
 | 
			
		||||
      loadPreview();
 | 
			
		||||
    }
 | 
			
		||||
  }, [file.objectName, isOpen]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    return () => {
 | 
			
		||||
      if (previewUrl && previewUrl.startsWith("blob:")) {
 | 
			
		||||
        URL.revokeObjectURL(previewUrl);
 | 
			
		||||
      }
 | 
			
		||||
      if (videoBlob && videoBlob.startsWith("blob:")) {
 | 
			
		||||
        URL.revokeObjectURL(videoBlob);
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
  }, [previewUrl, videoBlob]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (!isOpen) {
 | 
			
		||||
      if (previewUrl && previewUrl.startsWith("blob:")) {
 | 
			
		||||
        URL.revokeObjectURL(previewUrl);
 | 
			
		||||
        setPreviewUrl(null);
 | 
			
		||||
      }
 | 
			
		||||
      if (videoBlob && videoBlob.startsWith("blob:")) {
 | 
			
		||||
        URL.revokeObjectURL(videoBlob);
 | 
			
		||||
        setVideoBlob(null);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }, [isOpen]);
 | 
			
		||||
 | 
			
		||||
  const loadPreview = async () => {
 | 
			
		||||
    if (!file.objectName || isLoadingPreview) return;
 | 
			
		||||
 | 
			
		||||
    setIsLoadingPreview(true);
 | 
			
		||||
    try {
 | 
			
		||||
      const encodedObjectName = encodeURIComponent(file.objectName);
 | 
			
		||||
      const response = await getDownloadUrl(encodedObjectName);
 | 
			
		||||
      const url = response.data.url;
 | 
			
		||||
 | 
			
		||||
      setDownloadUrl(url);
 | 
			
		||||
 | 
			
		||||
      const fileType = getFileType();
 | 
			
		||||
 | 
			
		||||
      if (fileType === "video") {
 | 
			
		||||
        await loadVideoPreview(url);
 | 
			
		||||
      } else if (fileType === "audio") {
 | 
			
		||||
        await loadAudioPreview(url);
 | 
			
		||||
      } else if (fileType === "pdf") {
 | 
			
		||||
        await loadPdfPreview(url);
 | 
			
		||||
      } else {
 | 
			
		||||
        setPreviewUrl(url);
 | 
			
		||||
      }
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error("Failed to load preview:", error);
 | 
			
		||||
      toast.error(t("filePreview.loadError"));
 | 
			
		||||
    } finally {
 | 
			
		||||
      setIsLoading(false);
 | 
			
		||||
      setIsLoadingPreview(false);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const loadVideoPreview = async (url: string) => {
 | 
			
		||||
    try {
 | 
			
		||||
      const response = await fetch(url);
 | 
			
		||||
      if (!response.ok) {
 | 
			
		||||
        throw new Error(`HTTP error! status: ${response.status}`);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const blob = await response.blob();
 | 
			
		||||
      const blobUrl = URL.createObjectURL(blob);
 | 
			
		||||
      setVideoBlob(blobUrl);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error("Failed to load video as blob:", error);
 | 
			
		||||
      setPreviewUrl(url);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const loadAudioPreview = async (url: string) => {
 | 
			
		||||
    try {
 | 
			
		||||
      const response = await fetch(url);
 | 
			
		||||
      if (!response.ok) {
 | 
			
		||||
        throw new Error(`HTTP error! status: ${response.status}`);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const blob = await response.blob();
 | 
			
		||||
      const blobUrl = URL.createObjectURL(blob);
 | 
			
		||||
      setPreviewUrl(blobUrl);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error("Failed to load audio as blob:", error);
 | 
			
		||||
      setPreviewUrl(url);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const loadPdfPreview = async (url: string) => {
 | 
			
		||||
    try {
 | 
			
		||||
      const response = await fetch(url);
 | 
			
		||||
      if (!response.ok) {
 | 
			
		||||
        throw new Error(`HTTP error! status: ${response.status}`);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const blob = await response.blob();
 | 
			
		||||
      const finalBlob = new Blob([blob], { type: "application/pdf" });
 | 
			
		||||
      const blobUrl = URL.createObjectURL(finalBlob);
 | 
			
		||||
      setPreviewUrl(blobUrl);
 | 
			
		||||
      setPdfAsBlob(true);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error("Failed to load PDF as blob:", error);
 | 
			
		||||
      setPreviewUrl(url);
 | 
			
		||||
      setTimeout(() => {
 | 
			
		||||
        if (!pdfLoadFailed && !pdfAsBlob) {
 | 
			
		||||
          handlePdfLoadError();
 | 
			
		||||
        }
 | 
			
		||||
      }, 4000);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handlePdfLoadError = async () => {
 | 
			
		||||
    if (pdfLoadFailed || pdfAsBlob) return;
 | 
			
		||||
 | 
			
		||||
    setPdfLoadFailed(true);
 | 
			
		||||
 | 
			
		||||
    if (downloadUrl) {
 | 
			
		||||
      setTimeout(() => {
 | 
			
		||||
        loadPdfPreview(downloadUrl);
 | 
			
		||||
      }, 500);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleDownload = async () => {
 | 
			
		||||
    try {
 | 
			
		||||
      let downloadUrlToUse = downloadUrl;
 | 
			
		||||
 | 
			
		||||
      if (!downloadUrlToUse) {
 | 
			
		||||
        const encodedObjectName = encodeURIComponent(file.objectName);
 | 
			
		||||
        const response = await getDownloadUrl(encodedObjectName);
 | 
			
		||||
        downloadUrlToUse = response.data.url;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const fileResponse = await fetch(downloadUrlToUse);
 | 
			
		||||
      if (!fileResponse.ok) {
 | 
			
		||||
        throw new Error(`Download failed: ${fileResponse.status} - ${fileResponse.statusText}`);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const blob = await fileResponse.blob();
 | 
			
		||||
      const url = window.URL.createObjectURL(blob);
 | 
			
		||||
 | 
			
		||||
      const link = document.createElement("a");
 | 
			
		||||
      link.href = url;
 | 
			
		||||
      link.download = file.name;
 | 
			
		||||
      document.body.appendChild(link);
 | 
			
		||||
      link.click();
 | 
			
		||||
 | 
			
		||||
      document.body.removeChild(link);
 | 
			
		||||
      window.URL.revokeObjectURL(url);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      toast.error(t("filePreview.downloadError"));
 | 
			
		||||
      console.error("Download error:", error);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const getFileType = () => {
 | 
			
		||||
    const extension = file.name.split(".").pop()?.toLowerCase();
 | 
			
		||||
 | 
			
		||||
    if (extension === "pdf") return "pdf";
 | 
			
		||||
    if (["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "tiff"].includes(extension || "")) return "image";
 | 
			
		||||
    if (["mp3", "wav", "ogg", "m4a", "aac", "flac"].includes(extension || "")) return "audio";
 | 
			
		||||
    if (["mp4", "webm", "ogg", "mov", "avi", "mkv", "wmv", "flv", "m4v"].includes(extension || "")) return "video";
 | 
			
		||||
 | 
			
		||||
    return "other";
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const renderPreview = () => {
 | 
			
		||||
    const fileType = getFileType();
 | 
			
		||||
    const { icon: FileIcon, color } = getFileIcon(file.name);
 | 
			
		||||
 | 
			
		||||
    if (isLoading) {
 | 
			
		||||
      return (
 | 
			
		||||
        <div className="flex flex-col items-center justify-center h-96 gap-4">
 | 
			
		||||
          <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
 | 
			
		||||
          <p className="text-muted-foreground">{t("filePreview.loading")}</p>
 | 
			
		||||
        </div>
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const mediaUrl = fileType === "video" ? videoBlob : previewUrl;
 | 
			
		||||
 | 
			
		||||
    if (!mediaUrl && (fileType === "video" || fileType === "audio")) {
 | 
			
		||||
      return (
 | 
			
		||||
        <div className="flex flex-col items-center justify-center h-96 gap-4">
 | 
			
		||||
          <FileIcon className={`h-12 w-12 ${color}`} />
 | 
			
		||||
          <p className="text-muted-foreground">{t("filePreview.notAvailable")}</p>
 | 
			
		||||
          <p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
 | 
			
		||||
        </div>
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!previewUrl && fileType !== "video") {
 | 
			
		||||
      return (
 | 
			
		||||
        <div className="flex flex-col items-center justify-center h-96 gap-4">
 | 
			
		||||
          <FileIcon className={`h-12 w-12 ${color}`} />
 | 
			
		||||
          <p className="text-muted-foreground">{t("filePreview.notAvailable")}</p>
 | 
			
		||||
          <p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
 | 
			
		||||
        </div>
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    switch (fileType) {
 | 
			
		||||
      case "pdf":
 | 
			
		||||
        return (
 | 
			
		||||
          <ScrollArea className="w-full">
 | 
			
		||||
            <div className="w-full min-h-[600px] border rounded-lg overflow-hidden bg-card">
 | 
			
		||||
              {pdfAsBlob ? (
 | 
			
		||||
                <iframe
 | 
			
		||||
                  src={`${previewUrl!}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
 | 
			
		||||
                  className="w-full h-full min-h-[600px]"
 | 
			
		||||
                  title={file.name}
 | 
			
		||||
                  style={{ border: "none" }}
 | 
			
		||||
                />
 | 
			
		||||
              ) : pdfLoadFailed ? (
 | 
			
		||||
                <div className="flex items-center justify-center h-full min-h-[600px]">
 | 
			
		||||
                  <div className="flex flex-col items-center gap-4">
 | 
			
		||||
                    <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
 | 
			
		||||
                    <p className="text-muted-foreground">{t("filePreview.loadingAlternative")}</p>
 | 
			
		||||
                  </div>
 | 
			
		||||
                </div>
 | 
			
		||||
              ) : (
 | 
			
		||||
                <div className="w-full h-full min-h-[600px] relative">
 | 
			
		||||
                  <object
 | 
			
		||||
                    data={`${previewUrl!}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
 | 
			
		||||
                    type="application/pdf"
 | 
			
		||||
                    className="w-full h-full min-h-[600px]"
 | 
			
		||||
                    onError={handlePdfLoadError}
 | 
			
		||||
                  >
 | 
			
		||||
                    <iframe
 | 
			
		||||
                      src={`${previewUrl!}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
 | 
			
		||||
                      className="w-full h-full min-h-[600px]"
 | 
			
		||||
                      title={file.name}
 | 
			
		||||
                      style={{ border: "none" }}
 | 
			
		||||
                      onError={handlePdfLoadError}
 | 
			
		||||
                    />
 | 
			
		||||
                  </object>
 | 
			
		||||
                </div>
 | 
			
		||||
              )}
 | 
			
		||||
            </div>
 | 
			
		||||
          </ScrollArea>
 | 
			
		||||
        );
 | 
			
		||||
      case "image":
 | 
			
		||||
        return (
 | 
			
		||||
          <AspectRatio ratio={16 / 9} className="bg-muted">
 | 
			
		||||
            <img src={previewUrl!} alt={file.name} className="object-contain w-full h-full rounded-md" />
 | 
			
		||||
          </AspectRatio>
 | 
			
		||||
        );
 | 
			
		||||
      case "audio":
 | 
			
		||||
        return (
 | 
			
		||||
          <div className="flex flex-col items-center justify-center gap-6 py-4">
 | 
			
		||||
            <CustomAudioPlayer src={mediaUrl!} />
 | 
			
		||||
          </div>
 | 
			
		||||
        );
 | 
			
		||||
      case "video":
 | 
			
		||||
        return (
 | 
			
		||||
          <div className="flex flex-col items-center justify-center gap-4 py-6">
 | 
			
		||||
            <div className="w-full max-w-4xl">
 | 
			
		||||
              <video controls className="w-full rounded-lg" preload="metadata" style={{ maxHeight: "70vh" }}>
 | 
			
		||||
                <source src={mediaUrl!} />
 | 
			
		||||
                {t("filePreview.videoNotSupported")}
 | 
			
		||||
              </video>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        );
 | 
			
		||||
      default:
 | 
			
		||||
        return (
 | 
			
		||||
          <div className="flex flex-col items-center justify-center h-96 gap-4">
 | 
			
		||||
            <FileIcon className={`text-6xl ${color}`} />
 | 
			
		||||
            <p className="text-muted-foreground">{t("filePreview.notAvailable")}</p>
 | 
			
		||||
            <p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
 | 
			
		||||
          </div>
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
  const previewState = useFilePreview({ file, isOpen });
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Dialog open={isOpen} onOpenChange={onClose}>
 | 
			
		||||
@@ -331,12 +35,24 @@ export function FilePreviewModal({ isOpen, onClose, file }: FilePreviewModalProp
 | 
			
		||||
            <span className="truncate">{file.name}</span>
 | 
			
		||||
          </DialogTitle>
 | 
			
		||||
        </DialogHeader>
 | 
			
		||||
        <div className="flex-1 overflow-auto">{renderPreview()}</div>
 | 
			
		||||
        <div className="flex-1 overflow-auto">
 | 
			
		||||
          <FilePreviewRenderer
 | 
			
		||||
            fileType={previewState.fileType}
 | 
			
		||||
            fileName={file.name}
 | 
			
		||||
            previewUrl={previewState.previewUrl}
 | 
			
		||||
            videoBlob={previewState.videoBlob}
 | 
			
		||||
            textContent={previewState.textContent}
 | 
			
		||||
            isLoading={previewState.isLoading}
 | 
			
		||||
            pdfAsBlob={previewState.pdfAsBlob}
 | 
			
		||||
            pdfLoadFailed={previewState.pdfLoadFailed}
 | 
			
		||||
            onPdfLoadError={previewState.handlePdfLoadError}
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
        <DialogFooter>
 | 
			
		||||
          <Button variant="outline" onClick={onClose}>
 | 
			
		||||
            {t("common.close")}
 | 
			
		||||
          </Button>
 | 
			
		||||
          <Button onClick={handleDownload}>
 | 
			
		||||
          <Button onClick={previewState.handleDownload}>
 | 
			
		||||
            <IconDownload className="h-4 w-4" />
 | 
			
		||||
            {t("common.download")}
 | 
			
		||||
          </Button>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										13
									
								
								apps/web/src/components/modals/previews/audio-preview.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								apps/web/src/components/modals/previews/audio-preview.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,13 @@
 | 
			
		||||
import { CustomAudioPlayer } from "@/components/audio/custom-audio-player";
 | 
			
		||||
 | 
			
		||||
interface AudioPreviewProps {
 | 
			
		||||
  src: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function AudioPreview({ src }: AudioPreviewProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="flex flex-col items-center justify-center gap-6 py-4">
 | 
			
		||||
      <CustomAudioPlayer src={src} />
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										31
									
								
								apps/web/src/components/modals/previews/default-preview.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								apps/web/src/components/modals/previews/default-preview.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,31 @@
 | 
			
		||||
import { useTranslations } from "next-intl";
 | 
			
		||||
 | 
			
		||||
import { getFileIcon } from "@/utils/file-icons";
 | 
			
		||||
 | 
			
		||||
interface DefaultPreviewProps {
 | 
			
		||||
  fileName: string;
 | 
			
		||||
  isLoading?: boolean;
 | 
			
		||||
  message?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function DefaultPreview({ fileName, isLoading, message }: DefaultPreviewProps) {
 | 
			
		||||
  const t = useTranslations();
 | 
			
		||||
  const { icon: FileIcon, color } = getFileIcon(fileName);
 | 
			
		||||
 | 
			
		||||
  if (isLoading) {
 | 
			
		||||
    return (
 | 
			
		||||
      <div className="flex flex-col items-center justify-center h-96 gap-4">
 | 
			
		||||
        <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
 | 
			
		||||
        <p className="text-muted-foreground">{t("filePreview.loading")}</p>
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="flex flex-col items-center justify-center h-96 gap-4">
 | 
			
		||||
      <FileIcon className={`h-12 w-12 ${color}`} />
 | 
			
		||||
      <p className="text-muted-foreground">{message || t("filePreview.notAvailable")}</p>
 | 
			
		||||
      <p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,77 @@
 | 
			
		||||
import { type FileType } from "@/utils/file-types";
 | 
			
		||||
import { AudioPreview } from "./audio-preview";
 | 
			
		||||
import { DefaultPreview } from "./default-preview";
 | 
			
		||||
import { ImagePreview } from "./image-preview";
 | 
			
		||||
import { PdfPreview } from "./pdf-preview";
 | 
			
		||||
import { TextPreview } from "./text-preview";
 | 
			
		||||
import { VideoPreview } from "./video-preview";
 | 
			
		||||
 | 
			
		||||
interface FilePreviewRendererProps {
 | 
			
		||||
  fileType: FileType;
 | 
			
		||||
  fileName: string;
 | 
			
		||||
  previewUrl: string | null;
 | 
			
		||||
  videoBlob: string | null;
 | 
			
		||||
  textContent: string | null;
 | 
			
		||||
  isLoading: boolean;
 | 
			
		||||
  pdfAsBlob: boolean;
 | 
			
		||||
  pdfLoadFailed: boolean;
 | 
			
		||||
  onPdfLoadError: () => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function FilePreviewRenderer({
 | 
			
		||||
  fileType,
 | 
			
		||||
  fileName,
 | 
			
		||||
  previewUrl,
 | 
			
		||||
  videoBlob,
 | 
			
		||||
  textContent,
 | 
			
		||||
  isLoading,
 | 
			
		||||
  pdfAsBlob,
 | 
			
		||||
  pdfLoadFailed,
 | 
			
		||||
  onPdfLoadError,
 | 
			
		||||
}: FilePreviewRendererProps) {
 | 
			
		||||
  if (isLoading) {
 | 
			
		||||
    return <DefaultPreview fileName={fileName} isLoading />;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const mediaUrl = fileType === "video" ? videoBlob : previewUrl;
 | 
			
		||||
 | 
			
		||||
  if (!mediaUrl && (fileType === "video" || fileType === "audio")) {
 | 
			
		||||
    return <DefaultPreview fileName={fileName} />;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (fileType === "text" && !textContent) {
 | 
			
		||||
    return <DefaultPreview fileName={fileName} />;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (!previewUrl && fileType !== "video" && fileType !== "text") {
 | 
			
		||||
    return <DefaultPreview fileName={fileName} />;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  switch (fileType) {
 | 
			
		||||
    case "pdf":
 | 
			
		||||
      return (
 | 
			
		||||
        <PdfPreview
 | 
			
		||||
          src={previewUrl!}
 | 
			
		||||
          fileName={fileName}
 | 
			
		||||
          pdfAsBlob={pdfAsBlob}
 | 
			
		||||
          pdfLoadFailed={pdfLoadFailed}
 | 
			
		||||
          onLoadError={onPdfLoadError}
 | 
			
		||||
        />
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
    case "text":
 | 
			
		||||
      return <TextPreview content={textContent} fileName={fileName} />;
 | 
			
		||||
 | 
			
		||||
    case "image":
 | 
			
		||||
      return <ImagePreview src={previewUrl!} alt={fileName} />;
 | 
			
		||||
 | 
			
		||||
    case "audio":
 | 
			
		||||
      return <AudioPreview src={mediaUrl!} />;
 | 
			
		||||
 | 
			
		||||
    case "video":
 | 
			
		||||
      return <VideoPreview src={mediaUrl!} />;
 | 
			
		||||
 | 
			
		||||
    default:
 | 
			
		||||
      return <DefaultPreview fileName={fileName} />;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										14
									
								
								apps/web/src/components/modals/previews/image-preview.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								apps/web/src/components/modals/previews/image-preview.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,14 @@
 | 
			
		||||
import { AspectRatio } from "@/components/ui/aspect-ratio";
 | 
			
		||||
 | 
			
		||||
interface ImagePreviewProps {
 | 
			
		||||
  src: string;
 | 
			
		||||
  alt: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function ImagePreview({ src, alt }: ImagePreviewProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <AspectRatio ratio={16 / 9} className="bg-muted">
 | 
			
		||||
      <img src={src} alt={alt} className="object-contain w-full h-full rounded-md" />
 | 
			
		||||
    </AspectRatio>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										7
									
								
								apps/web/src/components/modals/previews/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								apps/web/src/components/modals/previews/index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
			
		||||
export { ImagePreview } from "./image-preview";
 | 
			
		||||
export { VideoPreview } from "./video-preview";
 | 
			
		||||
export { AudioPreview } from "./audio-preview";
 | 
			
		||||
export { PdfPreview } from "./pdf-preview";
 | 
			
		||||
export { TextPreview } from "./text-preview";
 | 
			
		||||
export { DefaultPreview } from "./default-preview";
 | 
			
		||||
export { FilePreviewRenderer } from "./file-preview-render";
 | 
			
		||||
							
								
								
									
										54
									
								
								apps/web/src/components/modals/previews/pdf-preview.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								apps/web/src/components/modals/previews/pdf-preview.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,54 @@
 | 
			
		||||
import { useTranslations } from "next-intl";
 | 
			
		||||
 | 
			
		||||
import { ScrollArea } from "@/components/ui/scroll-area";
 | 
			
		||||
 | 
			
		||||
interface PdfPreviewProps {
 | 
			
		||||
  src: string;
 | 
			
		||||
  fileName: string;
 | 
			
		||||
  pdfAsBlob: boolean;
 | 
			
		||||
  pdfLoadFailed: boolean;
 | 
			
		||||
  onLoadError: () => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function PdfPreview({ src, fileName, pdfAsBlob, pdfLoadFailed, onLoadError }: PdfPreviewProps) {
 | 
			
		||||
  const t = useTranslations();
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <ScrollArea className="w-full">
 | 
			
		||||
      <div className="w-full min-h-[600px] border rounded-lg overflow-hidden bg-card">
 | 
			
		||||
        {pdfAsBlob ? (
 | 
			
		||||
          <iframe
 | 
			
		||||
            src={`${src}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
 | 
			
		||||
            className="w-full h-full min-h-[600px]"
 | 
			
		||||
            title={fileName}
 | 
			
		||||
            style={{ border: "none" }}
 | 
			
		||||
          />
 | 
			
		||||
        ) : pdfLoadFailed ? (
 | 
			
		||||
          <div className="flex items-center justify-center h-full min-h-[600px]">
 | 
			
		||||
            <div className="flex flex-col items-center gap-4">
 | 
			
		||||
              <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
 | 
			
		||||
              <p className="text-muted-foreground">{t("filePreview.loadingAlternative")}</p>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        ) : (
 | 
			
		||||
          <div className="w-full h-full min-h-[600px] relative">
 | 
			
		||||
            <object
 | 
			
		||||
              data={`${src}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
 | 
			
		||||
              type="application/pdf"
 | 
			
		||||
              className="w-full h-full min-h-[600px]"
 | 
			
		||||
              onError={onLoadError}
 | 
			
		||||
            >
 | 
			
		||||
              <iframe
 | 
			
		||||
                src={`${src}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
 | 
			
		||||
                className="w-full h-full min-h-[600px]"
 | 
			
		||||
                title={fileName}
 | 
			
		||||
                style={{ border: "none" }}
 | 
			
		||||
                onError={onLoadError}
 | 
			
		||||
              />
 | 
			
		||||
            </object>
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
      </div>
 | 
			
		||||
    </ScrollArea>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										40
									
								
								apps/web/src/components/modals/previews/text-preview.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								apps/web/src/components/modals/previews/text-preview.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,40 @@
 | 
			
		||||
import { useTranslations } from "next-intl";
 | 
			
		||||
 | 
			
		||||
import { ScrollArea } from "@/components/ui/scroll-area";
 | 
			
		||||
import { getFileExtension } from "@/utils/file-types";
 | 
			
		||||
 | 
			
		||||
interface TextPreviewProps {
 | 
			
		||||
  content: string | null;
 | 
			
		||||
  fileName: string;
 | 
			
		||||
  isLoading?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function TextPreview({ content, fileName, isLoading }: TextPreviewProps) {
 | 
			
		||||
  const t = useTranslations();
 | 
			
		||||
  const extension = getFileExtension(fileName);
 | 
			
		||||
 | 
			
		||||
  if (isLoading || !content) {
 | 
			
		||||
    return (
 | 
			
		||||
      <ScrollArea className="w-full max-h-[600px]">
 | 
			
		||||
        <div className="w-full border rounded-lg overflow-hidden bg-card">
 | 
			
		||||
          <div className="flex items-center justify-center h-32">
 | 
			
		||||
            <div className="flex flex-col items-center gap-2">
 | 
			
		||||
              <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
 | 
			
		||||
              <p className="text-sm text-muted-foreground">{t("filePreview.loading")}</p>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </ScrollArea>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <ScrollArea className="w-full max-h-[600px]">
 | 
			
		||||
      <div className="w-full border rounded-lg overflow-hidden bg-card">
 | 
			
		||||
        <pre className="p-4 text-sm font-mono whitespace-pre-wrap break-words overflow-x-auto">
 | 
			
		||||
          <code className={`language-${extension || "text"}`}>{content}</code>
 | 
			
		||||
        </pre>
 | 
			
		||||
      </div>
 | 
			
		||||
    </ScrollArea>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										20
									
								
								apps/web/src/components/modals/previews/video-preview.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								apps/web/src/components/modals/previews/video-preview.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
import { useTranslations } from "next-intl";
 | 
			
		||||
 | 
			
		||||
interface VideoPreviewProps {
 | 
			
		||||
  src: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function VideoPreview({ src }: VideoPreviewProps) {
 | 
			
		||||
  const t = useTranslations();
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="flex flex-col items-center justify-center gap-4 py-6">
 | 
			
		||||
      <div className="w-full max-w-4xl">
 | 
			
		||||
        <video controls className="w-full rounded-lg" preload="metadata" style={{ maxHeight: "70vh" }}>
 | 
			
		||||
          <source src={src} />
 | 
			
		||||
          {t("filePreview.videoNotSupported")}
 | 
			
		||||
        </video>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@@ -1,18 +1,7 @@
 | 
			
		||||
"use client";
 | 
			
		||||
 | 
			
		||||
import { useEffect, useRef, useState } from "react";
 | 
			
		||||
import {
 | 
			
		||||
  IconAlertTriangle,
 | 
			
		||||
  IconCheck,
 | 
			
		||||
  IconCloudUpload,
 | 
			
		||||
  IconFileText,
 | 
			
		||||
  IconFileTypePdf,
 | 
			
		||||
  IconFileTypography,
 | 
			
		||||
  IconLoader,
 | 
			
		||||
  IconPhoto,
 | 
			
		||||
  IconTrash,
 | 
			
		||||
  IconX,
 | 
			
		||||
} from "@tabler/icons-react";
 | 
			
		||||
import { IconAlertTriangle, IconCheck, IconCloudUpload, IconLoader, IconTrash, IconX } from "@tabler/icons-react";
 | 
			
		||||
import axios from "axios";
 | 
			
		||||
import { useTranslations } from "next-intl";
 | 
			
		||||
import { toast } from "sonner";
 | 
			
		||||
@@ -21,6 +10,7 @@ import { Button } from "@/components/ui/button";
 | 
			
		||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
 | 
			
		||||
import { Progress } from "@/components/ui/progress";
 | 
			
		||||
import { checkFile, getPresignedUrl, registerFile } from "@/http/endpoints";
 | 
			
		||||
import { getFileIcon } from "@/utils/file-icons";
 | 
			
		||||
import { generateSafeFileName } from "@/utils/file-utils";
 | 
			
		||||
import getErrorData from "@/utils/getErrorData";
 | 
			
		||||
 | 
			
		||||
@@ -157,11 +147,9 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
 | 
			
		||||
    handleFilesSelect(event.dataTransfer.files);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const getFileIcon = (fileType: string) => {
 | 
			
		||||
    if (fileType.startsWith("image/")) return <IconPhoto size={24} className="text-blue-500" />;
 | 
			
		||||
    if (fileType.includes("pdf")) return <IconFileTypePdf size={24} className="text-red-500" />;
 | 
			
		||||
    if (fileType.includes("word")) return <IconFileTypography size={24} className="text-blue-700" />;
 | 
			
		||||
    return <IconFileText size={24} className="text-muted-foreground" />;
 | 
			
		||||
  const renderFileIcon = (fileName: string) => {
 | 
			
		||||
    const { icon: FileIcon, color } = getFileIcon(fileName);
 | 
			
		||||
    return <FileIcon size={24} className={color} />;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const getStatusIcon = (status: UploadStatus) => {
 | 
			
		||||
@@ -420,7 +408,7 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
 | 
			
		||||
                          className="w-10 h-10 rounded object-cover"
 | 
			
		||||
                        />
 | 
			
		||||
                      ) : (
 | 
			
		||||
                        getFileIcon(upload.file.type)
 | 
			
		||||
                        renderFileIcon(upload.file.name)
 | 
			
		||||
                      )}
 | 
			
		||||
                    </div>
 | 
			
		||||
 | 
			
		||||
@@ -453,7 +441,33 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
 | 
			
		||||
                        >
 | 
			
		||||
                          <IconX size={14} />
 | 
			
		||||
                        </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">
 | 
			
		||||
                          <IconTrash size={14} />
 | 
			
		||||
                        </Button>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										255
									
								
								apps/web/src/hooks/use-file-preview.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										255
									
								
								apps/web/src/hooks/use-file-preview.ts
									
									
									
									
									
										Normal 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,
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
@@ -1,6 +1,5 @@
 | 
			
		||||
import type { AxiosResponse } from "axios";
 | 
			
		||||
 | 
			
		||||
// Create Reverse Share
 | 
			
		||||
export type CreateReverseShareBody = {
 | 
			
		||||
  name?: string;
 | 
			
		||||
  description?: string;
 | 
			
		||||
@@ -31,7 +30,6 @@ export type CreateReverseShareResult = AxiosResponse<{
 | 
			
		||||
  };
 | 
			
		||||
}>;
 | 
			
		||||
 | 
			
		||||
// Update Reverse Share
 | 
			
		||||
export type UpdateReverseShareBody = {
 | 
			
		||||
  id: string;
 | 
			
		||||
  name?: string;
 | 
			
		||||
@@ -64,7 +62,6 @@ export type UpdateReverseShareResult = AxiosResponse<{
 | 
			
		||||
  };
 | 
			
		||||
}>;
 | 
			
		||||
 | 
			
		||||
// List User Reverse Shares
 | 
			
		||||
export type ListUserReverseSharesResult = AxiosResponse<{
 | 
			
		||||
  reverseShares: {
 | 
			
		||||
    id: string;
 | 
			
		||||
@@ -91,7 +88,6 @@ export type ListUserReverseSharesResult = AxiosResponse<{
 | 
			
		||||
  }[];
 | 
			
		||||
}>;
 | 
			
		||||
 | 
			
		||||
// Get Reverse Share
 | 
			
		||||
export type GetReverseShareResult = AxiosResponse<{
 | 
			
		||||
  reverseShare: {
 | 
			
		||||
    id: string;
 | 
			
		||||
@@ -118,7 +114,6 @@ export type GetReverseShareResult = AxiosResponse<{
 | 
			
		||||
  };
 | 
			
		||||
}>;
 | 
			
		||||
 | 
			
		||||
// Delete Reverse Share
 | 
			
		||||
export type DeleteReverseShareResult = AxiosResponse<{
 | 
			
		||||
  reverseShare: {
 | 
			
		||||
    id: string;
 | 
			
		||||
@@ -138,7 +133,6 @@ export type DeleteReverseShareResult = AxiosResponse<{
 | 
			
		||||
  };
 | 
			
		||||
}>;
 | 
			
		||||
 | 
			
		||||
// Get Reverse Share for Upload (Public)
 | 
			
		||||
export type GetReverseShareForUploadParams = {
 | 
			
		||||
  password?: string;
 | 
			
		||||
};
 | 
			
		||||
@@ -157,7 +151,6 @@ export type GetReverseShareForUploadResult = AxiosResponse<{
 | 
			
		||||
  };
 | 
			
		||||
}>;
 | 
			
		||||
 | 
			
		||||
// Update Password
 | 
			
		||||
export type UpdateReverseSharePasswordBody = {
 | 
			
		||||
  password: string | null;
 | 
			
		||||
};
 | 
			
		||||
@@ -181,7 +174,6 @@ export type UpdateReverseSharePasswordResult = AxiosResponse<{
 | 
			
		||||
  };
 | 
			
		||||
}>;
 | 
			
		||||
 | 
			
		||||
// Presigned URL
 | 
			
		||||
export type GetPresignedUrlBody = {
 | 
			
		||||
  objectName: string;
 | 
			
		||||
};
 | 
			
		||||
@@ -191,7 +183,6 @@ export type GetPresignedUrlResult = AxiosResponse<{
 | 
			
		||||
  expiresIn: number;
 | 
			
		||||
}>;
 | 
			
		||||
 | 
			
		||||
// Register File Upload
 | 
			
		||||
export type RegisterFileUploadBody = {
 | 
			
		||||
  name: string;
 | 
			
		||||
  description?: string;
 | 
			
		||||
@@ -210,7 +201,6 @@ export type RegisterFileUploadResult = AxiosResponse<{
 | 
			
		||||
  file: ReverseShareFile;
 | 
			
		||||
}>;
 | 
			
		||||
 | 
			
		||||
// Check Password
 | 
			
		||||
export type CheckReverseSharePasswordBody = {
 | 
			
		||||
  password: string;
 | 
			
		||||
};
 | 
			
		||||
@@ -219,18 +209,15 @@ export type CheckReverseSharePasswordResult = AxiosResponse<{
 | 
			
		||||
  valid: boolean;
 | 
			
		||||
}>;
 | 
			
		||||
 | 
			
		||||
// Download File
 | 
			
		||||
export type DownloadReverseShareFileResult = AxiosResponse<{
 | 
			
		||||
  url: string;
 | 
			
		||||
  expiresIn: number;
 | 
			
		||||
}>;
 | 
			
		||||
 | 
			
		||||
// Delete File
 | 
			
		||||
export type DeleteReverseShareFileResult = AxiosResponse<{
 | 
			
		||||
  file: ReverseShareFile;
 | 
			
		||||
}>;
 | 
			
		||||
 | 
			
		||||
// Shared Type
 | 
			
		||||
export type ReverseShareFile = {
 | 
			
		||||
  id: string;
 | 
			
		||||
  name: string;
 | 
			
		||||
@@ -244,7 +231,6 @@ export type ReverseShareFile = {
 | 
			
		||||
  updatedAt: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Activate Reverse Share
 | 
			
		||||
export type ActivateReverseShareResult = AxiosResponse<{
 | 
			
		||||
  reverseShare: {
 | 
			
		||||
    id: string;
 | 
			
		||||
@@ -264,7 +250,6 @@ export type ActivateReverseShareResult = AxiosResponse<{
 | 
			
		||||
  };
 | 
			
		||||
}>;
 | 
			
		||||
 | 
			
		||||
// Deactivate Reverse Share
 | 
			
		||||
export type DeactivateReverseShareResult = AxiosResponse<{
 | 
			
		||||
  reverseShare: {
 | 
			
		||||
    id: string;
 | 
			
		||||
@@ -284,7 +269,6 @@ export type DeactivateReverseShareResult = AxiosResponse<{
 | 
			
		||||
  };
 | 
			
		||||
}>;
 | 
			
		||||
 | 
			
		||||
// Update Reverse Share File
 | 
			
		||||
export type UpdateReverseShareFileBody = {
 | 
			
		||||
  name?: string;
 | 
			
		||||
  description?: string | null;
 | 
			
		||||
 
 | 
			
		||||
@@ -7,8 +7,8 @@
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
export type LoginBody = {
 | 
			
		||||
  /** User email */
 | 
			
		||||
  email: string;
 | 
			
		||||
  /** User email or username */
 | 
			
		||||
  emailOrUsername: string;
 | 
			
		||||
  /**
 | 
			
		||||
   * User password
 | 
			
		||||
   * @minLength 8
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,30 @@
 | 
			
		||||
import {
 | 
			
		||||
  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,
 | 
			
		||||
  IconFileCode,
 | 
			
		||||
  IconFileDescription,
 | 
			
		||||
@@ -8,8 +33,16 @@ import {
 | 
			
		||||
  IconFileText,
 | 
			
		||||
  IconFileTypePdf,
 | 
			
		||||
  IconFileZip,
 | 
			
		||||
  IconKey,
 | 
			
		||||
  IconLock,
 | 
			
		||||
  IconMarkdown,
 | 
			
		||||
  IconMath,
 | 
			
		||||
  IconPalette,
 | 
			
		||||
  IconPhoto,
 | 
			
		||||
  IconPresentation,
 | 
			
		||||
  IconSettings,
 | 
			
		||||
  IconTerminal,
 | 
			
		||||
  IconTool,
 | 
			
		||||
  IconVideo,
 | 
			
		||||
} from "@tabler/icons-react";
 | 
			
		||||
 | 
			
		||||
@@ -20,56 +53,452 @@ interface 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,
 | 
			
		||||
    color: "text-blue-500",
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  // Documents
 | 
			
		||||
  {
 | 
			
		||||
    extensions: ["pdf"],
 | 
			
		||||
    icon: IconFileTypePdf,
 | 
			
		||||
    color: "text-red-500",
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    extensions: ["doc", "docx"],
 | 
			
		||||
    extensions: ["doc", "docx", "odt", "rtf"],
 | 
			
		||||
    icon: IconFileText,
 | 
			
		||||
    color: "text-blue-600",
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    extensions: ["xls", "xlsx", "csv"],
 | 
			
		||||
    extensions: ["xls", "xlsx", "ods", "csv"],
 | 
			
		||||
    icon: IconFileSpreadsheet,
 | 
			
		||||
    color: "text-green-600",
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    extensions: ["ppt", "pptx"],
 | 
			
		||||
    extensions: ["ppt", "pptx", "odp"],
 | 
			
		||||
    icon: IconPresentation,
 | 
			
		||||
    color: "text-orange-500",
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  // Media
 | 
			
		||||
  {
 | 
			
		||||
    extensions: ["mp3", "wav", "ogg", "m4a"],
 | 
			
		||||
    extensions: ["mp3", "wav", "ogg", "m4a", "aac", "flac", "wma", "opus"],
 | 
			
		||||
    icon: IconFileMusic,
 | 
			
		||||
    color: "text-purple-500",
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    extensions: ["mp4", "avi", "mov", "wmv", "mkv"],
 | 
			
		||||
    extensions: ["mp4", "avi", "mov", "wmv", "mkv", "webm", "flv", "m4v", "3gp"],
 | 
			
		||||
    icon: IconVideo,
 | 
			
		||||
    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,
 | 
			
		||||
    color: "text-yellow-600",
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  // JavaScript/TypeScript
 | 
			
		||||
  {
 | 
			
		||||
    extensions: ["html", "css", "js", "ts", "jsx", "tsx", "json", "xml"],
 | 
			
		||||
    icon: IconFileCode,
 | 
			
		||||
    extensions: ["js", "mjs", "cjs"],
 | 
			
		||||
    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",
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    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,
 | 
			
		||||
    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 } {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										374
									
								
								apps/web/src/utils/file-types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										374
									
								
								apps/web/src/utils/file-types.ts
									
									
									
									
									
										Normal 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() || "";
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user