mirror of
https://github.com/kyantech/Palmr.git
synced 2025-10-23 06:11:58 +00:00
Compare commits
43 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 | ||
|
76d96816bc | ||
|
b3e7658a76 | ||
|
61a579aeb3 | ||
|
cc9c375774 | ||
|
016006ba3d | ||
|
cbc567c6a8 | ||
|
25b4d886f7 | ||
|
98953e042b | ||
|
9e06a67593 | ||
|
9682f96905 | ||
|
d2c69c3b36 |
@@ -132,6 +132,7 @@ set -e
|
||||
|
||||
echo "Starting Palmr Application..."
|
||||
echo "Storage Mode: \${ENABLE_S3:-false}"
|
||||
echo "Secure Site: \${SECURE_SITE:-false}"
|
||||
echo "Database: SQLite"
|
||||
|
||||
# Set global environment variables
|
||||
|
@@ -15,6 +15,7 @@
|
||||
"configuring-smtp",
|
||||
"available-languages",
|
||||
"uid-gid-configuration",
|
||||
"reverse-proxy-configuration",
|
||||
"password-reset-without-smtp",
|
||||
"oidc-authentication",
|
||||
"---Developers---",
|
||||
|
@@ -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
|
||||
|
@@ -56,6 +56,7 @@ services:
|
||||
environment:
|
||||
- ENABLE_S3=false
|
||||
- ENCRYPTION_KEY=change-this-key-in-production-min-32-chars # CHANGE THIS KEY FOR SECURITY
|
||||
# - SECURE_SITE=false # Set to true if you are using a reverse proxy
|
||||
ports:
|
||||
- "5487:5487" # Web interface
|
||||
- "3333:3333" # API port (OPTIONAL EXPOSED - ONLY IF YOU WANT TO ACCESS THE API DIRECTLY)
|
||||
@@ -91,6 +92,7 @@ services:
|
||||
environment:
|
||||
- ENABLE_S3=false
|
||||
- ENCRYPTION_KEY=change-this-key-in-production-min-32-chars # CHANGE THIS KEY FOR SECURITY
|
||||
# - SECURE_SITE=false # Set to true if you are using a reverse proxy
|
||||
# Optional: Set custom UID/GID for file permissions
|
||||
# - PALMR_UID=1000
|
||||
# - PALMR_GID=1000
|
||||
@@ -121,9 +123,12 @@ Configure Palmr. behavior through environment variables:
|
||||
| ---------------- | ------- | ------------------------------------------------------- |
|
||||
| `ENABLE_S3` | `false` | Enable S3-compatible storage |
|
||||
| `ENCRYPTION_KEY` | - | **Required**: Minimum 32 characters for file encryption |
|
||||
| `SECURE_SITE` | `false` | Enable secure cookies for HTTPS/reverse proxy setups |
|
||||
|
||||
> **⚠️ Security Warning**: Always change the `ENCRYPTION_KEY` in production. This key encrypts your files - losing it makes files permanently inaccessible.
|
||||
|
||||
> **🔗 Reverse Proxy**: If deploying behind a reverse proxy (Traefik, Nginx, etc.), set `SECURE_SITE=true` and review our [Reverse Proxy Configuration](/docs/3.0-beta/reverse-proxy-configuration) guide for proper setup.
|
||||
|
||||
### Generate Secure Encryption Keys
|
||||
|
||||
Need a strong key for `ENCRYPTION_KEY`? Use our built-in generator to create cryptographically secure keys:
|
||||
|
199
apps/docs/content/docs/3.0-beta/reverse-proxy-configuration.mdx
Normal file
199
apps/docs/content/docs/3.0-beta/reverse-proxy-configuration.mdx
Normal file
@@ -0,0 +1,199 @@
|
||||
---
|
||||
title: Reverse Proxy Configuration
|
||||
icon: "Shield"
|
||||
---
|
||||
|
||||
When deploying **Palmr.** behind a reverse proxy (like Traefik, Nginx, or Cloudflare), you need to configure secure cookie settings to ensure proper authentication. This guide covers the `SECURE_SITE` environment variable and related proxy configurations.
|
||||
|
||||
## Overview
|
||||
|
||||
Reverse proxies terminate SSL/TLS connections and forward requests to Palmr., which can cause authentication issues if cookies aren't configured properly for HTTPS environments. The `SECURE_SITE` environment variable controls cookie security settings to handle these scenarios.
|
||||
|
||||
## The SECURE_SITE Environment Variable
|
||||
|
||||
The `SECURE_SITE` variable configures how Palmr. handles authentication cookies based on your deployment environment:
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Value | Cookie Settings | Use Case |
|
||||
| ------- | ------------------------------------- | ----------------------------------- |
|
||||
| `true` | `secure: true`, `sameSite: "lax"` | HTTPS/Production with reverse proxy |
|
||||
| `false` | `secure: false`, `sameSite: "strict"` | HTTP/Development (default) |
|
||||
|
||||
### When to Use SECURE_SITE=true
|
||||
|
||||
Set `SECURE_SITE=true` in the following scenarios:
|
||||
|
||||
- ✅ **Reverse Proxy with HTTPS**: Traefik, Nginx, HAProxy with SSL termination
|
||||
- ✅ **Cloud Providers**: Cloudflare, AWS ALB, Azure Application Gateway
|
||||
- ✅ **CDN with HTTPS**: Any CDN that terminates SSL
|
||||
- ✅ **Production Deployments**: When users access via HTTPS
|
||||
|
||||
### When to Use SECURE_SITE=false
|
||||
|
||||
Keep `SECURE_SITE=false` (default) for:
|
||||
|
||||
- ✅ **Local Development**: Running on `http://localhost`
|
||||
- ✅ **Direct HTTP Access**: No reverse proxy involved
|
||||
- ✅ **Testing Environments**: When using HTTP
|
||||
- ✅ **HTTP Reverse Proxy**: Nginx, Apache, etc. without SSL termination
|
||||
|
||||
---
|
||||
|
||||
## HTTP Reverse Proxy Setup
|
||||
|
||||
**Docker Compose for HTTP Nginx:**
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- SECURE_SITE=false # HTTP = false
|
||||
```
|
||||
|
||||
> **⚠️ HTTP Security**: Remember that HTTP transmits data in plain text. Consider using HTTPS in production environments.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting Authentication Issues
|
||||
|
||||
### Common Symptoms
|
||||
|
||||
If you experience authentication issues behind a reverse proxy:
|
||||
|
||||
- ❌ Login appears successful but redirects to login page
|
||||
- ❌ "No Authorization was found in request.cookies" errors
|
||||
- ❌ API requests return 401 Unauthorized
|
||||
- ❌ User registration fails silently
|
||||
|
||||
### Diagnostic Steps
|
||||
|
||||
1. **Check Browser Developer Tools**:
|
||||
|
||||
- Look for cookies in Application/Storage tab
|
||||
- Verify cookie has `Secure` flag when using HTTPS
|
||||
- Check if `SameSite` attribute is appropriate
|
||||
|
||||
2. **Verify Environment Variables**:
|
||||
|
||||
```bash
|
||||
docker exec -it palmr env | grep SECURE_SITE
|
||||
```
|
||||
|
||||
3. **Test Cookie Settings**:
|
||||
- With `SECURE_SITE=false`: Should work on HTTP
|
||||
- With `SECURE_SITE=true`: Should work on HTTPS
|
||||
|
||||
### Common Fixes
|
||||
|
||||
**Problem**: Authentication fails with reverse proxy
|
||||
|
||||
**Solution**: Set `SECURE_SITE=true` and ensure proper headers:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- SECURE_SITE=true
|
||||
```
|
||||
|
||||
**Problem**: Mixed content errors
|
||||
|
||||
**Solution**: Ensure proxy passes correct headers:
|
||||
|
||||
```yaml
|
||||
# Traefik
|
||||
- "traefik.http.middlewares.palmr-headers.headers.customrequestheaders.X-Forwarded-Proto=https"
|
||||
|
||||
# Nginx
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
```
|
||||
|
||||
**Problem**: Authentication fails with HTTP reverse proxy
|
||||
|
||||
**Solution**: Use `SECURE_SITE=false` and ensure proper cookie headers:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- SECURE_SITE=false # For HTTP proxy
|
||||
```
|
||||
|
||||
```nginx
|
||||
# Nginx - Add these headers for cookie handling
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_pass_header Set-Cookie;
|
||||
```
|
||||
|
||||
**Problem**: SQLite "readonly database" error with bind mounts
|
||||
|
||||
**Solution**: Configure proper UID/GID permissions:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- PALMR_UID=1000 # Your host UID (check with: id)
|
||||
- PALMR_GID=1000 # Your host GID
|
||||
- ENCRYPTION_KEY=your-key-here
|
||||
```
|
||||
|
||||
> **💡 Note**: Check your host UID/GID with `id` command and use those values. See [UID/GID Configuration](/docs/3.0-beta/uid-gid-configuration) for detailed setup.
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
> **⚠️ Important**: Always use HTTPS in production environments. The `SECURE_SITE=true` setting ensures cookies are only sent over encrypted connections.
|
||||
|
||||
---
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Multiple Domains
|
||||
|
||||
If serving Palmr. on multiple domains, ensure consistent cookie settings:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- SECURE_SITE=true # Use for all HTTPS domains
|
||||
```
|
||||
|
||||
### Development vs Production
|
||||
|
||||
Use environment-specific configurations:
|
||||
|
||||
**Development (HTTP):**
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- SECURE_SITE=false
|
||||
```
|
||||
|
||||
**Production (HTTPS):**
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- SECURE_SITE=true
|
||||
```
|
||||
|
||||
### Health Checks
|
||||
|
||||
Add health checks to ensure proper proxy configuration:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
palmr:
|
||||
# ... other config
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5487/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Need Help?
|
||||
|
||||
If you're still experiencing issues after following this guide:
|
||||
|
||||
1. **Check the Logs**: `docker logs palmr`
|
||||
2. **Verify Headers**: Use browser dev tools or `curl -I`
|
||||
3. **Test Direct Access**: Try accessing Palmr. directly (bypassing proxy)
|
||||
4. **Open an Issue**: [Report bugs on GitHub](https://github.com/kyantech/Palmr/issues)
|
||||
|
||||
> **💡 Pro Tip**: When reporting issues, include your reverse proxy configuration and any relevant error messages from both Palmr. and your proxy logs.
|
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,
|
||||
|
@@ -11,6 +11,7 @@ const envSchema = z.object({
|
||||
S3_REGION: z.string().optional(),
|
||||
S3_BUCKET_NAME: z.string().optional(),
|
||||
S3_FORCE_PATH_STYLE: z.union([z.literal("true"), z.literal("false")]).default("false"),
|
||||
SECURE_SITE: z.union([z.literal("true"), z.literal("false")]).default("false"),
|
||||
DATABASE_URL: z.string().optional().default("file:/app/server/prisma/palmr.db"),
|
||||
});
|
||||
|
||||
|
@@ -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,3 +1,4 @@
|
||||
import { env } from "../../env";
|
||||
import { LoginSchema, RequestPasswordResetSchema, createResetPasswordSchema } from "./dto";
|
||||
import { AuthService } from "./service";
|
||||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
@@ -17,8 +18,8 @@ export class AuthController {
|
||||
reply.setCookie("token", token, {
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
secure: false,
|
||||
sameSite: "strict",
|
||||
secure: env.SECURE_SITE === "true" ? true : false,
|
||||
sameSite: env.SECURE_SITE === "true" ? "lax" : "strict",
|
||||
});
|
||||
|
||||
return reply.send({ user });
|
||||
|
@@ -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");
|
||||
}
|
||||
|
@@ -98,43 +98,82 @@ 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 chunks: Buffer[] = [];
|
||||
const body = request.body as any;
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
if (Buffer.isBuffer(body)) {
|
||||
if (body.length === 0) {
|
||||
throw new Error("No file data received");
|
||||
}
|
||||
await provider.uploadFile(objectName, body);
|
||||
return;
|
||||
}
|
||||
|
||||
stream.on("end", async () => {
|
||||
try {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
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 (buffer.length === 0) {
|
||||
throw new Error("No file data received");
|
||||
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) => {
|
||||
body.on("data", (chunk: Buffer) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
body.on("end", async () => {
|
||||
try {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
|
||||
if (buffer.length === 0) {
|
||||
throw new Error("No file data received");
|
||||
}
|
||||
|
||||
await provider.uploadFile(objectName, buffer);
|
||||
resolve();
|
||||
} catch (error) {
|
||||
console.error("Error uploading small file:", error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
await provider.uploadFile(objectName, buffer);
|
||||
resolve();
|
||||
} catch (error) {
|
||||
console.error("Error uploading small file:", error);
|
||||
body.on("error", (error: Error) => {
|
||||
console.error("Error reading upload stream:", error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
stream.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) {
|
||||
|
@@ -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,6 +1,8 @@
|
||||
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";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
@@ -9,6 +11,126 @@ const prisma = new PrismaClient();
|
||||
export class StorageService {
|
||||
private configService = new ConfigService();
|
||||
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if (!fs.existsSync(pathToCheck)) {
|
||||
fs.mkdirSync(pathToCheck, { recursive: true });
|
||||
console.log(`Created directory: ${pathToCheck}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Could not create path ${pathToCheck}:`, err);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!fs.existsSync(pathToCheck)) {
|
||||
console.warn(`Path does not exist: ${pathToCheck}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const commandsToTry =
|
||||
process.platform === "win32"
|
||||
? ["wmic logicaldisk get size,freespace,caption"]
|
||||
: process.platform === "darwin"
|
||||
? [`df -k "${pathToCheck}"`, `df "${pathToCheck}"`]
|
||||
: [`df -B1 "${pathToCheck}"`, `df -k "${pathToCheck}"`, `df "${pathToCheck}"`];
|
||||
|
||||
for (const command of commandsToTry) {
|
||||
const result = await this._tryDiskSpaceCommand(command);
|
||||
if (result) {
|
||||
console.log(`✅ Successfully got disk space for path: ${pathToCheck}`);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async getDiskSpace(
|
||||
userId?: string,
|
||||
isAdmin?: boolean
|
||||
@@ -20,46 +142,40 @@ export class StorageService {
|
||||
}> {
|
||||
try {
|
||||
if (isAdmin) {
|
||||
const command = process.platform === "win32"
|
||||
? "wmic logicaldisk get size,freespace,caption"
|
||||
: process.platform === "darwin"
|
||||
? "df -k ."
|
||||
: "df -B1 .";
|
||||
console.log(`Running in container: ${IS_RUNNING_IN_CONTAINER}`);
|
||||
|
||||
const { stdout } = await execAsync(command);
|
||||
let total = 0;
|
||||
let available = 0;
|
||||
const diskInfo = await this._getDiskSpaceMultiplePaths();
|
||||
|
||||
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);
|
||||
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");
|
||||
|
||||
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 },
|
||||
@@ -68,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,20 +14,25 @@ export async function userRoutes(app: FastifyInstance) {
|
||||
const usersCount = await prisma.user.count();
|
||||
|
||||
if (usersCount > 0) {
|
||||
await request.jwtVerify();
|
||||
if (!request.user.isAdmin) {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
if (!request.user.isAdmin) {
|
||||
return reply
|
||||
.status(403)
|
||||
.send({ error: "Access restricted to administrators" })
|
||||
.description("Access restricted to administrators");
|
||||
}
|
||||
} catch (authErr) {
|
||||
console.error(authErr);
|
||||
return reply
|
||||
.status(403)
|
||||
.send({ error: "Access restricted to administrators" })
|
||||
.description("Access restricted to administrators");
|
||||
.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(401)
|
||||
.send({ error: "Unauthorized: a valid token is required to access this resource." })
|
||||
.description("Unauthorized: a valid token is required to access this resource.");
|
||||
return reply.status(500).send({ error: "Internal server error" }).description("Internal server error");
|
||||
}
|
||||
};
|
||||
|
||||
|
@@ -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",
|
||||
@@ -1342,4 +1355,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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",
|
||||
});
|
||||
|
||||
|
@@ -14,8 +14,8 @@ export default function AuthCallbackPage() {
|
||||
if (token) {
|
||||
Cookies.set("token", token, {
|
||||
path: "/",
|
||||
secure: false,
|
||||
sameSite: "strict",
|
||||
secure: window.location.protocol === "https:",
|
||||
sameSite: window.location.protocol === "https:" ? "lax" : "strict",
|
||||
httpOnly: false,
|
||||
});
|
||||
|
||||
|
@@ -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);
|
||||
|
||||
setDiskSpace(diskSpaceRes.data);
|
||||
if (error.response?.status === 503 && error.response?.data?.code === "DISK_SPACE_DETECTION_FAILED") {
|
||||
setDiskSpaceError("disk_detection_failed");
|
||||
} else if (error.response?.status >= 500) {
|
||||
setDiskSpaceError("server_error");
|
||||
} else {
|
||||
setDiskSpaceError("unknown_error");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const allFiles = filesRes.data.files || [];
|
||||
const sortedFiles = [...allFiles].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
const loadFilesAndShares = async () => {
|
||||
const [filesRes, sharesRes] = await Promise.all([listFiles(), listUserShares()]);
|
||||
|
||||
setRecentFiles(sortedFiles.slice(0, 5));
|
||||
const allFiles = filesRes.data.files || [];
|
||||
const sortedFiles = [...allFiles].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
setRecentFiles(sortedFiles.slice(0, 5));
|
||||
|
||||
const allShares = sharesRes.data.shares || [];
|
||||
const sortedShares = [...allShares].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
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));
|
||||
};
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
@@ -49,6 +49,17 @@ export function useLogin() {
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const appInfoResponse = await fetch("/api/app/info");
|
||||
const appInfo = await appInfoResponse.json();
|
||||
|
||||
if (appInfo.firstUserAccess) {
|
||||
setUser(null);
|
||||
setIsAdmin(false);
|
||||
setIsAuthenticated(false);
|
||||
setIsInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const userResponse = await getCurrentUser();
|
||||
if (!userResponse?.data?.user) {
|
||||
throw new Error("No user data");
|
||||
|
@@ -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")),
|
||||
});
|
||||
|
||||
|
@@ -49,7 +49,7 @@ export function LanguageSwitcher() {
|
||||
maxAge: COOKIE_MAX_AGE,
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
secure: false,
|
||||
secure: window.location.protocol === "https:",
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
|
@@ -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>
|
||||
|
@@ -41,6 +41,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const appInfoResponse = await fetch("/api/app/info");
|
||||
const appInfo = await appInfoResponse.json();
|
||||
|
||||
if (appInfo.firstUserAccess) {
|
||||
setUser(null);
|
||||
setIsAdmin(false);
|
||||
setIsAuthenticated(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await getCurrentUser();
|
||||
if (!response?.data?.user) {
|
||||
throw new Error("No user data");
|
||||
|
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() || "";
|
||||
}
|
@@ -36,46 +36,86 @@ echo "💾 Database: $DATABASE_URL"
|
||||
echo "📁 Creating data directories..."
|
||||
mkdir -p /app/server/prisma /app/server/uploads /app/server/temp-chunks /app/server/uploads/logo
|
||||
|
||||
# Fix ownership of database directory BEFORE database operations
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
echo "🔐 Ensuring proper ownership before database operations..."
|
||||
chown -R $TARGET_UID:$TARGET_GID /app/server/prisma 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Check if it's a first run (no database file exists)
|
||||
if [ ! -f "/app/server/prisma/palmr.db" ]; then
|
||||
echo "🚀 First run detected - setting up database..."
|
||||
|
||||
# Create database with proper schema path
|
||||
# Create database with proper schema path - run as target user to avoid permission issues
|
||||
echo "🗄️ Creating database schema..."
|
||||
npx prisma db push --schema=./prisma/schema.prisma --skip-generate
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
su-exec $TARGET_UID:$TARGET_GID npx prisma db push --schema=./prisma/schema.prisma --skip-generate
|
||||
else
|
||||
npx prisma db push --schema=./prisma/schema.prisma --skip-generate
|
||||
fi
|
||||
|
||||
# Run seed script from application directory (where node_modules is)
|
||||
# Run seed script from application directory (where node_modules is) - as target user
|
||||
echo "🌱 Seeding database..."
|
||||
node ./prisma/seed.js
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
su-exec $TARGET_UID:$TARGET_GID node ./prisma/seed.js
|
||||
else
|
||||
node ./prisma/seed.js
|
||||
fi
|
||||
|
||||
echo "✅ Database setup completed!"
|
||||
else
|
||||
echo "♻️ Existing database found"
|
||||
|
||||
# Always run migrations to ensure schema is up to date
|
||||
# Always run migrations to ensure schema is up to date - as target user
|
||||
echo "🔧 Checking for schema updates..."
|
||||
npx prisma db push --schema=./prisma/schema.prisma --skip-generate
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
su-exec $TARGET_UID:$TARGET_GID npx prisma db push --schema=./prisma/schema.prisma --skip-generate
|
||||
else
|
||||
npx prisma db push --schema=./prisma/schema.prisma --skip-generate
|
||||
fi
|
||||
|
||||
# Check if configurations exist
|
||||
# Check if configurations exist - as target user
|
||||
echo "🔍 Verifying database configurations..."
|
||||
CONFIG_COUNT=$(node -e "
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
prisma.appConfig.count()
|
||||
.then(count => {
|
||||
console.log(count);
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(() => {
|
||||
console.log(0);
|
||||
process.exit(0);
|
||||
});
|
||||
" 2>/dev/null || echo "0")
|
||||
CONFIG_COUNT=$(
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
su-exec $TARGET_UID:$TARGET_GID node -e "
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
prisma.appConfig.count()
|
||||
.then(count => {
|
||||
console.log(count);
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(() => {
|
||||
console.log(0);
|
||||
process.exit(0);
|
||||
});
|
||||
" 2>/dev/null || echo "0"
|
||||
else
|
||||
node -e "
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
prisma.appConfig.count()
|
||||
.then(count => {
|
||||
console.log(count);
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(() => {
|
||||
console.log(0);
|
||||
process.exit(0);
|
||||
});
|
||||
" 2>/dev/null || echo "0"
|
||||
fi
|
||||
)
|
||||
|
||||
if [ "$CONFIG_COUNT" -eq "0" ]; then
|
||||
echo "🌱 No configurations found, running seed..."
|
||||
# Always run seed from application directory where node_modules is available
|
||||
node ./prisma/seed.js
|
||||
# Always run seed from application directory where node_modules is available - as target user
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
su-exec $TARGET_UID:$TARGET_GID node ./prisma/seed.js
|
||||
else
|
||||
node ./prisma/seed.js
|
||||
fi
|
||||
else
|
||||
echo "✅ Found $CONFIG_COUNT configurations"
|
||||
fi
|
||||
|
Reference in New Issue
Block a user