feat: add folder system (#241)

This commit is contained in:
Tommy Johnston
2025-09-09 08:07:07 -04:00
committed by GitHub
parent 494161eb47
commit abd8366e94
95 changed files with 9577 additions and 2306 deletions

View File

@@ -14,6 +14,7 @@
- **Self-hosted** Deploy on your own server or VPS.
- **Full control** No third-party dependencies, ensuring privacy and security.
- **No artificial limits** Share files without hidden restrictions or fees.
- **Folder organization** Create folders to organize and share files.
- **Simple deployment** SQLite database and filesystem storage for easy setup.
- **Scalable storage** Optional S3-compatible object storage for enterprise needs.

View File

@@ -105,6 +105,12 @@ The Palmr. API provides comprehensive access to all platform features:
- **File management** - Rename, delete, and organize files
- **Metadata access** - Retrieve file information and properties
### Folder operations
- **Create folders** - Build folder structures for organization
- **Folder management** - Rename, move, delete folders
- **Folder sharing** - Share folders with same controls as files
### Share management
- **Create shares** - Generate public links for file sharing

View File

@@ -49,6 +49,7 @@ The frontend is organized with:
- **Custom hooks** to isolate logic and side effects
- A **route protection system** using session cookies and middleware
- A **file management interface** integrated with the backend
- **Folder support** for organizing files hierarchically
- A **reusable modal system** used for file actions, confirmations, and more
- **Dynamic, locale-aware routing** using next-intl
@@ -68,7 +69,7 @@ Data is stored in **SQLite**, which handles user info, file metadata, session to
Key features include:
- **Authentication/authorization** with JWT + cookie sessions
- **File management logic** including uploads, deletes, and renames
- **File management logic** including uploads, deletes, renames, and folders
- **Storage operations** to handle file organization, usage tracking, and cleanup
- A **share system** that generates tokenized public file links
- Schema-based request validation for all endpoints
@@ -106,9 +107,10 @@ Volumes are used to persist data locally, and containers are networked together
### File management
Files are at the heart of Palmr. Users can upload files via the frontend, and they're stored directly in the filesystem. The backend handles metadata (name, size, type, ownership), and also handles deletion, renaming, and public sharing. Every file operation is tracked, and all actions can be scoped per user.
Files are at the heart of Palmr. Users can upload files via the frontend, and they're stored directly in the filesystem. Users can also create folders to organize files. The backend handles metadata (name, size, type, ownership), and also handles deletion, renaming, and public sharing. Every file operation is tracked, and all actions can be scoped per user.
- Upload/download with instant feedback
- Create and organize files in folders
- File previews, type validation, and size limits
- Token-based sharing system
- Disk usage tracking by user

View File

@@ -44,7 +44,7 @@ The central hub after login, providing an overview of recent activity, quick act
### Files list view
Comprehensive file browser displaying all uploaded files in a detailed list format with metadata, actions, and sorting options.
Comprehensive file browser displaying all uploaded files in a detailed list format with metadata, actions, sorting options, and folder navigation.
<ZoomableImage
src="/assets/v3/screenshots/files-list.png"
@@ -53,7 +53,7 @@ Comprehensive file browser displaying all uploaded files in a detailed list form
### Files card view
Alternative file browser layout showing files as visual cards, perfect for quick browsing and visual file identification.
Alternative file browser layout showing files as visual cards, perfect for quick browsing, visual file identification, and folder navigation.
<ZoomableImage
src="/assets/v3/screenshots/files-card.png"
@@ -73,7 +73,7 @@ File upload interface where users can drag and drop or select files to upload to
### Shares page
Management interface for all shared files and folders, showing share status, permissions, and access controls.
Management interface for all shared files and folders, showing share status, permissions, and access controls for both individual files and folders.
<ZoomableImage
src="/assets/v3/screenshots/shares.png"

View File

@@ -26,6 +26,7 @@ model User {
twoFactorVerified Boolean @default(false)
files File[]
folders Folder[]
shares Share[]
reverseShares ReverseShare[]
@@ -49,11 +50,16 @@ model File {
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
folderId String?
folder Folder? @relation(fields: [folderId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
shares Share[] @relation("ShareFiles")
@@index([folderId])
@@map("files")
}
@@ -73,6 +79,7 @@ model Share {
security ShareSecurity @relation(fields: [securityId], references: [id])
files File[] @relation("ShareFiles")
folders Folder[] @relation("ShareFolders")
recipients ShareRecipient[]
alias ShareAlias?
@@ -285,3 +292,28 @@ model TrustedDevice {
@@map("trusted_devices")
}
model Folder {
id String @id @default(cuid())
name String
description String?
objectName String
parentId String?
parent Folder? @relation("FolderHierarchy", fields: [parentId], references: [id], onDelete: Cascade)
children Folder[] @relation("FolderHierarchy")
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
files File[]
shares Share[] @relation("ShareFolders")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([parentId])
@@map("folders")
}

View File

@@ -18,6 +18,7 @@ export function registerSwagger(app: any) {
{ name: "Auth Providers", description: "External authentication providers management" },
{ name: "User", description: "User management endpoints" },
{ name: "File", description: "File management endpoints" },
{ name: "Folder", description: "Folder management endpoints" },
{ name: "Share", description: "File sharing endpoints" },
{ name: "Storage", description: "Storage management endpoints" },
{ name: "App", description: "Application configuration endpoints" },

View File

@@ -3,7 +3,18 @@ import { FastifyReply, FastifyRequest } from "fastify";
import { env } from "../../env";
import { prisma } from "../../shared/prisma";
import { ConfigService } from "../config/service";
import { CheckFileInput, CheckFileSchema, RegisterFileInput, RegisterFileSchema, UpdateFileSchema } from "./dto";
import {
CheckFileInput,
CheckFileSchema,
ListFilesInput,
ListFilesSchema,
MoveFileInput,
MoveFileSchema,
RegisterFileInput,
RegisterFileSchema,
UpdateFileInput,
UpdateFileSchema,
} from "./dto";
import { FileService } from "./service";
export class FileController {
@@ -72,6 +83,15 @@ export class FileController {
});
}
if (input.folderId) {
const folder = await prisma.folder.findFirst({
where: { id: input.folderId, userId },
});
if (!folder) {
return reply.status(400).send({ error: "Folder not found or access denied." });
}
}
const fileRecord = await prisma.file.create({
data: {
name: input.name,
@@ -80,6 +100,7 @@ export class FileController {
size: BigInt(input.size),
objectName: input.objectName,
userId,
folderId: input.folderId,
},
});
@@ -91,6 +112,7 @@ export class FileController {
size: fileRecord.size.toString(),
objectName: fileRecord.objectName,
userId: fileRecord.userId,
folderId: fileRecord.folderId,
createdAt: fileRecord.createdAt,
updatedAt: fileRecord.updatedAt,
};
@@ -189,18 +211,43 @@ export class FileController {
return reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
}
const files = await prisma.file.findMany({
where: { userId },
});
const input: ListFilesInput = ListFilesSchema.parse(request.query);
const { folderId, recursive: recursiveStr } = input;
const recursive = recursiveStr === "false" ? false : true;
const filesResponse = files.map((file) => ({
let files: any[];
let targetFolderId: string | null;
if (folderId === "null" || folderId === "" || !folderId) {
targetFolderId = null; // Root folder
} else {
targetFolderId = folderId;
}
if (recursive) {
if (targetFolderId === null) {
files = await this.getAllUserFilesRecursively(userId);
} else {
const { FolderService } = await import("../folder/service.js");
const folderService = new FolderService();
files = await folderService.getAllFilesInFolder(targetFolderId, userId);
}
} else {
files = await prisma.file.findMany({
where: { userId, folderId: targetFolderId },
});
}
const filesResponse = files.map((file: any) => ({
id: file.id,
name: file.name,
description: file.description,
extension: file.extension,
size: file.size.toString(),
size: typeof file.size === "bigint" ? file.size.toString() : file.size,
objectName: file.objectName,
userId: file.userId,
folderId: file.folderId,
relativePath: file.relativePath || null,
createdAt: file.createdAt,
updatedAt: file.updatedAt,
}));
@@ -278,6 +325,7 @@ export class FileController {
size: updatedFile.size.toString(),
objectName: updatedFile.objectName,
userId: updatedFile.userId,
folderId: updatedFile.folderId,
createdAt: updatedFile.createdAt,
updatedAt: updatedFile.updatedAt,
};
@@ -291,4 +339,86 @@ export class FileController {
return reply.status(400).send({ error: error.message });
}
}
async moveFile(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
}
const { id } = request.params as { id: string };
const input: MoveFileInput = MoveFileSchema.parse(request.body);
const existingFile = await prisma.file.findFirst({
where: { id, userId },
});
if (!existingFile) {
return reply.status(404).send({ error: "File not found." });
}
if (input.folderId) {
const targetFolder = await prisma.folder.findFirst({
where: { id: input.folderId, userId },
});
if (!targetFolder) {
return reply.status(400).send({ error: "Target folder not found." });
}
}
const updatedFile = await prisma.file.update({
where: { id },
data: { folderId: input.folderId },
});
const fileResponse = {
id: updatedFile.id,
name: updatedFile.name,
description: updatedFile.description,
extension: updatedFile.extension,
size: updatedFile.size.toString(),
objectName: updatedFile.objectName,
userId: updatedFile.userId,
folderId: updatedFile.folderId,
createdAt: updatedFile.createdAt,
updatedAt: updatedFile.updatedAt,
};
return reply.send({
file: fileResponse,
message: "File moved successfully.",
});
} catch (error: any) {
console.error("Error moving file:", error);
return reply.status(400).send({ error: error.message });
}
}
private async getAllUserFilesRecursively(userId: string): Promise<any[]> {
const rootFiles = await prisma.file.findMany({
where: { userId, folderId: null },
});
const rootFolders = await prisma.folder.findMany({
where: { userId, parentId: null },
select: { id: true },
});
let allFiles = [...rootFiles];
if (rootFolders.length > 0) {
const { FolderService } = await import("../folder/service.js");
const folderService = new FolderService();
for (const folder of rootFolders) {
const folderFiles = await folderService.getAllFilesInFolder(folder.id, userId);
allFiles = [...allFiles, ...folderFiles];
}
}
return allFiles;
}
}

View File

@@ -9,6 +9,7 @@ export const RegisterFileSchema = z.object({
invalid_type_error: "O tamanho deve ser um número",
}),
objectName: z.string().min(1, "O objectName é obrigatório"),
folderId: z.string().optional(),
});
export const CheckFileSchema = z.object({
@@ -20,6 +21,7 @@ export const CheckFileSchema = z.object({
invalid_type_error: "O tamanho deve ser um número",
}),
objectName: z.string().min(1, "O objectName é obrigatório"),
folderId: z.string().optional(),
});
export type RegisterFileInput = z.infer<typeof RegisterFileSchema>;
@@ -30,4 +32,15 @@ export const UpdateFileSchema = z.object({
description: z.string().optional().nullable().describe("The file description"),
});
export const MoveFileSchema = z.object({
folderId: z.string().nullable(),
});
export const ListFilesSchema = z.object({
folderId: z.string().optional().describe("The folder ID"),
recursive: z.string().optional().default("true").describe("Include files from subfolders"),
});
export type UpdateFileInput = z.infer<typeof UpdateFileSchema>;
export type MoveFileInput = z.infer<typeof MoveFileSchema>;
export type ListFilesInput = z.infer<typeof ListFilesSchema>;

View File

@@ -2,7 +2,7 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { FileController } from "./controller";
import { CheckFileSchema, RegisterFileSchema, UpdateFileSchema } from "./dto";
import { CheckFileSchema, ListFilesSchema, MoveFileSchema, RegisterFileSchema, UpdateFileSchema } from "./dto";
export async function fileRoutes(app: FastifyInstance) {
const fileController = new FileController();
@@ -62,6 +62,7 @@ export async function fileRoutes(app: FastifyInstance) {
size: z.string().describe("The file size"),
objectName: z.string().describe("The object name of the file"),
userId: z.string().describe("The user ID"),
folderId: z.string().nullable().describe("The folder ID"),
createdAt: z.date().describe("The file creation date"),
updatedAt: z.date().describe("The file last update date"),
}),
@@ -78,6 +79,7 @@ export async function fileRoutes(app: FastifyInstance) {
app.post(
"/files/check",
{
preValidation,
schema: {
tags: ["File"],
operationId: "checkFile",
@@ -106,11 +108,12 @@ export async function fileRoutes(app: FastifyInstance) {
app.get(
"/files/:objectName/download",
{
preValidation,
schema: {
tags: ["File"],
operationId: "getDownloadUrl",
summary: "Get Download URL",
description: "Generates a pre-signed URL for downloading a private file",
description: "Generates a pre-signed URL for downloading a file",
params: z.object({
objectName: z.string().min(1, "The objectName is required"),
}),
@@ -136,7 +139,8 @@ export async function fileRoutes(app: FastifyInstance) {
tags: ["File"],
operationId: "listFiles",
summary: "List Files",
description: "Lists user files",
description: "Lists user files recursively by default, optionally filtered by folder",
querystring: ListFilesSchema,
response: {
200: z.object({
files: z.array(
@@ -148,6 +152,8 @@ export async function fileRoutes(app: FastifyInstance) {
size: z.string().describe("The file size"),
objectName: z.string().describe("The object name of the file"),
userId: z.string().describe("The user ID"),
folderId: z.string().nullable().describe("The folder ID"),
relativePath: z.string().nullable().describe("The relative path (only for recursive listing)"),
createdAt: z.date().describe("The file creation date"),
updatedAt: z.date().describe("The file last update date"),
})
@@ -160,6 +166,84 @@ export async function fileRoutes(app: FastifyInstance) {
fileController.listFiles.bind(fileController)
);
app.patch(
"/files/:id",
{
preValidation,
schema: {
tags: ["File"],
operationId: "updateFile",
summary: "Update File Metadata",
description: "Updates file metadata in the database",
params: z.object({
id: z.string().min(1, "The file id is required").describe("The file ID"),
}),
body: UpdateFileSchema,
response: {
200: z.object({
file: z.object({
id: z.string().describe("The file ID"),
name: z.string().describe("The file name"),
description: z.string().nullable().describe("The file description"),
extension: z.string().describe("The file extension"),
size: z.string().describe("The file size"),
objectName: z.string().describe("The object name of the file"),
userId: z.string().describe("The user ID"),
folderId: z.string().nullable().describe("The folder ID"),
createdAt: z.date().describe("The file creation date"),
updatedAt: z.date().describe("The file last update date"),
}),
message: z.string().describe("Success message"),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
403: z.object({ error: z.string().describe("Error message") }),
404: z.object({ error: z.string().describe("Error message") }),
},
},
},
fileController.updateFile.bind(fileController)
);
app.put(
"/files/:id/move",
{
preValidation,
schema: {
tags: ["File"],
operationId: "moveFile",
summary: "Move File",
description: "Moves a file to a different folder",
params: z.object({
id: z.string().min(1, "The file id is required").describe("The file ID"),
}),
body: MoveFileSchema,
response: {
200: z.object({
file: z.object({
id: z.string().describe("The file ID"),
name: z.string().describe("The file name"),
description: z.string().nullable().describe("The file description"),
extension: z.string().describe("The file extension"),
size: z.string().describe("The file size"),
objectName: z.string().describe("The object name of the file"),
userId: z.string().describe("The user ID"),
folderId: z.string().nullable().describe("The folder ID"),
createdAt: z.date().describe("The file creation date"),
updatedAt: z.date().describe("The file last update date"),
}),
message: z.string().describe("Success message"),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
403: z.object({ error: z.string().describe("Error message") }),
404: z.object({ error: z.string().describe("Error message") }),
},
},
},
fileController.moveFile.bind(fileController)
);
app.delete(
"/files/:id",
{
@@ -185,42 +269,4 @@ export async function fileRoutes(app: FastifyInstance) {
},
fileController.deleteFile.bind(fileController)
);
app.patch(
"/files/:id",
{
preValidation,
schema: {
tags: ["File"],
operationId: "updateFile",
summary: "Update File Metadata",
description: "Updates file metadata in the database",
params: z.object({
id: z.string().min(1, "The file id is required").describe("The file ID"),
}),
body: UpdateFileSchema,
response: {
200: z.object({
file: z.object({
id: z.string().describe("The file ID"),
name: z.string().describe("The file name"),
description: z.string().nullable().describe("The file description"),
extension: z.string().describe("The file extension"),
size: z.string().describe("The file size"),
objectName: z.string().describe("The object name of the file"),
userId: z.string().describe("The user ID"),
createdAt: z.date().describe("The file creation date"),
updatedAt: z.date().describe("The file last update date"),
}),
message: z.string().describe("Success message"),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
403: z.object({ error: z.string().describe("Error message") }),
404: z.object({ error: z.string().describe("Error message") }),
},
},
},
fileController.updateFile.bind(fileController)
);
}

View File

@@ -0,0 +1,412 @@
import { FastifyReply, FastifyRequest } from "fastify";
import { env } from "../../env";
import { prisma } from "../../shared/prisma";
import { ConfigService } from "../config/service";
import {
CheckFolderSchema,
ListFoldersSchema,
MoveFolderSchema,
RegisterFolderSchema,
UpdateFolderSchema,
} from "./dto";
import { FolderService } from "./service";
export class FolderController {
private folderService = new FolderService();
private configService = new ConfigService();
async registerFolder(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
}
const input = RegisterFolderSchema.parse(request.body);
if (input.parentId) {
const parentFolder = await prisma.folder.findFirst({
where: { id: input.parentId, userId },
});
if (!parentFolder) {
return reply.status(400).send({ error: "Parent folder not found or access denied" });
}
}
const existingFolder = await prisma.folder.findFirst({
where: {
name: input.name,
parentId: input.parentId || null,
userId,
},
});
if (existingFolder) {
return reply.status(400).send({ error: "A folder with this name already exists in this location" });
}
const folderRecord = await prisma.folder.create({
data: {
name: input.name,
description: input.description,
objectName: input.objectName,
parentId: input.parentId,
userId,
},
include: {
_count: {
select: {
files: true,
children: true,
},
},
},
});
const totalSize = await this.folderService.calculateFolderSize(folderRecord.id, userId);
const folderResponse = {
id: folderRecord.id,
name: folderRecord.name,
description: folderRecord.description,
objectName: folderRecord.objectName,
parentId: folderRecord.parentId,
userId: folderRecord.userId,
createdAt: folderRecord.createdAt,
updatedAt: folderRecord.updatedAt,
totalSize: totalSize.toString(),
_count: folderRecord._count,
};
return reply.status(201).send({
folder: folderResponse,
message: "Folder registered successfully.",
});
} catch (error: any) {
console.error("Error in registerFolder:", error);
return reply.status(400).send({ error: error.message });
}
}
async checkFolder(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({
error: "Unauthorized: a valid token is required to access this resource.",
code: "unauthorized",
});
}
const input = CheckFolderSchema.parse(request.body);
if (input.name.length > 100) {
return reply.status(400).send({
code: "folderNameTooLong",
error: "Folder name exceeds maximum length of 100 characters",
details: "100",
});
}
const existingFolder = await prisma.folder.findFirst({
where: {
name: input.name,
parentId: input.parentId || null,
userId,
},
});
if (existingFolder) {
return reply.status(400).send({
error: "A folder with this name already exists in this location",
code: "duplicateFolderName",
});
}
return reply.status(201).send({
message: "Folder checks succeeded.",
});
} catch (error: any) {
console.error("Error in checkFolder:", error);
return reply.status(400).send({ error: error.message });
}
}
async listFolders(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
}
const input = ListFoldersSchema.parse(request.query);
const { parentId, recursive: recursiveStr } = input;
const recursive = recursiveStr === "false" ? false : true;
let folders: any[];
if (recursive) {
folders = await prisma.folder.findMany({
where: { userId },
include: {
_count: {
select: {
files: true,
children: true,
},
},
},
orderBy: [{ name: "asc" }],
});
} else {
// Get only direct children of specified parent
const targetParentId = parentId === "null" || parentId === "" || !parentId ? null : parentId;
folders = await prisma.folder.findMany({
where: {
userId,
parentId: targetParentId,
},
include: {
_count: {
select: {
files: true,
children: true,
},
},
},
orderBy: [{ name: "asc" }],
});
}
const foldersResponse = await Promise.all(
folders.map(async (folder) => {
const totalSize = await this.folderService.calculateFolderSize(folder.id, userId);
return {
id: folder.id,
name: folder.name,
description: folder.description,
objectName: folder.objectName,
parentId: folder.parentId,
userId: folder.userId,
createdAt: folder.createdAt,
updatedAt: folder.updatedAt,
totalSize: totalSize.toString(),
_count: folder._count,
};
})
);
return reply.send({ folders: foldersResponse });
} catch (error: any) {
console.error("Error in listFolders:", error);
return reply.status(500).send({ error: error.message });
}
}
async updateFolder(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
const { id } = request.params as { id: string };
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({
error: "Unauthorized: a valid token is required to access this resource.",
});
}
const updateData = UpdateFolderSchema.parse(request.body);
const folderRecord = await prisma.folder.findUnique({ where: { id } });
if (!folderRecord) {
return reply.status(404).send({ error: "Folder not found." });
}
if (folderRecord.userId !== userId) {
return reply.status(403).send({ error: "Access denied." });
}
if (updateData.name && updateData.name !== folderRecord.name) {
const duplicateFolder = await prisma.folder.findFirst({
where: {
name: updateData.name,
parentId: folderRecord.parentId,
userId,
id: { not: id },
},
});
if (duplicateFolder) {
return reply.status(400).send({ error: "A folder with this name already exists in this location" });
}
}
const updatedFolder = await prisma.folder.update({
where: { id },
data: updateData,
include: {
_count: {
select: {
files: true,
children: true,
},
},
},
});
const totalSize = await this.folderService.calculateFolderSize(updatedFolder.id, userId);
const folderResponse = {
id: updatedFolder.id,
name: updatedFolder.name,
description: updatedFolder.description,
objectName: updatedFolder.objectName,
parentId: updatedFolder.parentId,
userId: updatedFolder.userId,
createdAt: updatedFolder.createdAt,
updatedAt: updatedFolder.updatedAt,
totalSize: totalSize.toString(),
_count: updatedFolder._count,
};
return reply.send({
folder: folderResponse,
message: "Folder updated successfully.",
});
} catch (error: any) {
console.error("Error in updateFolder:", error);
return reply.status(400).send({ error: error.message });
}
}
async moveFolder(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
}
const { id } = request.params as { id: string };
const body = request.body as any;
const input = {
parentId: body.parentId === undefined ? null : body.parentId,
};
const validatedInput = MoveFolderSchema.parse(input);
const existingFolder = await prisma.folder.findFirst({
where: { id, userId },
});
if (!existingFolder) {
return reply.status(404).send({ error: "Folder not found." });
}
if (validatedInput.parentId) {
const parentFolder = await prisma.folder.findFirst({
where: { id: validatedInput.parentId, userId },
});
if (!parentFolder) {
return reply.status(400).send({ error: "Parent folder not found or access denied" });
}
if (await this.isDescendantOf(validatedInput.parentId, id, userId)) {
return reply.status(400).send({ error: "Cannot move a folder into itself or its subfolders" });
}
}
const updatedFolder = await prisma.folder.update({
where: { id },
data: { parentId: validatedInput.parentId },
include: {
_count: {
select: {
files: true,
children: true,
},
},
},
});
const totalSize = await this.folderService.calculateFolderSize(updatedFolder.id, userId);
const folderResponse = {
id: updatedFolder.id,
name: updatedFolder.name,
description: updatedFolder.description,
objectName: updatedFolder.objectName,
parentId: updatedFolder.parentId,
userId: updatedFolder.userId,
createdAt: updatedFolder.createdAt,
updatedAt: updatedFolder.updatedAt,
totalSize: totalSize.toString(),
_count: updatedFolder._count,
};
return reply.send({
folder: folderResponse,
message: "Folder moved successfully.",
});
} catch (error: any) {
console.error("Error in moveFolder:", error);
const statusCode = error.message === "Folder not found" ? 404 : 400;
return reply.status(statusCode).send({ error: error.message });
}
}
async deleteFolder(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
const { id } = request.params as { id: string };
if (!id) {
return reply.status(400).send({ error: "The 'id' parameter is required." });
}
const folderRecord = await prisma.folder.findUnique({ where: { id } });
if (!folderRecord) {
return reply.status(404).send({ error: "Folder not found." });
}
const userId = (request as any).user?.userId;
if (folderRecord.userId !== userId) {
return reply.status(403).send({ error: "Access denied." });
}
await this.folderService.deleteObject(folderRecord.objectName);
await prisma.folder.delete({ where: { id } });
return reply.send({ message: "Folder deleted successfully." });
} catch (error) {
console.error("Error in deleteFolder:", error);
return reply.status(500).send({ error: "Internal server error." });
}
}
private async isDescendantOf(potentialDescendantId: string, ancestorId: string, userId: string): Promise<boolean> {
let currentId: string | null = potentialDescendantId;
while (currentId) {
if (currentId === ancestorId) {
return true;
}
const folder: { parentId: string | null } | null = await prisma.folder.findFirst({
where: { id: currentId, userId },
});
if (!folder) break;
currentId = folder.parentId;
}
return false;
}
}

View File

@@ -0,0 +1,56 @@
import { z } from "zod";
export const RegisterFolderSchema = z.object({
name: z.string().min(1, "O nome da pasta é obrigatório"),
description: z.string().optional(),
objectName: z.string().min(1, "O objectName é obrigatório"),
parentId: z.string().optional(),
});
export const UpdateFolderSchema = z.object({
name: z.string().optional(),
description: z.string().optional().nullable(),
});
export const MoveFolderSchema = z.object({
parentId: z.string().nullable(),
});
export const FolderResponseSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string().nullable(),
parentId: z.string().nullable(),
userId: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
totalSize: z
.bigint()
.transform((val) => val.toString())
.optional(),
_count: z
.object({
files: z.number(),
children: z.number(),
})
.optional(),
});
export const CheckFolderSchema = z.object({
name: z.string().min(1, "O nome da pasta é obrigatório"),
description: z.string().optional(),
objectName: z.string().min(1, "O objectName é obrigatório"),
parentId: z.string().optional(),
});
export const ListFoldersSchema = z.object({
parentId: z.string().optional(),
recursive: z.string().optional().default("true"),
});
export type RegisterFolderInput = z.infer<typeof RegisterFolderSchema>;
export type UpdateFolderInput = z.infer<typeof UpdateFolderSchema>;
export type MoveFolderInput = z.infer<typeof MoveFolderSchema>;
export type CheckFolderInput = z.infer<typeof CheckFolderSchema>;
export type ListFoldersInput = z.infer<typeof ListFoldersSchema>;
export type FolderResponse = z.infer<typeof FolderResponseSchema>;

View File

@@ -0,0 +1,245 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { FolderController } from "./controller";
import {
CheckFolderSchema,
FolderResponseSchema,
ListFoldersSchema,
MoveFolderSchema,
RegisterFolderSchema,
UpdateFolderSchema,
} from "./dto";
export async function folderRoutes(app: FastifyInstance) {
const folderController = new FolderController();
const preValidation = async (request: FastifyRequest, reply: FastifyReply) => {
try {
await request.jwtVerify();
} catch (err) {
console.error(err);
reply.status(401).send({ error: "Token inválido ou ausente." });
}
};
app.post(
"/folders",
{
schema: {
tags: ["Folder"],
operationId: "registerFolder",
summary: "Register Folder Metadata",
description: "Registers folder metadata in the database",
body: RegisterFolderSchema,
response: {
201: z.object({
folder: z.object({
id: z.string().describe("The folder ID"),
name: z.string().describe("The folder name"),
description: z.string().nullable().describe("The folder description"),
parentId: z.string().nullable().describe("The parent folder ID"),
userId: z.string().describe("The user ID"),
createdAt: z.date().describe("The folder creation date"),
updatedAt: z.date().describe("The folder last update date"),
totalSize: z.string().optional().describe("The total size of the folder"),
_count: z
.object({
files: z.number().describe("Number of files in folder"),
children: z.number().describe("Number of subfolders"),
})
.optional()
.describe("Count statistics"),
}),
message: z.string().describe("The folder registration message"),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
},
},
},
folderController.registerFolder.bind(folderController)
);
app.post(
"/folders/check",
{
preValidation,
schema: {
tags: ["Folder"],
operationId: "checkFolder",
summary: "Check Folder validity",
description: "Checks if the folder meets all requirements",
body: CheckFolderSchema,
response: {
201: z.object({
message: z.string().describe("The folder check success message"),
}),
400: z.object({
error: z.string().describe("Error message"),
code: z.string().optional().describe("Error code"),
details: z.string().optional().describe("Error details"),
}),
401: z.object({
error: z.string().describe("Error message"),
code: z.string().optional().describe("Error code"),
}),
},
},
},
folderController.checkFolder.bind(folderController)
);
app.get(
"/folders",
{
preValidation,
schema: {
tags: ["Folder"],
operationId: "listFolders",
summary: "List Folders",
description: "Lists user folders recursively by default, optionally filtered by folder",
querystring: ListFoldersSchema,
response: {
200: z.object({
folders: z.array(
z.object({
id: z.string().describe("The folder ID"),
name: z.string().describe("The folder name"),
description: z.string().nullable().describe("The folder description"),
parentId: z.string().nullable().describe("The parent folder ID"),
userId: z.string().describe("The user ID"),
createdAt: z.date().describe("The folder creation date"),
updatedAt: z.date().describe("The folder last update date"),
totalSize: z.string().optional().describe("The total size of the folder"),
_count: z
.object({
files: z.number().describe("Number of files in folder"),
children: z.number().describe("Number of subfolders"),
})
.optional()
.describe("Count statistics"),
})
),
}),
500: z.object({ error: z.string().describe("Error message") }),
},
},
},
folderController.listFolders.bind(folderController)
);
app.patch(
"/folders/:id",
{
preValidation,
schema: {
tags: ["Folder"],
operationId: "updateFolder",
summary: "Update Folder Metadata",
description: "Updates folder metadata in the database",
params: z.object({
id: z.string().min(1, "The folder id is required").describe("The folder ID"),
}),
body: UpdateFolderSchema,
response: {
200: z.object({
folder: z.object({
id: z.string().describe("The folder ID"),
name: z.string().describe("The folder name"),
description: z.string().nullable().describe("The folder description"),
parentId: z.string().nullable().describe("The parent folder ID"),
userId: z.string().describe("The user ID"),
createdAt: z.date().describe("The folder creation date"),
updatedAt: z.date().describe("The folder last update date"),
totalSize: z.string().optional().describe("The total size of the folder"),
_count: z
.object({
files: z.number().describe("Number of files in folder"),
children: z.number().describe("Number of subfolders"),
})
.optional()
.describe("Count statistics"),
}),
message: z.string().describe("Success message"),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
403: z.object({ error: z.string().describe("Error message") }),
404: z.object({ error: z.string().describe("Error message") }),
},
},
},
folderController.updateFolder.bind(folderController)
);
app.put(
"/folders/:id/move",
{
preValidation,
schema: {
tags: ["Folder"],
operationId: "moveFolder",
summary: "Move Folder",
description: "Moves a folder to a different parent folder",
params: z.object({
id: z.string().min(1, "The folder id is required").describe("The folder ID"),
}),
body: MoveFolderSchema,
response: {
200: z.object({
folder: z.object({
id: z.string().describe("The folder ID"),
name: z.string().describe("The folder name"),
description: z.string().nullable().describe("The folder description"),
parentId: z.string().nullable().describe("The parent folder ID"),
userId: z.string().describe("The user ID"),
createdAt: z.date().describe("The folder creation date"),
updatedAt: z.date().describe("The folder last update date"),
totalSize: z.string().optional().describe("The total size of the folder"),
_count: z
.object({
files: z.number().describe("Number of files in folder"),
children: z.number().describe("Number of subfolders"),
})
.optional()
.describe("Count statistics"),
}),
message: z.string().describe("Success message"),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
403: z.object({ error: z.string().describe("Error message") }),
404: z.object({ error: z.string().describe("Error message") }),
},
},
},
folderController.moveFolder.bind(folderController)
);
app.delete(
"/folders/:id",
{
preValidation,
schema: {
tags: ["Folder"],
operationId: "deleteFolder",
summary: "Delete Folder",
description: "Deletes a folder and all its contents",
params: z.object({
id: z.string().min(1, "The folder id is required").describe("The folder ID"),
}),
response: {
200: z.object({
message: z.string().describe("The folder deletion message"),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
404: z.object({ error: z.string().describe("Error message") }),
500: z.object({ error: z.string().describe("Error message") }),
},
},
},
folderController.deleteFolder.bind(folderController)
);
}

View File

@@ -0,0 +1,93 @@
import { isS3Enabled } from "../../config/storage.config";
import { FilesystemStorageProvider } from "../../providers/filesystem-storage.provider";
import { S3StorageProvider } from "../../providers/s3-storage.provider";
import { prisma } from "../../shared/prisma";
import { StorageProvider } from "../../types/storage";
export class FolderService {
private storageProvider: StorageProvider;
constructor() {
if (isS3Enabled) {
this.storageProvider = new S3StorageProvider();
} else {
this.storageProvider = FilesystemStorageProvider.getInstance();
}
}
async getPresignedPutUrl(objectName: string, expires: number): Promise<string> {
try {
return await this.storageProvider.getPresignedPutUrl(objectName, expires);
} catch (err) {
console.error("Erro no presignedPutObject:", err);
throw err;
}
}
async getPresignedGetUrl(objectName: string, expires: number, folderName?: string): Promise<string> {
try {
return await this.storageProvider.getPresignedGetUrl(objectName, expires, folderName);
} catch (err) {
console.error("Erro no presignedGetObject:", err);
throw err;
}
}
async deleteObject(objectName: string): Promise<void> {
try {
await this.storageProvider.deleteObject(objectName);
} catch (err) {
console.error("Erro no removeObject:", err);
throw err;
}
}
isFilesystemMode(): boolean {
return !isS3Enabled;
}
async getAllFilesInFolder(folderId: string, userId: string, basePath: string = ""): Promise<any[]> {
const files = await prisma.file.findMany({
where: { folderId, userId },
});
const subfolders = await prisma.folder.findMany({
where: { parentId: folderId, userId },
select: { id: true, name: true },
});
let allFiles = files.map((file: any) => ({
...file,
relativePath: basePath + file.name,
}));
for (const subfolder of subfolders) {
const subfolderPath = basePath + subfolder.name + "/";
const subfolderFiles = await this.getAllFilesInFolder(subfolder.id, userId, subfolderPath);
allFiles = [...allFiles, ...subfolderFiles];
}
return allFiles;
}
async calculateFolderSize(folderId: string, userId: string): Promise<bigint> {
const files = await prisma.file.findMany({
where: { folderId, userId },
select: { size: true },
});
const subfolders = await prisma.folder.findMany({
where: { parentId: folderId, userId },
select: { id: true },
});
let totalSize = files.reduce((sum, file) => sum + file.size, BigInt(0));
for (const subfolder of subfolders) {
const subfolderSize = await this.calculateFolderSize(subfolder.id, userId);
totalSize += subfolderSize;
}
return totalSize;
}
}

View File

@@ -2,7 +2,7 @@ import { FastifyReply, FastifyRequest } from "fastify";
import {
CreateShareSchema,
UpdateShareFilesSchema,
UpdateShareItemsSchema,
UpdateSharePasswordSchema,
UpdateShareRecipientsSchema,
UpdateShareSchema,
@@ -116,7 +116,7 @@ export class ShareController {
}
}
async addFiles(request: FastifyRequest, reply: FastifyReply) {
async addItems(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
const userId = (request as any).user?.userId;
@@ -125,9 +125,9 @@ export class ShareController {
}
const { shareId } = request.params as { shareId: string };
const { files } = UpdateShareFilesSchema.parse(request.body);
const { files, folders } = UpdateShareItemsSchema.parse(request.body);
const share = await this.shareService.addFilesToShare(shareId, userId, files);
const share = await this.shareService.addItemsToShare(shareId, userId, files || [], folders || []);
return reply.send({ share });
} catch (error: any) {
if (error.message === "Share not found") {
@@ -136,14 +136,14 @@ export class ShareController {
if (error.message === "Unauthorized to update this share") {
return reply.status(401).send({ error: error.message });
}
if (error.message.startsWith("Files not found:")) {
if (error.message.startsWith("Files not found:") || error.message.startsWith("Folders not found:")) {
return reply.status(404).send({ error: error.message });
}
return reply.status(400).send({ error: error.message });
}
}
async removeFiles(request: FastifyRequest, reply: FastifyReply) {
async removeItems(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
const userId = (request as any).user?.userId;
@@ -152,9 +152,9 @@ export class ShareController {
}
const { shareId } = request.params as { shareId: string };
const { files } = UpdateShareFilesSchema.parse(request.body);
const { files, folders } = UpdateShareItemsSchema.parse(request.body);
const share = await this.shareService.removeFilesFromShare(shareId, userId, files);
const share = await this.shareService.removeItemsFromShare(shareId, userId, files || [], folders || []);
return reply.send({ share });
} catch (error: any) {
if (error.message === "Share not found") {

View File

@@ -1,6 +1,7 @@
import { z } from "zod";
export const CreateShareSchema = z.object({
export const CreateShareSchema = z
.object({
name: z.string().optional().describe("The share name"),
description: z.string().optional().describe("The share description"),
expiration: z
@@ -9,11 +10,22 @@ export const CreateShareSchema = z.object({
message: "Data de expiração deve estar no formato ISO 8601 (ex: 2025-02-06T13:20:49Z)",
})
.optional(),
files: z.array(z.string()).describe("The file IDs"),
files: z.array(z.string()).optional().describe("The file IDs"),
folders: z.array(z.string()).optional().describe("The folder IDs"),
password: z.string().optional().describe("The share password"),
maxViews: z.number().optional().nullable().describe("The maximum number of views"),
recipients: z.array(z.string().email()).optional().describe("The recipient emails"),
});
})
.refine(
(data) => {
const hasFiles = data.files && data.files.length > 0;
const hasFolders = data.folders && data.folders.length > 0;
return hasFiles || hasFolders;
},
{
message: "At least one file or folder must be selected to create a share",
}
);
export const UpdateShareSchema = z.object({
id: z.string(),
@@ -55,10 +67,30 @@ export const ShareResponseSchema = z.object({
size: z.string().describe("The file size"),
objectName: z.string().describe("The file object name"),
userId: z.string().describe("The user ID"),
folderId: z.string().nullable().describe("The folder ID containing this file"),
createdAt: z.string().describe("The file creation date"),
updatedAt: z.string().describe("The file update date"),
})
),
folders: z.array(
z.object({
id: z.string().describe("The folder ID"),
name: z.string().describe("The folder name"),
description: z.string().nullable().describe("The folder description"),
objectName: z.string().describe("The folder object name"),
parentId: z.string().nullable().describe("The parent folder ID"),
userId: z.string().describe("The user ID"),
totalSize: z.string().nullable().describe("The total size of folder contents"),
createdAt: z.string().describe("The folder creation date"),
updatedAt: z.string().describe("The folder update date"),
_count: z
.object({
files: z.number().describe("Number of files in folder"),
children: z.number().describe("Number of subfolders"),
})
.optional(),
})
),
recipients: z.array(
z.object({
id: z.string().describe("The recipient ID"),
@@ -74,9 +106,21 @@ export const UpdateSharePasswordSchema = z.object({
password: z.string().nullable().describe("The new password. Send null to remove password"),
});
export const UpdateShareFilesSchema = z.object({
files: z.array(z.string().min(1, "File ID is required").describe("The file IDs")),
});
export const UpdateShareItemsSchema = z
.object({
files: z.array(z.string().min(1, "File ID is required").describe("The file IDs")).optional(),
folders: z.array(z.string().min(1, "Folder ID is required").describe("The folder IDs")).optional(),
})
.refine(
(data) => {
const hasFiles = data.files && data.files.length > 0;
const hasFolders = data.folders && data.folders.length > 0;
return hasFiles || hasFolders;
},
{
message: "At least one file or folder must be provided",
}
);
export const UpdateShareRecipientsSchema = z.object({
emails: z.array(z.string().email("Invalid email format").describe("The recipient emails")),

View File

@@ -9,30 +9,39 @@ export interface IShareRepository {
| (Share & {
security: ShareSecurity;
files: any[];
folders: any[];
recipients: { email: string }[];
})
| null
>;
findShareBySecurityId(securityId: string): Promise<(Share & { security: ShareSecurity; files: any[] }) | null>;
findShareBySecurityId(
securityId: string
): Promise<(Share & { security: ShareSecurity; files: any[]; folders: any[] }) | null>;
updateShare(id: string, data: Partial<Share>): Promise<Share>;
updateShareSecurity(id: string, data: Partial<ShareSecurity>): Promise<ShareSecurity>;
deleteShare(id: string): Promise<Share>;
incrementViews(id: string): Promise<Share>;
addFilesToShare(shareId: string, fileIds: string[]): Promise<void>;
removeFilesFromShare(shareId: string, fileIds: string[]): Promise<void>;
addFoldersToShare(shareId: string, folderIds: string[]): Promise<void>;
removeFoldersFromShare(shareId: string, folderIds: string[]): Promise<void>;
findFilesByIds(fileIds: string[]): Promise<any[]>;
findFoldersByIds(folderIds: string[]): Promise<any[]>;
addRecipients(shareId: string, emails: string[]): Promise<void>;
removeRecipients(shareId: string, emails: string[]): Promise<void>;
findSharesByUserId(userId: string): Promise<Share[]>;
findSharesByUserId(
userId: string
): Promise<(Share & { security: ShareSecurity; files: any[]; folders: any[]; recipients: any[]; alias: any })[]>;
}
export class PrismaShareRepository implements IShareRepository {
async createShare(
data: Omit<CreateShareInput, "password" | "maxViews"> & { securityId: string; creatorId: string }
): Promise<Share> {
const { files, recipients, expiration, ...shareData } = data;
const { files, folders, recipients, expiration, ...shareData } = data;
const validFiles = (files ?? []).filter((id) => id && id.trim().length > 0);
const validFolders = (folders ?? []).filter((id) => id && id.trim().length > 0);
const validRecipients = (recipients ?? []).filter((email) => email && email.trim().length > 0);
return prisma.share.create({
@@ -45,6 +54,12 @@ export class PrismaShareRepository implements IShareRepository {
connect: validFiles.map((id) => ({ id })),
}
: undefined,
folders:
validFolders.length > 0
? {
connect: validFolders.map((id) => ({ id })),
}
: undefined,
recipients:
validRecipients?.length > 0
? {
@@ -61,10 +76,28 @@ export class PrismaShareRepository implements IShareRepository {
return prisma.share.findUnique({
where: { id },
include: {
alias: true,
security: true,
files: true,
folders: {
select: {
id: true,
name: true,
description: true,
objectName: true,
parentId: true,
userId: true,
createdAt: true,
updatedAt: true,
_count: {
select: {
files: true,
children: true,
},
},
},
},
recipients: true,
alias: true,
},
});
}
@@ -75,6 +108,24 @@ export class PrismaShareRepository implements IShareRepository {
include: {
security: true,
files: true,
folders: {
select: {
id: true,
name: true,
description: true,
objectName: true,
parentId: true,
userId: true,
createdAt: true,
updatedAt: true,
_count: {
select: {
files: true,
children: true,
},
},
},
},
},
});
}
@@ -121,6 +172,17 @@ export class PrismaShareRepository implements IShareRepository {
});
}
async addFoldersToShare(shareId: string, folderIds: string[]): Promise<void> {
await prisma.share.update({
where: { id: shareId },
data: {
folders: {
connect: folderIds.map((id) => ({ id })),
},
},
});
}
async removeFilesFromShare(shareId: string, fileIds: string[]): Promise<void> {
await prisma.share.update({
where: { id: shareId },
@@ -132,6 +194,17 @@ export class PrismaShareRepository implements IShareRepository {
});
}
async removeFoldersFromShare(shareId: string, folderIds: string[]): Promise<void> {
await prisma.share.update({
where: { id: shareId },
data: {
folders: {
disconnect: folderIds.map((id) => ({ id })),
},
},
});
}
async findFilesByIds(fileIds: string[]): Promise<any[]> {
return prisma.file.findMany({
where: {
@@ -142,6 +215,16 @@ export class PrismaShareRepository implements IShareRepository {
});
}
async findFoldersByIds(folderIds: string[]): Promise<any[]> {
return prisma.folder.findMany({
where: {
id: {
in: folderIds,
},
},
});
}
async addRecipients(shareId: string, emails: string[]): Promise<void> {
await prisma.share.update({
where: { id: shareId },
@@ -178,6 +261,24 @@ export class PrismaShareRepository implements IShareRepository {
include: {
security: true,
files: true,
folders: {
select: {
id: true,
name: true,
description: true,
objectName: true,
parentId: true,
userId: true,
createdAt: true,
updatedAt: true,
_count: {
select: {
files: true,
children: true,
},
},
},
},
recipients: true,
alias: true,
},

View File

@@ -6,7 +6,7 @@ import {
CreateShareSchema,
ShareAliasResponseSchema,
ShareResponseSchema,
UpdateShareFilesSchema,
UpdateShareItemsSchema,
UpdateSharePasswordSchema,
UpdateShareRecipientsSchema,
UpdateShareSchema,
@@ -32,7 +32,7 @@ export async function shareRoutes(app: FastifyInstance) {
tags: ["Share"],
operationId: "createShare",
summary: "Create a new share",
description: "Create a new share",
description: "Create a new share with files and/or folders",
body: CreateShareSchema,
response: {
201: z.object({
@@ -164,17 +164,17 @@ export async function shareRoutes(app: FastifyInstance) {
);
app.post(
"/shares/:shareId/files",
"/shares/:shareId/items",
{
preValidation,
schema: {
tags: ["Share"],
operationId: "addFiles",
summary: "Add files to share",
operationId: "addItems",
summary: "Add files and/or folders to share",
params: z.object({
shareId: z.string().describe("The share ID"),
}),
body: UpdateShareFilesSchema,
body: UpdateShareItemsSchema,
response: {
200: z.object({
share: ShareResponseSchema,
@@ -185,21 +185,21 @@ export async function shareRoutes(app: FastifyInstance) {
},
},
},
shareController.addFiles.bind(shareController)
shareController.addItems.bind(shareController)
);
app.delete(
"/shares/:shareId/files",
"/shares/:shareId/items",
{
preValidation,
schema: {
tags: ["Share"],
operationId: "removeFiles",
summary: "Remove files from share",
operationId: "removeItems",
summary: "Remove files and/or folders from share",
params: z.object({
shareId: z.string().describe("The share ID"),
}),
body: UpdateShareFilesSchema,
body: UpdateShareItemsSchema,
response: {
200: z.object({
share: ShareResponseSchema,
@@ -210,7 +210,7 @@ export async function shareRoutes(app: FastifyInstance) {
},
},
},
shareController.removeFiles.bind(shareController)
shareController.removeItems.bind(shareController)
);
app.post(

View File

@@ -2,6 +2,7 @@ import bcrypt from "bcryptjs";
import { prisma } from "../../shared/prisma";
import { EmailService } from "../email/service";
import { FolderService } from "../folder/service";
import { UserService } from "../user/service";
import { CreateShareInput, ShareResponseSchema, UpdateShareInput } from "./dto";
import { IShareRepository, PrismaShareRepository } from "./repository";
@@ -11,8 +12,9 @@ export class ShareService {
private emailService = new EmailService();
private userService = new UserService();
private folderService = new FolderService();
private formatShareResponse(share: any) {
private async formatShareResponse(share: any) {
return {
...share,
createdAt: share.createdAt.toISOString(),
@@ -36,6 +38,20 @@ export class ShareService {
createdAt: file.createdAt.toISOString(),
updatedAt: file.updatedAt.toISOString(),
})) || [],
folders:
share.folders && share.folders.length > 0
? await Promise.all(
share.folders.map(async (folder: any) => {
const totalSize = await this.folderService.calculateFolderSize(folder.id, folder.userId);
return {
...folder,
totalSize: totalSize.toString(),
createdAt: folder.createdAt.toISOString(),
updatedAt: folder.updatedAt.toISOString(),
};
})
)
: [],
recipients:
share.recipients?.map((recipient: any) => ({
...recipient,
@@ -46,7 +62,37 @@ export class ShareService {
}
async createShare(data: CreateShareInput, userId: string) {
const { password, maxViews, ...shareData } = data;
const { password, maxViews, files, folders, ...shareData } = data;
if (files && files.length > 0) {
const existingFiles = await prisma.file.findMany({
where: {
id: { in: files },
userId: userId,
},
});
const notFoundFiles = files.filter((id) => !existingFiles.some((file) => file.id === id));
if (notFoundFiles.length > 0) {
throw new Error(`Files not found or access denied: ${notFoundFiles.join(", ")}`);
}
}
if (folders && folders.length > 0) {
const existingFolders = await prisma.folder.findMany({
where: {
id: { in: folders },
userId: userId,
},
});
const notFoundFolders = folders.filter((id) => !existingFolders.some((folder) => folder.id === id));
if (notFoundFolders.length > 0) {
throw new Error(`Folders not found or access denied: ${notFoundFolders.join(", ")}`);
}
}
if ((!files || files.length === 0) && (!folders || folders.length === 0)) {
throw new Error("At least one file or folder must be selected to create a share");
}
const security = await prisma.shareSecurity.create({
data: {
@@ -57,12 +103,14 @@ export class ShareService {
const share = await this.shareRepository.createShare({
...shareData,
files,
folders,
securityId: security.id,
creatorId: userId,
});
const shareWithRelations = await this.shareRepository.findShareById(share.id);
return ShareResponseSchema.parse(this.formatShareResponse(shareWithRelations));
return ShareResponseSchema.parse(await this.formatShareResponse(shareWithRelations));
}
async getShare(shareId: string, password?: string, userId?: string) {
@@ -73,7 +121,7 @@ export class ShareService {
}
if (userId && share.creatorId === userId) {
return ShareResponseSchema.parse(this.formatShareResponse(share));
return ShareResponseSchema.parse(await this.formatShareResponse(share));
}
if (share.expiration && new Date() > new Date(share.expiration)) {
@@ -98,7 +146,7 @@ export class ShareService {
await this.shareRepository.incrementViews(shareId);
const updatedShare = await this.shareRepository.findShareById(shareId);
return ShareResponseSchema.parse(this.formatShareResponse(updatedShare));
return ShareResponseSchema.parse(await this.formatShareResponse(updatedShare));
}
async updateShare(shareId: string, data: Omit<UpdateShareInput, "id">, userId: string) {
@@ -136,7 +184,7 @@ export class ShareService {
});
const shareWithRelations = await this.shareRepository.findShareById(shareId);
return this.formatShareResponse(shareWithRelations);
return await this.formatShareResponse(shareWithRelations);
}
async deleteShare(id: string) {
@@ -172,12 +220,12 @@ export class ShareService {
return deletedShare;
});
return ShareResponseSchema.parse(this.formatShareResponse(deleted));
return ShareResponseSchema.parse(await this.formatShareResponse(deleted));
}
async listUserShares(userId: string) {
const shares = await this.shareRepository.findSharesByUserId(userId);
return shares.map((share) => this.formatShareResponse(share));
return await Promise.all(shares.map(async (share) => await this.formatShareResponse(share)));
}
async updateSharePassword(shareId: string, userId: string, password: string | null) {
@@ -195,10 +243,10 @@ export class ShareService {
});
const updated = await this.shareRepository.findShareById(shareId);
return ShareResponseSchema.parse(this.formatShareResponse(updated));
return ShareResponseSchema.parse(await this.formatShareResponse(updated));
}
async addFilesToShare(shareId: string, userId: string, fileIds: string[]) {
async addItemsToShare(shareId: string, userId: string, fileIds: string[], folderIds: string[]) {
const share = await this.shareRepository.findShareById(shareId);
if (!share) {
throw new Error("Share not found");
@@ -208,6 +256,7 @@ export class ShareService {
throw new Error("Unauthorized to update this share");
}
if (fileIds.length > 0) {
const existingFiles = await this.shareRepository.findFilesByIds(fileIds);
const notFoundFiles = fileIds.filter((id) => !existingFiles.some((file) => file.id === id));
@@ -216,11 +265,24 @@ export class ShareService {
}
await this.shareRepository.addFilesToShare(shareId, fileIds);
const updated = await this.shareRepository.findShareById(shareId);
return ShareResponseSchema.parse(this.formatShareResponse(updated));
}
async removeFilesFromShare(shareId: string, userId: string, fileIds: string[]) {
if (folderIds.length > 0) {
const existingFolders = await this.shareRepository.findFoldersByIds(folderIds);
const notFoundFolders = folderIds.filter((id) => !existingFolders.some((folder) => folder.id === id));
if (notFoundFolders.length > 0) {
throw new Error(`Folders not found: ${notFoundFolders.join(", ")}`);
}
await this.shareRepository.addFoldersToShare(shareId, folderIds);
}
const updated = await this.shareRepository.findShareById(shareId);
return ShareResponseSchema.parse(await this.formatShareResponse(updated));
}
async removeItemsFromShare(shareId: string, userId: string, fileIds: string[], folderIds: string[]) {
const share = await this.shareRepository.findShareById(shareId);
if (!share) {
throw new Error("Share not found");
@@ -230,9 +292,16 @@ export class ShareService {
throw new Error("Unauthorized to update this share");
}
if (fileIds.length > 0) {
await this.shareRepository.removeFilesFromShare(shareId, fileIds);
}
if (folderIds.length > 0) {
await this.shareRepository.removeFoldersFromShare(shareId, folderIds);
}
const updated = await this.shareRepository.findShareById(shareId);
return ShareResponseSchema.parse(this.formatShareResponse(updated));
return ShareResponseSchema.parse(await this.formatShareResponse(updated));
}
async findShareById(id: string) {
@@ -255,7 +324,7 @@ export class ShareService {
await this.shareRepository.addRecipients(shareId, emails);
const updated = await this.shareRepository.findShareById(shareId);
return ShareResponseSchema.parse(this.formatShareResponse(updated));
return ShareResponseSchema.parse(await this.formatShareResponse(updated));
}
async removeRecipients(shareId: string, userId: string, emails: string[]) {
@@ -270,7 +339,7 @@ export class ShareService {
await this.shareRepository.removeRecipients(shareId, emails);
const updated = await this.shareRepository.findShareById(shareId);
return ShareResponseSchema.parse(this.formatShareResponse(updated));
return ShareResponseSchema.parse(await this.formatShareResponse(updated));
}
async createOrUpdateAlias(shareId: string, alias: string, userId: string) {
@@ -341,7 +410,6 @@ export class ShareService {
throw new Error("No recipients found for this share");
}
// Get sender information
let senderName = "Someone";
try {
const sender = await this.userService.getUserById(userId);

View File

@@ -14,6 +14,7 @@ import { fileRoutes } from "./modules/file/routes";
import { ChunkManager } from "./modules/filesystem/chunk-manager";
import { downloadQueueRoutes } from "./modules/filesystem/download-queue-routes";
import { filesystemRoutes } from "./modules/filesystem/routes";
import { folderRoutes } from "./modules/folder/routes";
import { healthRoutes } from "./modules/health/routes";
import { reverseShareRoutes } from "./modules/reverse-share/routes";
import { shareRoutes } from "./modules/share/routes";
@@ -75,6 +76,7 @@ async function startServer() {
app.register(twoFactorRoutes, { prefix: "/auth" });
app.register(userRoutes);
app.register(fileRoutes);
app.register(folderRoutes);
app.register(downloadQueueRoutes);
app.register(shareRoutes);
app.register(reverseShareRoutes);

View File

@@ -144,7 +144,13 @@
"update": "تحديث",
"click": "انقر على",
"creating": "جاري الإنشاء...",
"loadingSimple": "جاري التحميل..."
"loadingSimple": "جاري التحميل...",
"create": "إنشاء",
"deleting": "جاري الحذف...",
"move": "نقل",
"rename": "إعادة تسمية",
"search": "بحث",
"share": "مشاركة"
},
"createShare": {
"title": "إنشاء مشاركة",
@@ -160,7 +166,13 @@
"create": "إنشاء مشاركة",
"success": "تم إنشاء المشاركة بنجاح",
"error": "فشل في إنشاء المشاركة",
"namePlaceholder": "أدخل اسمًا لمشاركتك"
"namePlaceholder": "أدخل اسمًا لمشاركتك",
"nextSelectFiles": "التالي: اختيار الملفات",
"searchLabel": "بحث",
"tabs": {
"shareDetails": "تفاصيل المشاركة",
"selectFiles": "اختيار الملفات"
}
},
"customization": {
"breadcrumb": "التخصيص",
@@ -214,7 +226,9 @@
},
"deleteConfirmation": {
"filesToDelete": "الملفات المراد حذفها",
"sharesToDelete": "المشاركات التي سيتم حذفها"
"sharesToDelete": "المشاركات التي سيتم حذفها",
"foldersToDelete": "المجلدات المراد حذفها",
"itemsToDelete": "العناصر المراد حذفها"
},
"downloadQueue": {
"downloadQueued": "تم إضافة التنزيل إلى قائمة الانتظار: {fileName}",
@@ -322,7 +336,8 @@
"previewFile": "معاينة الملف",
"addToShare": "إضافة إلى المشاركة",
"removeFromShare": "إزالة من المشاركة",
"saveChanges": "حفظ التغييرات"
"saveChanges": "حفظ التغييرات",
"editFolder": "تحرير المجلد"
},
"files": {
"title": "جميع الملفات",
@@ -347,7 +362,18 @@
},
"totalFiles": "{count, plural, =0 {لا توجد ملفات} =1 {ملف واحد} other {# ملفات}}",
"bulkDeleteConfirmation": "هل أنت متأكد من رغبتك في حذف {count, plural, =1 {ملف واحد} other {# ملفات}}؟ لا يمكن التراجع عن هذا الإجراء.",
"bulkDeleteTitle": "حذف الملفات المحددة"
"bulkDeleteTitle": "حذف الملفات المحددة",
"actions": {
"open": "فتح",
"rename": "إعادة تسمية",
"delete": "حذف"
},
"empty": {
"title": "لا توجد ملفات أو مجلدات بعد",
"description": "ارفع ملفك الأول أو أنشئ مجلدًا للبدء"
},
"files": "ملفات",
"folders": "مجلدات"
},
"filesTable": {
"ariaLabel": "جدول الملفات",
@@ -377,6 +403,33 @@
"delete": "حذف المحدد"
}
},
"folderActions": {
"editFolder": "تحرير المجلد",
"folderName": "اسم المجلد",
"folderNamePlaceholder": "أدخل اسم المجلد",
"folderDescription": "الوصف",
"folderDescriptionPlaceholder": "أدخل وصف المجلد (اختياري)",
"createFolder": "إنشاء مجلد جديد",
"renameFolder": "إعادة تسمية المجلد",
"moveFolder": "نقل المجلد",
"shareFolder": "مشاركة المجلد",
"deleteFolder": "حذف المجلد",
"moveTo": "نقل إلى",
"selectDestination": "اختر مجلد الوجهة",
"rootFolder": "الجذر",
"folderCreated": "تم إنشاء المجلد بنجاح",
"folderRenamed": "تم إعادة تسمية المجلد بنجاح",
"folderMoved": "تم نقل المجلد بنجاح",
"folderDeleted": "تم حذف المجلد بنجاح",
"folderShared": "تم مشاركة المجلد بنجاح",
"createFolderError": "خطأ في إنشاء المجلد",
"renameFolderError": "خطأ في إعادة تسمية المجلد",
"moveFolderError": "خطأ في نقل المجلد",
"deleteFolderError": "خطأ في حذف المجلد",
"shareFolderError": "خطأ في مشاركة المجلد",
"deleteConfirmation": "هل أنت متأكد من أنك تريد حذف هذا المجلد؟",
"deleteWarning": "لا يمكن التراجع عن هذا الإجراء."
},
"footer": {
"poweredBy": "مدعوم من",
"kyanHomepage": "الصفحة الرئيسية لـ Kyantech"
@@ -478,6 +531,13 @@
"removeFailed": "فشل في حذف الشعار"
}
},
"moveItems": {
"itemsToMove": "العناصر المراد نقلها:",
"movingTo": "النقل إلى:",
"title": "نقل {count, plural, =1 {عنصر} other {عناصر}}",
"description": "نقل {count, plural, =1 {عنصر} other {عناصر}} إلى موقع جديد",
"success": "تم نقل {count} {count, plural, =1 {عنصر} other {عناصر}} بنجاح"
},
"navbar": {
"logoAlt": "شعار التطبيق",
"profileMenu": "قائمة الملف الشخصي",
@@ -1108,7 +1168,10 @@
},
"searchBar": {
"placeholder": "ابحث عن الملفات...",
"results": "تم العثور على {filtered} من {total} ملف"
"results": "تم العثور على {filtered} من {total} ملف",
"placeholderFolders": "البحث في المجلدات...",
"noResults": "لم يتم العثور على نتائج لـ \"{query}\"",
"placeholderFiles": "البحث في الملفات..."
},
"settings": {
"groups": {
@@ -1322,7 +1385,17 @@
"editError": "فشل في تحديث المشاركة",
"bulkDeleteConfirmation": "هل أنت متأكد من أنك تريد حذف {count, plural, =1 {مشاركة واحدة} other {# مشاركات}} محددة؟ لا يمكن التراجع عن هذا الإجراء.",
"bulkDeleteTitle": "حذف المشاركات المحددة",
"addDescriptionPlaceholder": "إضافة وصف..."
"addDescriptionPlaceholder": "إضافة وصف...",
"aliasLabel": "اسم مستعار للرابط",
"aliasPlaceholder": "أدخل اسمًا مستعارًا مخصصًا",
"copyLink": "نسخ الرابط",
"fileTitle": "مشاركة ملف",
"folderTitle": "مشاركة مجلد",
"generateLink": "إنشاء رابط",
"linkDescriptionFile": "إنشاء رابط مخصص لمشاركة الملف",
"linkDescriptionFolder": "إنشاء رابط مخصص لمشاركة المجلد",
"linkReady": "رابط المشاركة جاهز:",
"linkTitle": "إنشاء رابط"
},
"shareDetails": {
"title": "تفاصيل المشاركة",
@@ -1449,7 +1522,8 @@
"files": "ملفات",
"totalSize": "الحجم الإجمالي",
"creating": "جاري الإنشاء...",
"create": "إنشاء مشاركة"
"create": "إنشاء مشاركة",
"itemsToShare": "العناصر للمشاركة ({count} {count, plural, =1 {عنصر} other {عناصر}})"
},
"shareSecurity": {
"subtitle": "تكوين حماية كلمة المرور وخيارات الأمان لهذه المشاركة",
@@ -1554,7 +1628,8 @@
"download": "تنزيل محدد"
},
"selectAll": "تحديد الكل",
"selectShare": "تحديد المشاركة {shareName}"
"selectShare": "تحديد المشاركة {shareName}",
"folderCount": "مجلدات"
},
"storageUsage": {
"title": "استخدام التخزين",

View File

@@ -144,7 +144,13 @@
"update": "Aktualisieren",
"click": "Klicken Sie auf",
"creating": "Erstellen...",
"loadingSimple": "Lade..."
"loadingSimple": "Lade...",
"create": "Erstellen",
"deleting": "Lösche...",
"move": "Verschieben",
"rename": "Umbenennen",
"search": "Suchen",
"share": "Teilen"
},
"createShare": {
"title": "Freigabe Erstellen",
@@ -160,7 +166,13 @@
"create": "Freigabe Erstellen",
"success": "Freigabe erfolgreich erstellt",
"error": "Fehler beim Erstellen der Freigabe",
"namePlaceholder": "Geben Sie einen Namen für Ihre Freigabe ein"
"namePlaceholder": "Geben Sie einen Namen für Ihre Freigabe ein",
"nextSelectFiles": "Weiter: Dateien auswählen",
"searchLabel": "Suchen",
"tabs": {
"shareDetails": "Freigabe-Details",
"selectFiles": "Dateien auswählen"
}
},
"customization": {
"breadcrumb": "Anpassung",
@@ -214,7 +226,9 @@
},
"deleteConfirmation": {
"filesToDelete": "Zu löschende Dateien",
"sharesToDelete": "Freigaben, die gelöscht werden"
"sharesToDelete": "Freigaben, die gelöscht werden",
"foldersToDelete": "Zu löschende Ordner",
"itemsToDelete": "Zu löschende Elemente"
},
"downloadQueue": {
"downloadQueued": "Download in Warteschlange: {fileName}",
@@ -322,7 +336,8 @@
"previewFile": "Datei-Vorschau",
"addToShare": "Zur Freigabe hinzufügen",
"removeFromShare": "Aus Freigabe entfernen",
"saveChanges": "Änderungen Speichern"
"saveChanges": "Änderungen Speichern",
"editFolder": "Ordner bearbeiten"
},
"files": {
"title": "Alle Dateien",
@@ -347,7 +362,18 @@
"table": "Tabelle",
"grid": "Raster"
},
"totalFiles": "{count, plural, =0 {Keine Dateien} =1 {1 Datei} other {# Dateien}}"
"totalFiles": "{count, plural, =0 {Keine Dateien} =1 {1 Datei} other {# Dateien}}",
"actions": {
"open": "Öffnen",
"rename": "Umbenennen",
"delete": "Löschen"
},
"empty": {
"title": "Noch keine Dateien oder Ordner",
"description": "Laden Sie Ihre erste Datei hoch oder erstellen Sie einen Ordner, um zu beginnen"
},
"files": "Dateien",
"folders": "Ordner"
},
"filesTable": {
"ariaLabel": "Dateien-Tabelle",
@@ -377,6 +403,33 @@
"delete": "Ausgewählte Löschen"
}
},
"folderActions": {
"editFolder": "Ordner bearbeiten",
"folderName": "Ordnername",
"folderNamePlaceholder": "Ordnername eingeben",
"folderDescription": "Beschreibung",
"folderDescriptionPlaceholder": "Ordnerbeschreibung eingeben (optional)",
"createFolder": "Neuen Ordner erstellen",
"renameFolder": "Ordner umbenennen",
"moveFolder": "Ordner verschieben",
"shareFolder": "Ordner teilen",
"deleteFolder": "Ordner löschen",
"moveTo": "Verschieben nach",
"selectDestination": "Zielordner auswählen",
"rootFolder": "Stammordner",
"folderCreated": "Ordner erfolgreich erstellt",
"folderRenamed": "Ordner erfolgreich umbenannt",
"folderMoved": "Ordner erfolgreich verschoben",
"folderDeleted": "Ordner erfolgreich gelöscht",
"folderShared": "Ordner erfolgreich geteilt",
"createFolderError": "Fehler beim Erstellen des Ordners",
"renameFolderError": "Fehler beim Umbenennen des Ordners",
"moveFolderError": "Fehler beim Verschieben des Ordners",
"deleteFolderError": "Fehler beim Löschen des Ordners",
"shareFolderError": "Fehler beim Teilen des Ordners",
"deleteConfirmation": "Sind Sie sicher, dass Sie diesen Ordner löschen möchten?",
"deleteWarning": "Diese Aktion kann nicht rückgängig gemacht werden."
},
"footer": {
"poweredBy": "Angetrieben von",
"kyanHomepage": "Kyantech Homepage"
@@ -478,6 +531,13 @@
"removeFailed": "Fehler beim Entfernen des Logos"
}
},
"moveItems": {
"itemsToMove": "Zu verschiebende Elemente:",
"movingTo": "Verschieben nach:",
"title": "{count, plural, =1 {Element} other {Elemente}} verschieben",
"description": "{count, plural, =1 {Element} other {Elemente}} an einen neuen Ort verschieben",
"success": "Erfolgreich {count} {count, plural, =1 {Element} other {Elemente}} verschoben"
},
"navbar": {
"logoAlt": "Anwendungslogo",
"profileMenu": "Profilmenü",
@@ -1106,7 +1166,10 @@
},
"searchBar": {
"placeholder": "Dateien suchen...",
"results": "Gefunden {filtered} von {total} Dateien"
"results": "Gefunden {filtered} von {total} Dateien",
"placeholderFolders": "Ordner durchsuchen...",
"noResults": "Keine Ergebnisse für \"{query}\" gefunden",
"placeholderFiles": "Dateien suchen..."
},
"settings": {
"groups": {
@@ -1320,7 +1383,17 @@
"editSuccess": "Freigabe erfolgreich aktualisiert",
"editError": "Fehler beim Aktualisieren der Freigabe",
"bulkDeleteConfirmation": "Sind Sie sicher, dass Sie {count, plural, =1 {1 Freigabe} other {# Freigaben}} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"bulkDeleteTitle": "Ausgewählte Freigaben löschen"
"bulkDeleteTitle": "Ausgewählte Freigaben löschen",
"aliasLabel": "Link-Alias",
"aliasPlaceholder": "Benutzerdefinierten Alias eingeben",
"copyLink": "Link kopieren",
"fileTitle": "Datei teilen",
"folderTitle": "Ordner teilen",
"generateLink": "Link generieren",
"linkDescriptionFile": "Erstellen Sie einen benutzerdefinierten Link zum Teilen der Datei",
"linkDescriptionFolder": "Erstellen Sie einen benutzerdefinierten Link zum Teilen des Ordners",
"linkReady": "Ihr Freigabe-Link ist bereit:",
"linkTitle": "Link generieren"
},
"shareDetails": {
"title": "Freigabe-Details",
@@ -1447,7 +1520,8 @@
"files": "Dateien",
"totalSize": "Gesamtgröße",
"creating": "Erstellen...",
"create": "Freigabe Erstellen"
"create": "Freigabe Erstellen",
"itemsToShare": "Zu teilende Elemente ({count} {count, plural, =1 {Element} other {Elemente}})"
},
"shareSecurity": {
"subtitle": "Passwortschutz und Sicherheitsoptionen für diese Freigabe konfigurieren",
@@ -1552,7 +1626,8 @@
"download": "Download ausgewählt"
},
"selectAll": "Alle auswählen",
"selectShare": "Freigabe {shareName} auswählen"
"selectShare": "Freigabe {shareName} auswählen",
"folderCount": "Ordner"
},
"storageUsage": {
"title": "Speichernutzung",

View File

@@ -136,6 +136,7 @@
"update": "Update",
"updating": "Updating...",
"delete": "Delete",
"deleting": "Deleting...",
"close": "Close",
"download": "Download",
"unexpectedError": "An unexpected error occurred. Please try again.",
@@ -144,7 +145,12 @@
"dashboard": "Dashboard",
"back": "Back",
"click": "Click to",
"creating": "Creating..."
"creating": "Creating...",
"create": "Create",
"rename": "Rename",
"move": "Move",
"share": "Share",
"search": "Search"
},
"createShare": {
"title": "Create Share",
@@ -160,7 +166,13 @@
"passwordLabel": "Password",
"create": "Create Share",
"success": "Share created successfully",
"error": "Failed to create share"
"error": "Failed to create share",
"tabs": {
"shareDetails": "Share Details",
"selectFiles": "Select Files"
},
"nextSelectFiles": "Next: Select Files",
"searchLabel": "Search"
},
"customization": {
"breadcrumb": "Customization",
@@ -214,6 +226,8 @@
},
"deleteConfirmation": {
"filesToDelete": "Files to be deleted",
"foldersToDelete": "Folders to be deleted",
"itemsToDelete": "Items to be deleted",
"sharesToDelete": "Shares to be deleted"
},
"downloadQueue": {
@@ -319,6 +333,7 @@
"fileCount": "{count, plural, =1 {file} other {files}}",
"filesSelected": "{count, plural, =0 {No files selected} =1 {1 file selected} other {# files selected}}",
"editFile": "Edit file",
"editFolder": "Edit folder",
"previewFile": "Preview file",
"addToShare": "Add to share",
"removeFromShare": "Remove from share",
@@ -339,11 +354,22 @@
"bulkDownloadSuccess": "Files download started successfully",
"bulkDownloadError": "Error creating ZIP file",
"bulkDownloadFileError": "Error downloading file {fileName}",
"bulkDeleteSuccess": "{count, plural, =1 {1 file deleted successfully} other {# files deleted successfully}}",
"bulkDeleteError": "Error deleting selected files",
"bulkDeleteTitle": "Delete Selected Files",
"bulkDeleteConfirmation": "Are you sure you want to delete {count, plural, =1 {1 file} other {# files}}? This action cannot be undone.",
"bulkDeleteSuccess": "{count, plural, =1 {1 item deleted successfully} other {# items deleted successfully}}",
"bulkDeleteError": "Error deleting selected items",
"bulkDeleteTitle": "Delete Selected Items",
"bulkDeleteConfirmation": "Are you sure you want to delete {count, plural, =1 {1 item} other {# items}}? This action cannot be undone.",
"totalFiles": "{count, plural, =0 {No files} =1 {1 file} other {# files}}",
"empty": {
"title": "No files or folders yet",
"description": "Upload your first file or create a folder to get started"
},
"files": "files",
"folders": "folders",
"actions": {
"open": "Open",
"rename": "Rename",
"delete": "Delete"
},
"viewMode": {
"table": "Table",
"grid": "Grid"
@@ -377,6 +403,33 @@
"delete": "Delete Selected"
}
},
"folderActions": {
"editFolder": "Edit Folder",
"folderName": "Folder Name",
"folderNamePlaceholder": "Enter folder name",
"folderDescription": "Description",
"folderDescriptionPlaceholder": "Enter folder description (optional)",
"createFolder": "Create Folder",
"renameFolder": "Rename Folder",
"moveFolder": "Move Folder",
"shareFolder": "Share Folder",
"deleteFolder": "Delete Folder",
"moveTo": "Move to",
"selectDestination": "Select destination folder",
"rootFolder": "Root",
"folderCreated": "Folder created successfully",
"folderRenamed": "Folder renamed successfully",
"folderMoved": "Folder moved successfully",
"folderDeleted": "Folder deleted successfully",
"folderShared": "Folder shared successfully",
"createFolderError": "Error creating folder",
"renameFolderError": "Error renaming folder",
"moveFolderError": "Error moving folder",
"deleteFolderError": "Error deleting folder",
"shareFolderError": "Error sharing folder",
"deleteConfirmation": "Are you sure you want to delete this folder?",
"deleteWarning": "This action cannot be undone."
},
"footer": {
"poweredBy": "Powered by",
"kyanHomepage": "Kyantech homepage"
@@ -478,6 +531,13 @@
"removeFailed": "Failed to remove logo"
}
},
"moveItems": {
"itemsToMove": "Items to move:",
"movingTo": "Moving to:",
"title": "Move {count, plural, =1 {Item} other {Items}}",
"description": "Move {count, plural, =1 {item} other {items}} to a new location",
"success": "Successfully moved {count} {count, plural, =1 {item} other {items}}"
},
"navbar": {
"logoAlt": "App Logo",
"profileMenu": "Profile Menu",
@@ -1103,8 +1163,11 @@
}
},
"searchBar": {
"placeholder": "Search files...",
"results": "Found {filtered} of {total} files"
"placeholder": "Search files and folders...",
"placeholderFiles": "Search files...",
"placeholderFolders": "Search folders...",
"results": "Showing {filtered} of {total} items",
"noResults": "No results found for \"{query}\""
},
"settings": {
"groups": {
@@ -1297,20 +1360,20 @@
"pageTitle": "Share"
},
"shareActions": {
"fileTitle": "Share File",
"folderTitle": "Share Folder",
"linkTitle": "Generate Link",
"linkDescriptionFile": "Generate a custom link to share the file",
"linkDescriptionFolder": "Generate a custom link to share the folder",
"aliasLabel": "Link Alias",
"aliasPlaceholder": "Enter custom alias",
"linkReady": "Your share link is ready:",
"generateLink": "Generate Link",
"copyLink": "Copy Link",
"deleteTitle": "Delete Share",
"deleteConfirmation": "Are you sure you want to delete this share? This action cannot be undone.",
"addDescriptionPlaceholder": "Add description...",
"editTitle": "Edit Share",
"nameLabel": "Share Name",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Enter a description (optional)",
"expirationLabel": "Expiration Date",
"expirationPlaceholder": "MM/DD/YYYY HH:MM",
"maxViewsLabel": "Max Views",
"maxViewsPlaceholder": "Leave empty for unlimited",
"passwordProtection": "Password Protected",
"passwordLabel": "Password",
"passwordPlaceholder": "Enter password",
"newPasswordLabel": "New Password (leave empty to keep current)",
"newPasswordPlaceholder": "Enter new password",
"manageFilesTitle": "Manage Files",
@@ -1381,28 +1444,6 @@
"noExpiration": "This share will never expire and will remain accessible indefinitely."
}
},
"shareFile": {
"title": "Share File",
"linkTitle": "Generate Link",
"nameLabel": "Share Name",
"namePlaceholder": "Enter share name",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Enter a description (optional)",
"expirationLabel": "Expiration Date",
"expirationPlaceholder": "MM/DD/YYYY HH:MM",
"maxViewsLabel": "Maximum Views",
"maxViewsPlaceholder": "Leave empty for unlimited",
"passwordProtection": "Password Protected",
"passwordLabel": "Password",
"passwordPlaceholder": "Enter password",
"linkDescription": "Generate a custom link to share the file",
"aliasLabel": "Link Alias",
"aliasPlaceholder": "Enter custom alias",
"linkReady": "Your share link is ready:",
"createShare": "Create Share",
"generateLink": "Generate Link",
"copyLink": "Copy Link"
},
"shareManager": {
"deleteSuccess": "Share deleted successfully",
"deleteError": "Failed to delete share",
@@ -1440,11 +1481,12 @@
"shareNamePlaceholder": "Enter share name",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Enter a description (optional)",
"filesToShare": "Files to share",
"filesToShare": "Files to Share",
"files": "files",
"totalSize": "Total size",
"creating": "Creating...",
"create": "Create Share"
"creating": "Creating share...",
"create": "Create Share",
"itemsToShare": "Items to share ({count} {count, plural, =1 {item} other {items}})"
},
"shareSecurity": {
"title": "Share Security Settings",
@@ -1527,6 +1569,7 @@
"public": "Public"
},
"filesCount": "files",
"folderCount": "folders",
"recipientsCount": "recipients",
"actions": {
"menu": "Share actions menu",

View File

@@ -144,7 +144,13 @@
"update": "Actualizar",
"click": "Haga clic para",
"creating": "Creando...",
"loadingSimple": "Cargando..."
"loadingSimple": "Cargando...",
"create": "Crear",
"deleting": "Eliminando...",
"move": "Mover",
"rename": "Renombrar",
"search": "Buscar",
"share": "Compartir"
},
"createShare": {
"title": "Crear Compartir",
@@ -160,7 +166,13 @@
"create": "Crear Compartir",
"success": "Compartir creado exitosamente",
"error": "Error al crear compartir",
"namePlaceholder": "Ingrese un nombre para su compartir"
"namePlaceholder": "Ingrese un nombre para su compartir",
"nextSelectFiles": "Siguiente: Seleccionar archivos",
"searchLabel": "Buscar",
"tabs": {
"shareDetails": "Detalles del compartido",
"selectFiles": "Seleccionar archivos"
}
},
"customization": {
"breadcrumb": "Personalización",
@@ -214,7 +226,9 @@
},
"deleteConfirmation": {
"filesToDelete": "Archivos que serán eliminados",
"sharesToDelete": "Compartidos que serán eliminados"
"sharesToDelete": "Compartidos que serán eliminados",
"foldersToDelete": "Carpetas a eliminar",
"itemsToDelete": "Elementos a eliminar"
},
"downloadQueue": {
"downloadQueued": "Descarga en cola: {fileName}",
@@ -322,7 +336,8 @@
"previewFile": "Vista previa del archivo",
"addToShare": "Agregar a compartición",
"removeFromShare": "Quitar de compartición",
"saveChanges": "Guardar Cambios"
"saveChanges": "Guardar Cambios",
"editFolder": "Editar carpeta"
},
"files": {
"title": "Todos los Archivos",
@@ -347,7 +362,18 @@
"table": "Tabla",
"grid": "Cuadrícula"
},
"totalFiles": "{count, plural, =0 {Sin archivos} =1 {1 archivo} other {# archivos}}"
"totalFiles": "{count, plural, =0 {Sin archivos} =1 {1 archivo} other {# archivos}}",
"actions": {
"open": "Abrir",
"rename": "Renombrar",
"delete": "Eliminar"
},
"empty": {
"title": "Aún no hay archivos o carpetas",
"description": "Suba su primer archivo o cree una carpeta para comenzar"
},
"files": "archivos",
"folders": "carpetas"
},
"filesTable": {
"ariaLabel": "Tabla de archivos",
@@ -377,6 +403,33 @@
"delete": "Eliminar Seleccionados"
}
},
"folderActions": {
"editFolder": "Editar carpeta",
"folderName": "Nombre de carpeta",
"folderNamePlaceholder": "Ingrese nombre de carpeta",
"folderDescription": "Descripción",
"folderDescriptionPlaceholder": "Ingrese descripción de carpeta (opcional)",
"createFolder": "Crear nueva carpeta",
"renameFolder": "Renombrar carpeta",
"moveFolder": "Mover carpeta",
"shareFolder": "Compartir carpeta",
"deleteFolder": "Eliminar carpeta",
"moveTo": "Mover a",
"selectDestination": "Seleccionar carpeta destino",
"rootFolder": "Raíz",
"folderCreated": "Carpeta creada exitosamente",
"folderRenamed": "Carpeta renombrada exitosamente",
"folderMoved": "Carpeta movida exitosamente",
"folderDeleted": "Carpeta eliminada exitosamente",
"folderShared": "Carpeta compartida exitosamente",
"createFolderError": "Error al crear carpeta",
"renameFolderError": "Error al renombrar carpeta",
"moveFolderError": "Error al mover carpeta",
"deleteFolderError": "Error al eliminar carpeta",
"shareFolderError": "Error al compartir carpeta",
"deleteConfirmation": "¿Está seguro de que desea eliminar esta carpeta?",
"deleteWarning": "Esta acción no se puede deshacer."
},
"footer": {
"poweredBy": "Desarrollado por",
"kyanHomepage": "Página principal de Kyantech"
@@ -478,6 +531,13 @@
"removeFailed": "Error al eliminar el logo"
}
},
"moveItems": {
"itemsToMove": "Elementos a mover:",
"movingTo": "Moviendo a:",
"title": "Mover {count, plural, =1 {elemento} other {elementos}}",
"description": "Mover {count, plural, =1 {elemento} other {elementos}} a una nueva ubicación",
"success": "Se movieron exitosamente {count} {count, plural, =1 {elemento} other {elementos}}"
},
"navbar": {
"logoAlt": "Logo de la aplicación",
"profileMenu": "Menú de perfil",
@@ -1106,7 +1166,10 @@
},
"searchBar": {
"placeholder": "Buscar archivos...",
"results": "Se encontraron {filtered} de {total} archivos"
"results": "Se encontraron {filtered} de {total} archivos",
"placeholderFolders": "Buscar carpetas...",
"noResults": "No se encontraron resultados para \"{query}\"",
"placeholderFiles": "Buscar archivos..."
},
"settings": {
"groups": {
@@ -1320,7 +1383,17 @@
"editSuccess": "Compartir actualizado exitosamente",
"editError": "Error al actualizar compartir",
"bulkDeleteConfirmation": "¿Estás seguro de que quieres eliminar {count, plural, =1 {1 compartido} other {# compartidos}}? Esta acción no se puede deshacer.",
"bulkDeleteTitle": "Eliminar Compartidos Seleccionados"
"bulkDeleteTitle": "Eliminar Compartidos Seleccionados",
"aliasLabel": "Alias del enlace",
"aliasPlaceholder": "Ingrese alias personalizado",
"copyLink": "Copiar enlace",
"fileTitle": "Compartir archivo",
"folderTitle": "Compartir carpeta",
"generateLink": "Generar enlace",
"linkDescriptionFile": "Genere un enlace personalizado para compartir el archivo",
"linkDescriptionFolder": "Genere un enlace personalizado para compartir la carpeta",
"linkReady": "Su enlace de compartición está listo:",
"linkTitle": "Generar enlace"
},
"shareDetails": {
"title": "Detalles del Compartir",
@@ -1447,7 +1520,8 @@
"files": "archivos",
"totalSize": "Tamaño total",
"creating": "Creando...",
"create": "Crear Compartir"
"create": "Crear Compartir",
"itemsToShare": "Elementos a compartir ({count} {count, plural, =1 {elemento} other {elementos}})"
},
"shareSecurity": {
"subtitle": "Configurar protección por contraseña y opciones de seguridad para este compartir",
@@ -1530,6 +1604,7 @@
"public": "Público"
},
"filesCount": "archivos",
"folderCount": "carpetas",
"recipientsCount": "destinatarios",
"actions": {
"menu": "Menú de acciones de compartir",

View File

@@ -144,7 +144,13 @@
"update": "Mettre à jour",
"click": "Clique para",
"creating": "Criando...",
"loadingSimple": "Chargement..."
"loadingSimple": "Chargement...",
"create": "Créer",
"deleting": "Suppression...",
"move": "Déplacer",
"rename": "Renommer",
"search": "Rechercher",
"share": "Partager"
},
"createShare": {
"title": "Créer un Partage",
@@ -160,7 +166,13 @@
"create": "Créer un Partage",
"success": "Partage créé avec succès",
"error": "Échec de la création du partage",
"namePlaceholder": "Entrez un nom pour votre partage"
"namePlaceholder": "Entrez un nom pour votre partage",
"nextSelectFiles": "Suivant : Sélectionner les fichiers",
"searchLabel": "Rechercher",
"tabs": {
"shareDetails": "Détails du partage",
"selectFiles": "Sélectionner les fichiers"
}
},
"customization": {
"breadcrumb": "Personnalisation",
@@ -214,7 +226,9 @@
},
"deleteConfirmation": {
"filesToDelete": "Fichiers à supprimer",
"sharesToDelete": "Partages qui seront supprimés"
"sharesToDelete": "Partages qui seront supprimés",
"foldersToDelete": "Dossiers à supprimer",
"itemsToDelete": "Éléments à supprimer"
},
"downloadQueue": {
"downloadQueued": "Téléchargement en file d'attente : {fileName}",
@@ -322,7 +336,8 @@
"previewFile": "Aperçu du fichier",
"addToShare": "Ajouter au partage",
"removeFromShare": "Retirer du partage",
"saveChanges": "Sauvegarder les Modifications"
"saveChanges": "Sauvegarder les Modifications",
"editFolder": "Modifier le dossier"
},
"files": {
"title": "Tous les Fichiers",
@@ -347,7 +362,18 @@
"table": "Tableau",
"grid": "Grille"
},
"totalFiles": "{count, plural, =0 {Aucun fichier} =1 {1 fichier} other {# fichiers}}"
"totalFiles": "{count, plural, =0 {Aucun fichier} =1 {1 fichier} other {# fichiers}}",
"actions": {
"open": "Ouvrir",
"rename": "Renommer",
"delete": "Supprimer"
},
"empty": {
"title": "Aucun fichier ou dossier pour le moment",
"description": "Téléchargez votre premier fichier ou créez un dossier pour commencer"
},
"files": "fichiers",
"folders": "dossiers"
},
"filesTable": {
"ariaLabel": "Tableau des fichiers",
@@ -377,6 +403,33 @@
"delete": "Supprimer les Sélectionnés"
}
},
"folderActions": {
"editFolder": "Modifier le dossier",
"folderName": "Nom du dossier",
"folderNamePlaceholder": "Entrez le nom du dossier",
"folderDescription": "Description",
"folderDescriptionPlaceholder": "Entrez la description du dossier (facultatif)",
"createFolder": "Créer un nouveau dossier",
"renameFolder": "Renommer le dossier",
"moveFolder": "Déplacer le dossier",
"shareFolder": "Partager le dossier",
"deleteFolder": "Supprimer le dossier",
"moveTo": "Déplacer vers",
"selectDestination": "Sélectionner le dossier de destination",
"rootFolder": "Racine",
"folderCreated": "Dossier créé avec succès",
"folderRenamed": "Dossier renommé avec succès",
"folderMoved": "Dossier déplacé avec succès",
"folderDeleted": "Dossier supprimé avec succès",
"folderShared": "Dossier partagé avec succès",
"createFolderError": "Erreur lors de la création du dossier",
"renameFolderError": "Erreur lors du renommage du dossier",
"moveFolderError": "Erreur lors du déplacement du dossier",
"deleteFolderError": "Erreur lors de la suppression du dossier",
"shareFolderError": "Erreur lors du partage du dossier",
"deleteConfirmation": "Êtes-vous sûr de vouloir supprimer ce dossier ?",
"deleteWarning": "Cette action ne peut pas être annulée."
},
"footer": {
"poweredBy": "Propulsé par",
"kyanHomepage": "Page d'accueil de Kyantech"
@@ -478,6 +531,13 @@
"removeFailed": "Échec de la suppression du logo"
}
},
"moveItems": {
"itemsToMove": "Éléments à déplacer :",
"movingTo": "Déplacement vers :",
"title": "Déplacer {count, plural, =1 {élément} other {éléments}}",
"description": "Déplacer {count, plural, =1 {élément} other {éléments}} vers un nouvel emplacement",
"success": "{count} {count, plural, =1 {élément déplacé} other {éléments déplacés}} avec succès"
},
"navbar": {
"logoAlt": "Logo de l'Application",
"profileMenu": "Menu du Profil",
@@ -1106,7 +1166,10 @@
},
"searchBar": {
"placeholder": "Rechercher des fichiers...",
"results": "Trouvé {filtered} sur {total} fichiers"
"results": "Trouvé {filtered} sur {total} fichiers",
"placeholderFolders": "Rechercher des dossiers...",
"noResults": "Aucun résultat trouvé pour \"{query}\"",
"placeholderFiles": "Rechercher des fichiers..."
},
"settings": {
"title": "Paramètres",
@@ -1320,7 +1383,17 @@
"editSuccess": "Partage mis à jour avec succès",
"editError": "Échec de la mise à jour du partage",
"bulkDeleteConfirmation": "Êtes-vous sûr de vouloir supprimer {count, plural, =1 {1 partage} other {# partages}} ? Cette action ne peut pas être annulée.",
"bulkDeleteTitle": "Supprimer les Partages Sélectionnés"
"bulkDeleteTitle": "Supprimer les Partages Sélectionnés",
"aliasLabel": "Alias du lien",
"aliasPlaceholder": "Entrez un alias personnalisé",
"copyLink": "Copier le lien",
"fileTitle": "Partager le fichier",
"folderTitle": "Partager le dossier",
"generateLink": "Générer un lien",
"linkDescriptionFile": "Générez un lien personnalisé pour partager le fichier",
"linkDescriptionFolder": "Générez un lien personnalisé pour partager le dossier",
"linkReady": "Votre lien de partage est prêt :",
"linkTitle": "Générer un lien"
},
"shareDetails": {
"title": "Détails du Partage",
@@ -1447,7 +1520,8 @@
"files": "fichiers",
"totalSize": "Taille totale",
"creating": "Création...",
"create": "Créer un Partage"
"create": "Créer un Partage",
"itemsToShare": "Éléments à partager ({count} {count, plural, =1 {élément} other {éléments}})"
},
"shareSecurity": {
"subtitle": "Configurer la protection par mot de passe et les options de sécurité pour ce partage",
@@ -1552,7 +1626,8 @@
"download": "Télécharger sélectionné"
},
"selectAll": "Tout sélectionner",
"selectShare": "Sélectionner le partage {shareName}"
"selectShare": "Sélectionner le partage {shareName}",
"folderCount": "dossiers"
},
"storageUsage": {
"title": "Utilisation du Stockage",

View File

@@ -144,7 +144,13 @@
"update": "अपडेट करें",
"click": "क्लिक करें",
"creating": "बना रहा है...",
"loadingSimple": "लोड हो रहा है..."
"loadingSimple": "लोड हो रहा है...",
"create": "बनाएं",
"deleting": "हटा रहे हैं...",
"move": "स्थानांतरित करें",
"rename": "नाम बदलें",
"search": "खोजें",
"share": "साझा करें"
},
"createShare": {
"title": "साझाकरण बनाएं",
@@ -160,7 +166,13 @@
"create": "साझाकरण बनाएं",
"success": "साझाकरण सफलतापूर्वक बनाया गया",
"error": "साझाकरण बनाने में विफल",
"namePlaceholder": "अपने साझाकरण के लिए एक नाम दर्ज करें"
"namePlaceholder": "अपने साझाकरण के लिए एक नाम दर्ज करें",
"nextSelectFiles": "आगे: फ़ाइलें चुनें",
"searchLabel": "खोजें",
"tabs": {
"shareDetails": "साझाकरण विवरण",
"selectFiles": "फ़ाइलें चुनें"
}
},
"customization": {
"breadcrumb": "अनुकूलन",
@@ -214,7 +226,9 @@
},
"deleteConfirmation": {
"filesToDelete": "हटाई जाने वाली फाइलें",
"sharesToDelete": "साझाकरण जो हटाए जाएंगे"
"sharesToDelete": "साझाकरण जो हटाए जाएंगे",
"foldersToDelete": "हटाए जाने वाले फ़ोल्डर",
"itemsToDelete": "हटाए जाने वाले आइटम"
},
"downloadQueue": {
"downloadQueued": "डाउनलोड कतार में: {fileName}",
@@ -322,7 +336,8 @@
"previewFile": "फाइल पूर्वावलोकन",
"addToShare": "साझाकरण में जोड़ें",
"removeFromShare": "साझाकरण से हटाएं",
"saveChanges": "परिवर्तन सहेजें"
"saveChanges": "परिवर्तन सहेजें",
"editFolder": "फ़ोल्डर संपादित करें"
},
"files": {
"title": "सभी फाइलें",
@@ -347,7 +362,18 @@
},
"totalFiles": "{count, plural, =0 {कोई फ़ाइल नहीं} =1 {1 फ़ाइल} other {# फ़ाइलें}}",
"bulkDeleteConfirmation": "क्या आप वास्तव में {count, plural, =1 {1 फाइल} other {# फाइलों}} को हटाना चाहते हैं? यह क्रिया पूर्ववत नहीं की जा सकती।",
"bulkDeleteTitle": "चयनित फाइलों को हटाएं"
"bulkDeleteTitle": "चयनित फाइलों को हटाएं",
"actions": {
"open": "खोलें",
"rename": "नाम बदलें",
"delete": "हटाएं"
},
"empty": {
"title": "अभी तक कोई फ़ाइल या फ़ोल्डर नहीं",
"description": "आरंभ करने के लिए अपनी पहली फ़ाइल अपलोड करें या फ़ोल्डर बनाएं"
},
"files": "फ़ाइलें",
"folders": "फ़ोल्डर"
},
"filesTable": {
"ariaLabel": "फाइल तालिका",
@@ -377,6 +403,33 @@
"delete": "चयनित हटाएं"
}
},
"folderActions": {
"editFolder": "फ़ोल्डर संपादित करें",
"folderName": "फ़ोल्डर नाम",
"folderNamePlaceholder": "फ़ोल्डर नाम दर्ज करें",
"folderDescription": "विवरण",
"folderDescriptionPlaceholder": "फ़ोल्डर विवरण दर्ज करें (वैकल्पिक)",
"createFolder": "नया फ़ोल्डर बनाएं",
"renameFolder": "फ़ोल्डर का नाम बदलें",
"moveFolder": "फ़ोल्डर स्थानांतरित करें",
"shareFolder": "फ़ोल्डर साझा करें",
"deleteFolder": "फ़ोल्डर हटाएं",
"moveTo": "यहाँ स्थानांतरित करें",
"selectDestination": "गंतव्य फ़ोल्डर चुनें",
"rootFolder": "मूल",
"folderCreated": "फ़ोल्डर सफलतापूर्वक बनाया गया",
"folderRenamed": "फ़ोल्डर का नाम सफलतापूर्वक बदला गया",
"folderMoved": "फ़ोल्डर सफलतापूर्वक स्थानांतरित किया गया",
"folderDeleted": "फ़ोल्डर सफलतापूर्वक हटाया गया",
"folderShared": "फ़ोल्डर सफलतापूर्वक साझा किया गया",
"createFolderError": "फ़ोल्डर बनाने में त्रुटि",
"renameFolderError": "फ़ोल्डर का नाम बदलने में त्रुटि",
"moveFolderError": "फ़ोल्डर स्थानांतरित करने में त्रुटि",
"deleteFolderError": "फ़ोल्डर हटाने में त्रुटि",
"shareFolderError": "फ़ोल्डर साझा करने में त्रुटि",
"deleteConfirmation": "क्या आप वाकई इस फ़ोल्डर को हटाना चाहते हैं?",
"deleteWarning": "यह कार्य पूर्ववत नहीं किया जा सकता।"
},
"footer": {
"poweredBy": "द्वारा संचालित",
"kyanHomepage": "Kyantech होमपेज"
@@ -478,6 +531,13 @@
"removeFailed": "लोगो हटाने में विफल"
}
},
"moveItems": {
"itemsToMove": "स्थानांतरित करने वाले आइटम:",
"movingTo": "यहाँ स्थानांतरित कर रहे हैं:",
"title": "आइटम स्थानांतरित करें",
"description": "आइटम को नए स्थान पर स्थानांतरित करें",
"success": "{count} आइटम सफलतापूर्वक स्थानांतरित किए गए"
},
"navbar": {
"logoAlt": "एप्लिकेशन लोगो",
"profileMenu": "प्रोफ़ाइल मेन्यू",
@@ -1106,7 +1166,10 @@
},
"searchBar": {
"placeholder": "फाइलें खोजें...",
"results": "{total} में से {filtered} फाइलें मिलीं"
"results": "{total} में से {filtered} फाइलें मिलीं",
"placeholderFolders": "फ़ोल्डर खोजें...",
"noResults": "\"{query}\" के लिए कोई परिणाम नहीं मिला",
"placeholderFiles": "फाइलें खोजें..."
},
"settings": {
"groups": {
@@ -1320,7 +1383,17 @@
"editError": "साझाकरण अपडेट करने में विफल",
"bulkDeleteConfirmation": "क्या आप वाकई {count, plural, =1 {1 साझाकरण} other {# साझाकरण}} हटाना चाहते हैं? इस क्रिया को पूर्ववत नहीं किया जा सकता।",
"bulkDeleteTitle": "चयनित साझाकरण हटाएं",
"addDescriptionPlaceholder": "विवरण जोड़ें..."
"addDescriptionPlaceholder": "विवरण जोड़ें...",
"aliasLabel": "लिंक उपनाम",
"aliasPlaceholder": "कस्टम उपनाम दर्ज करें",
"copyLink": "लिंक कॉपी करें",
"fileTitle": "फ़ाइल साझा करें",
"folderTitle": "फ़ोल्डर साझा करें",
"generateLink": "लिंक जेनरेट करें",
"linkDescriptionFile": "फ़ाइल साझा करने के लिए कस्टम लिंक जेनरेट करें",
"linkDescriptionFolder": "फ़ोल्डर साझा करने के लिए कस्टम लिंक जेनरेट करें",
"linkReady": "आपका साझाकरण लिंक तैयार है:",
"linkTitle": "लिंक जेनरेट करें"
},
"shareDetails": {
"title": "साझाकरण विवरण",
@@ -1447,7 +1520,8 @@
"files": "फाइलें",
"totalSize": "कुल आकार",
"creating": "बनाया जा रहा है...",
"create": "साझाकरण बनाएं"
"create": "साझाकरण बनाएं",
"itemsToShare": "साझा करने वाले आइटम ({count} आइटम)"
},
"shareSecurity": {
"subtitle": "इस साझाकरण के लिए पासवर्ड सुरक्षा और सुरक्षा विकल्प कॉन्फ़िगर करें",
@@ -1552,7 +1626,8 @@
"download": "चयनित डाउनलोड करें"
},
"selectAll": "सभी चुनें",
"selectShare": "साझाकरण {shareName} चुनें"
"selectShare": "साझाकरण {shareName} चुनें",
"folderCount": "फ़ोल्डर"
},
"storageUsage": {
"title": "स्टोरेज उपयोग",

View File

@@ -144,7 +144,13 @@
"update": "Aggiorna",
"click": "Clicca per",
"creating": "Creazione in corso...",
"loadingSimple": "Caricamento..."
"loadingSimple": "Caricamento...",
"create": "Crea",
"deleting": "Eliminazione...",
"move": "Sposta",
"rename": "Rinomina",
"search": "Cerca",
"share": "Condividi"
},
"createShare": {
"title": "Crea Condivisione",
@@ -160,7 +166,13 @@
"create": "Crea Condivisione",
"success": "Condivisione creata con successo",
"error": "Errore nella creazione della condivisione",
"namePlaceholder": "Inserisci un nome per la tua condivisione"
"namePlaceholder": "Inserisci un nome per la tua condivisione",
"nextSelectFiles": "Avanti: Seleziona file",
"searchLabel": "Cerca",
"tabs": {
"shareDetails": "Dettagli condivisione",
"selectFiles": "Seleziona file"
}
},
"customization": {
"breadcrumb": "Personalizzazione",
@@ -214,7 +226,9 @@
},
"deleteConfirmation": {
"filesToDelete": "File da eliminare",
"sharesToDelete": "Condivisioni che saranno eliminate"
"sharesToDelete": "Condivisioni che saranno eliminate",
"foldersToDelete": "Cartelle da eliminare",
"itemsToDelete": "Elementi da eliminare"
},
"downloadQueue": {
"downloadQueued": "Download in coda: {fileName}",
@@ -322,7 +336,8 @@
"previewFile": "Anteprima file",
"addToShare": "Aggiungi alla condivisione",
"removeFromShare": "Rimuovi dalla condivisione",
"saveChanges": "Salva Modifiche"
"saveChanges": "Salva Modifiche",
"editFolder": "Modifica cartella"
},
"files": {
"title": "Tutti i File",
@@ -347,7 +362,18 @@
"table": "Tabella",
"grid": "Griglia"
},
"totalFiles": "{count, plural, =0 {Nessun file} =1 {1 file} other {# file}}"
"totalFiles": "{count, plural, =0 {Nessun file} =1 {1 file} other {# file}}",
"actions": {
"open": "Apri",
"rename": "Rinomina",
"delete": "Elimina"
},
"empty": {
"title": "Ancora nessun file o cartella",
"description": "Carica il tuo primo file o crea una cartella per iniziare"
},
"files": "file",
"folders": "cartelle"
},
"filesTable": {
"ariaLabel": "Tabella dei file",
@@ -377,6 +403,33 @@
"delete": "Elimina Selezionati"
}
},
"folderActions": {
"editFolder": "Modifica cartella",
"folderName": "Nome cartella",
"folderNamePlaceholder": "Inserisci nome cartella",
"folderDescription": "Descrizione",
"folderDescriptionPlaceholder": "Inserisci descrizione cartella (opzionale)",
"createFolder": "Crea nuova cartella",
"renameFolder": "Rinomina cartella",
"moveFolder": "Sposta cartella",
"shareFolder": "Condividi cartella",
"deleteFolder": "Elimina cartella",
"moveTo": "Sposta in",
"selectDestination": "Seleziona cartella destinazione",
"rootFolder": "Radice",
"folderCreated": "Cartella creata con successo",
"folderRenamed": "Cartella rinominata con successo",
"folderMoved": "Cartella spostata con successo",
"folderDeleted": "Cartella eliminata con successo",
"folderShared": "Cartella condivisa con successo",
"createFolderError": "Errore nella creazione della cartella",
"renameFolderError": "Errore nella rinominazione della cartella",
"moveFolderError": "Errore nello spostamento della cartella",
"deleteFolderError": "Errore nell'eliminazione della cartella",
"shareFolderError": "Errore nella condivisione della cartella",
"deleteConfirmation": "Sei sicuro di voler eliminare questa cartella?",
"deleteWarning": "Questa azione non può essere annullata."
},
"footer": {
"poweredBy": "Sviluppato da",
"kyanHomepage": "Homepage di Kyantech"
@@ -478,6 +531,13 @@
"removeFailed": "Errore durante la rimozione del logo"
}
},
"moveItems": {
"itemsToMove": "Elementi da spostare:",
"movingTo": "Spostamento in:",
"title": "Sposta {count, plural, =1 {elemento} other {elementi}}",
"description": "Sposta {count, plural, =1 {elemento} other {elementi}} in una nuova posizione",
"success": "Spostati con successo {count} {count, plural, =1 {elemento} other {elementi}}"
},
"navbar": {
"logoAlt": "Logo dell'App",
"profileMenu": "Menu Profilo",
@@ -1106,7 +1166,10 @@
},
"searchBar": {
"placeholder": "Cerca file...",
"results": "Trovati {filtered} di {total} file"
"results": "Trovati {filtered} di {total} file",
"placeholderFolders": "Cerca cartelle...",
"noResults": "Nessun risultato trovato per \"{query}\"",
"placeholderFiles": "Cerca file..."
},
"settings": {
"groups": {
@@ -1320,7 +1383,17 @@
"editSuccess": "Condivisione aggiornata con successo",
"editError": "Errore nell'aggiornamento della condivisione",
"bulkDeleteConfirmation": "Sei sicuro di voler eliminare {count, plural, =1 {1 condivisione} other {# condivisioni}}? Questa azione non può essere annullata.",
"bulkDeleteTitle": "Elimina Condivisioni Selezionate"
"bulkDeleteTitle": "Elimina Condivisioni Selezionate",
"aliasLabel": "Alias collegamento",
"aliasPlaceholder": "Inserisci alias personalizzato",
"copyLink": "Copia collegamento",
"fileTitle": "Condividi file",
"folderTitle": "Condividi cartella",
"generateLink": "Genera collegamento",
"linkDescriptionFile": "Genera un collegamento personalizzato per condividere il file",
"linkDescriptionFolder": "Genera un collegamento personalizzato per condividere la cartella",
"linkReady": "Il tuo collegamento di condivisione è pronto:",
"linkTitle": "Genera collegamento"
},
"shareDetails": {
"title": "Dettagli Condivisione",
@@ -1447,7 +1520,8 @@
"files": "file",
"totalSize": "Dimensione totale",
"creating": "Creazione...",
"create": "Crea Condivisione"
"create": "Crea Condivisione",
"itemsToShare": "Elementi da condividere ({count} {count, plural, =1 {elemento} other {elementi}})"
},
"shareSecurity": {
"subtitle": "Configura protezione password e opzioni di sicurezza per questa condivisione",
@@ -1552,7 +1626,8 @@
"download": "Scarica selezionato"
},
"selectAll": "Seleziona tutto",
"selectShare": "Seleziona condivisione {shareName}"
"selectShare": "Seleziona condivisione {shareName}",
"folderCount": "cartelle"
},
"storageUsage": {
"title": "Utilizzo Archiviazione",

View File

@@ -144,7 +144,13 @@
"update": "更新",
"click": "クリックして",
"creating": "作成中...",
"loadingSimple": "読み込み中..."
"loadingSimple": "読み込み中...",
"create": "作成",
"deleting": "削除中...",
"move": "移動",
"rename": "名前を変更",
"search": "検索",
"share": "共有"
},
"createShare": {
"title": "共有を作成",
@@ -160,7 +166,13 @@
"create": "共有を作成",
"success": "共有が正常に作成されました",
"error": "共有の作成に失敗しました",
"namePlaceholder": "共有の名前を入力してください"
"namePlaceholder": "共有の名前を入力してください",
"nextSelectFiles": "次へ:ファイルを選択",
"searchLabel": "検索",
"tabs": {
"shareDetails": "共有の詳細",
"selectFiles": "ファイルを選択"
}
},
"customization": {
"breadcrumb": "カスタマイズ",
@@ -214,7 +226,9 @@
},
"deleteConfirmation": {
"filesToDelete": "削除するファイル",
"sharesToDelete": "削除される共有"
"sharesToDelete": "削除される共有",
"foldersToDelete": "削除するフォルダ",
"itemsToDelete": "削除するアイテム"
},
"downloadQueue": {
"downloadQueued": "ダウンロードキューに追加: {fileName}",
@@ -322,7 +336,8 @@
"previewFile": "ファイルをプレビュー",
"addToShare": "共有に追加",
"removeFromShare": "共有から削除",
"saveChanges": "変更を保存"
"saveChanges": "変更を保存",
"editFolder": "フォルダを編集"
},
"files": {
"title": "すべてのファイル",
@@ -347,7 +362,18 @@
},
"totalFiles": "{count, plural, =0 {ファイルなし} =1 {1ファイル} other {#ファイル}}",
"bulkDeleteConfirmation": "{count, plural, =1 {1つのファイル} other {#つのファイル}}を削除してよろしいですか?この操作は元に戻せません。",
"bulkDeleteTitle": "選択したファイルを削除"
"bulkDeleteTitle": "選択したファイルを削除",
"actions": {
"open": "開く",
"rename": "名前を変更",
"delete": "削除"
},
"empty": {
"title": "まだファイルやフォルダがありません",
"description": "最初のファイルをアップロードするか、フォルダを作成して始めましょう"
},
"files": "ファイル",
"folders": "フォルダ"
},
"filesTable": {
"ariaLabel": "ファイルテーブル",
@@ -377,6 +403,33 @@
"delete": "選択済みを削除"
}
},
"folderActions": {
"editFolder": "フォルダを編集",
"folderName": "フォルダ名",
"folderNamePlaceholder": "フォルダ名を入力",
"folderDescription": "説明",
"folderDescriptionPlaceholder": "フォルダの説明を入力(任意)",
"createFolder": "新しいフォルダを作成",
"renameFolder": "フォルダ名を変更",
"moveFolder": "フォルダを移動",
"shareFolder": "フォルダを共有",
"deleteFolder": "フォルダを削除",
"moveTo": "移動先",
"selectDestination": "移動先フォルダを選択",
"rootFolder": "ルート",
"folderCreated": "フォルダが正常に作成されました",
"folderRenamed": "フォルダが正常に名前変更されました",
"folderMoved": "フォルダが正常に移動されました",
"folderDeleted": "フォルダが正常に削除されました",
"folderShared": "フォルダが正常に共有されました",
"createFolderError": "フォルダの作成中にエラーが発生しました",
"renameFolderError": "フォルダの名前変更中にエラーが発生しました",
"moveFolderError": "フォルダの移動中にエラーが発生しました",
"deleteFolderError": "フォルダの削除中にエラーが発生しました",
"shareFolderError": "フォルダの共有中にエラーが発生しました",
"deleteConfirmation": "このフォルダを削除してもよろしいですか?",
"deleteWarning": "この操作は元に戻すことができません。"
},
"footer": {
"poweredBy": "提供:",
"kyanHomepage": "Kyantech ホームページ"
@@ -478,6 +531,13 @@
"removeFailed": "ロゴの削除に失敗しました"
}
},
"moveItems": {
"itemsToMove": "移動するアイテム:",
"movingTo": "移動先:",
"title": "アイテムを移動",
"description": "アイテムを新しい場所に移動",
"success": "{count}個のアイテムが正常に移動されました"
},
"navbar": {
"logoAlt": "アプリケーションロゴ",
"profileMenu": "プロフィールメニュー",
@@ -1106,7 +1166,10 @@
},
"searchBar": {
"placeholder": "ファイルを検索...",
"results": "全{total}件中{filtered}件が見つかりました"
"results": "全{total}件中{filtered}件が見つかりました",
"placeholderFolders": "フォルダを検索...",
"noResults": "\"{query}\"の検索結果が見つかりませんでした",
"placeholderFiles": "ファイルを検索..."
},
"settings": {
"groups": {
@@ -1320,7 +1383,17 @@
"editError": "共有の更新に失敗しました",
"bulkDeleteConfirmation": "{count, plural, =1 {1つの共有} other {#つの共有}}を削除してもよろしいですか?この操作は元に戻せません。",
"bulkDeleteTitle": "選択した共有を削除",
"addDescriptionPlaceholder": "説明を追加..."
"addDescriptionPlaceholder": "説明を追加...",
"aliasLabel": "リンクエイリアス",
"aliasPlaceholder": "カスタムエイリアスを入力",
"copyLink": "リンクをコピー",
"fileTitle": "ファイルを共有",
"folderTitle": "フォルダを共有",
"generateLink": "リンクを生成",
"linkDescriptionFile": "ファイルを共有するためのカスタムリンクを生成",
"linkDescriptionFolder": "フォルダを共有するためのカスタムリンクを生成",
"linkReady": "共有リンクの準備ができました:",
"linkTitle": "リンクを生成"
},
"shareDetails": {
"title": "共有詳細",
@@ -1447,7 +1520,8 @@
"files": "ファイル",
"totalSize": "合計サイズ",
"creating": "作成中...",
"create": "共有を作成"
"create": "共有を作成",
"itemsToShare": "共有するアイテム({count}個のアイテム)"
},
"shareSecurity": {
"subtitle": "この共有のパスワード保護とセキュリティオプションを設定",
@@ -1552,7 +1626,8 @@
"download": "選択したダウンロード"
},
"selectAll": "すべて選択",
"selectShare": "共有{shareName}を選択"
"selectShare": "共有{shareName}を選択",
"folderCount": "フォルダ"
},
"storageUsage": {
"title": "ストレージ使用量",

View File

@@ -144,7 +144,13 @@
"update": "업데이트",
"click": "클릭하여",
"creating": "생성 중...",
"loadingSimple": "로딩 중..."
"loadingSimple": "로딩 중...",
"create": "생성",
"deleting": "삭제 중...",
"move": "이동",
"rename": "이름 변경",
"search": "검색",
"share": "공유"
},
"createShare": {
"title": "공유 생성",
@@ -160,7 +166,13 @@
"create": "공유 생성",
"success": "공유가 성공적으로 생성되었습니다",
"error": "공유 생성에 실패했습니다",
"namePlaceholder": "공유 이름을 입력하세요"
"namePlaceholder": "공유 이름을 입력하세요",
"nextSelectFiles": "다음: 파일 선택",
"searchLabel": "검색",
"tabs": {
"shareDetails": "공유 세부사항",
"selectFiles": "파일 선택"
}
},
"customization": {
"breadcrumb": "커스터마이징",
@@ -214,7 +226,9 @@
},
"deleteConfirmation": {
"filesToDelete": "삭제할 파일",
"sharesToDelete": "삭제될 공유"
"sharesToDelete": "삭제될 공유",
"foldersToDelete": "삭제할 폴더",
"itemsToDelete": "삭제할 항목"
},
"downloadQueue": {
"downloadQueued": "다운로드 대기 중: {fileName}",
@@ -322,7 +336,8 @@
"previewFile": "파일 미리보기",
"addToShare": "공유에 추가",
"removeFromShare": "공유에서 제거",
"saveChanges": "변경사항 저장"
"saveChanges": "변경사항 저장",
"editFolder": "폴더 편집"
},
"files": {
"title": "모든 파일",
@@ -347,7 +362,18 @@
},
"totalFiles": "{count, plural, =0 {파일 없음} =1 {1개 파일} other {#개 파일}}",
"bulkDeleteConfirmation": "{count, plural, =1 {1개 파일} other {#개 파일}}을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"bulkDeleteTitle": "선택한 파일 삭제"
"bulkDeleteTitle": "선택한 파일 삭제",
"actions": {
"open": "열기",
"rename": "이름 변경",
"delete": "삭제"
},
"empty": {
"title": "아직 파일이나 폴더가 없습니다",
"description": "첫 번째 파일을 업로드하거나 폴더를 만들어 시작하세요"
},
"files": "파일",
"folders": "폴더"
},
"filesTable": {
"ariaLabel": "파일 테이블",
@@ -377,6 +403,33 @@
"delete": "선택된 항목 삭제"
}
},
"folderActions": {
"editFolder": "폴더 편집",
"folderName": "폴더 이름",
"folderNamePlaceholder": "폴더 이름 입력",
"folderDescription": "설명",
"folderDescriptionPlaceholder": "폴더 설명 입력 (선택사항)",
"createFolder": "새 폴더 만들기",
"renameFolder": "폴더 이름 변경",
"moveFolder": "폴더 이동",
"shareFolder": "폴더 공유",
"deleteFolder": "폴더 삭제",
"moveTo": "이동 위치",
"selectDestination": "대상 폴더 선택",
"rootFolder": "루트",
"folderCreated": "폴더가 성공적으로 생성되었습니다",
"folderRenamed": "폴더 이름이 성공적으로 변경되었습니다",
"folderMoved": "폴더가 성공적으로 이동되었습니다",
"folderDeleted": "폴더가 성공적으로 삭제되었습니다",
"folderShared": "폴더가 성공적으로 공유되었습니다",
"createFolderError": "폴더 생성 중 오류 발생",
"renameFolderError": "폴더 이름 변경 중 오류 발생",
"moveFolderError": "폴더 이동 중 오류 발생",
"deleteFolderError": "폴더 삭제 중 오류 발생",
"shareFolderError": "폴더 공유 중 오류 발생",
"deleteConfirmation": "이 폴더를 삭제하시겠습니까?",
"deleteWarning": "이 작업은 되돌릴 수 없습니다."
},
"footer": {
"poweredBy": "제공:",
"kyanHomepage": "Kyantech 홈페이지"
@@ -478,6 +531,13 @@
"removeFailed": "로고 삭제에 실패했습니다"
}
},
"moveItems": {
"itemsToMove": "이동할 항목:",
"movingTo": "이동 위치:",
"title": "항목 이동",
"description": "항목을 새 위치로 이동",
"success": "{count}개 항목이 성공적으로 이동되었습니다"
},
"navbar": {
"logoAlt": "애플리케이션 로고",
"profileMenu": "프로필 메뉴",
@@ -1106,7 +1166,10 @@
},
"searchBar": {
"placeholder": "파일 검색...",
"results": "전체 {total}개 중 {filtered}개 파일을 찾았습니다"
"results": "전체 {total}개 중 {filtered}개 파일을 찾았습니다",
"placeholderFolders": "폴더 검색...",
"noResults": "\"{query}\"에 대한 검색 결과가 없습니다",
"placeholderFiles": "파일 검색..."
},
"settings": {
"groups": {
@@ -1320,7 +1383,17 @@
"editError": "공유 업데이트에 실패했습니다",
"bulkDeleteConfirmation": "{count, plural, =1 {1개의 공유} other {#개의 공유}}를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"bulkDeleteTitle": "선택한 공유 삭제",
"addDescriptionPlaceholder": "설명 추가..."
"addDescriptionPlaceholder": "설명 추가...",
"aliasLabel": "링크 별칭",
"aliasPlaceholder": "사용자 정의 별칭 입력",
"copyLink": "링크 복사",
"fileTitle": "파일 공유",
"folderTitle": "폴더 공유",
"generateLink": "링크 생성",
"linkDescriptionFile": "파일을 공유할 사용자 정의 링크 생성",
"linkDescriptionFolder": "폴더를 공유할 사용자 정의 링크 생성",
"linkReady": "공유 링크가 준비되었습니다:",
"linkTitle": "링크 생성"
},
"shareDetails": {
"title": "공유 세부 정보",
@@ -1447,7 +1520,8 @@
"files": "파일",
"totalSize": "전체 크기",
"creating": "생성 중...",
"create": "공유 생성"
"create": "공유 생성",
"itemsToShare": "공유할 항목 ({count}개 항목)"
},
"shareSecurity": {
"subtitle": "이 공유의 비밀번호 보호 및 보안 옵션을 구성하세요",
@@ -1552,7 +1626,8 @@
"download": "선택한 다운로드"
},
"selectAll": "모두 선택",
"selectShare": "공유 {shareName} 선택"
"selectShare": "공유 {shareName} 선택",
"folderCount": "폴더"
},
"storageUsage": {
"title": "스토리지 사용량",

View File

@@ -144,7 +144,13 @@
"update": "Bijwerken",
"click": "Klik om",
"creating": "Maken...",
"loadingSimple": "Laden..."
"loadingSimple": "Laden...",
"create": "Aanmaken",
"deleting": "Verwijderen...",
"move": "Verplaatsen",
"rename": "Hernoemen",
"search": "Zoeken",
"share": "Delen"
},
"createShare": {
"title": "Delen Maken",
@@ -160,7 +166,13 @@
"create": "Delen Maken",
"success": "Delen succesvol aangemaakt",
"error": "Fout bij het aanmaken van delen",
"namePlaceholder": "Voer een naam in voor uw delen"
"namePlaceholder": "Voer een naam in voor uw delen",
"nextSelectFiles": "Volgende: Bestanden selecteren",
"searchLabel": "Zoeken",
"tabs": {
"shareDetails": "Deel details",
"selectFiles": "Bestanden selecteren"
}
},
"customization": {
"breadcrumb": "Aanpassing",
@@ -214,7 +226,9 @@
},
"deleteConfirmation": {
"filesToDelete": "Te verwijderen bestanden",
"sharesToDelete": "Delen die worden verwijderd"
"sharesToDelete": "Delen die worden verwijderd",
"foldersToDelete": "Mappen die worden verwijderd",
"itemsToDelete": "Items die worden verwijderd"
},
"downloadQueue": {
"downloadQueued": "Download in wachtrij: {fileName}",
@@ -322,7 +336,8 @@
"previewFile": "Bestand bekijken",
"addToShare": "Toevoegen aan share",
"removeFromShare": "Verwijderen uit share",
"saveChanges": "Wijzigingen Opslaan"
"saveChanges": "Wijzigingen Opslaan",
"editFolder": "Map bewerken"
},
"files": {
"title": "Alle Bestanden",
@@ -347,7 +362,18 @@
},
"totalFiles": "{count, plural, =0 {Geen bestanden} =1 {1 bestand} other {# bestanden}}",
"bulkDeleteConfirmation": "Weet je zeker dat je {count, plural, =1 {1 bestand} other {# bestanden}} wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"bulkDeleteTitle": "Geselecteerde Bestanden Verwijderen"
"bulkDeleteTitle": "Geselecteerde Bestanden Verwijderen",
"actions": {
"open": "Openen",
"rename": "Hernoemen",
"delete": "Verwijderen"
},
"empty": {
"title": "Nog geen bestanden of mappen",
"description": "Upload uw eerste bestand of maak een map om te beginnen"
},
"files": "bestanden",
"folders": "mappen"
},
"filesTable": {
"ariaLabel": "Bestanden tabel",
@@ -377,6 +403,33 @@
"delete": "Geselecteerde Verwijderen"
}
},
"folderActions": {
"editFolder": "Map bewerken",
"folderName": "Mapnaam",
"folderNamePlaceholder": "Voer mapnaam in",
"folderDescription": "Beschrijving",
"folderDescriptionPlaceholder": "Voer mapbeschrijving in (optioneel)",
"createFolder": "Nieuwe map maken",
"renameFolder": "Map hernoemen",
"moveFolder": "Map verplaatsen",
"shareFolder": "Map delen",
"deleteFolder": "Map verwijderen",
"moveTo": "Verplaatsen naar",
"selectDestination": "Bestemmingsmap selecteren",
"rootFolder": "Hoofdmap",
"folderCreated": "Map succesvol aangemaakt",
"folderRenamed": "Map succesvol hernoemd",
"folderMoved": "Map succesvol verplaatst",
"folderDeleted": "Map succesvol verwijderd",
"folderShared": "Map succesvol gedeeld",
"createFolderError": "Fout bij maken van map",
"renameFolderError": "Fout bij hernoemen van map",
"moveFolderError": "Fout bij verplaatsen van map",
"deleteFolderError": "Fout bij verwijderen van map",
"shareFolderError": "Fout bij delen van map",
"deleteConfirmation": "Weet u zeker dat u deze map wilt verwijderen?",
"deleteWarning": "Deze actie kan niet ongedaan worden gemaakt."
},
"footer": {
"poweredBy": "Mogelijk gemaakt door",
"kyanHomepage": "Kyantech homepage"
@@ -478,6 +531,13 @@
"removeFailed": "Fout bij het verwijderen van logo"
}
},
"moveItems": {
"itemsToMove": "Items om te verplaatsen:",
"movingTo": "Verplaatsen naar:",
"title": "{count, plural, =1 {Item} other {Items}} verplaatsen",
"description": "{count, plural, =1 {Item} other {Items}} naar een nieuwe locatie verplaatsen",
"success": "{count} {count, plural, =1 {item} other {items}} succesvol verplaatst"
},
"navbar": {
"logoAlt": "Applicatie Logo",
"profileMenu": "Profiel Menu",
@@ -1106,7 +1166,10 @@
},
"searchBar": {
"placeholder": "Bestanden zoeken...",
"results": "{filtered} van {total} bestanden gevonden"
"results": "{filtered} van {total} bestanden gevonden",
"placeholderFolders": "Zoek mappen...",
"noResults": "Geen resultaten gevonden voor \"{query}\"",
"placeholderFiles": "Bestanden zoeken..."
},
"settings": {
"groups": {
@@ -1320,7 +1383,17 @@
"editError": "Fout bij bijwerken van delen",
"bulkDeleteConfirmation": "Weet je zeker dat je {count, plural, =1 {1 deel} other {# delen}} wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"bulkDeleteTitle": "Geselecteerde Delen Verwijderen",
"addDescriptionPlaceholder": "Beschrijving toevoegen..."
"addDescriptionPlaceholder": "Beschrijving toevoegen...",
"aliasLabel": "Link alias",
"aliasPlaceholder": "Voer aangepaste alias in",
"copyLink": "Link kopiëren",
"fileTitle": "Bestand delen",
"folderTitle": "Map delen",
"generateLink": "Link genereren",
"linkDescriptionFile": "Genereer een aangepaste link om het bestand te delen",
"linkDescriptionFolder": "Genereer een aangepaste link om de map te delen",
"linkReady": "Uw deel-link is klaar:",
"linkTitle": "Link genereren"
},
"shareDetails": {
"title": "Delen Details",
@@ -1447,7 +1520,8 @@
"files": "bestanden",
"totalSize": "Totale grootte",
"creating": "Aanmaken...",
"create": "Delen Maken"
"create": "Delen Maken",
"itemsToShare": "Items om te delen ({count} {count, plural, =1 {item} other {items}})"
},
"shareSecurity": {
"subtitle": "Configureer wachtwoordbeveiliging en beveiligingsopties voor dit delen",
@@ -1552,7 +1626,8 @@
"download": "Download geselecteerd"
},
"selectAll": "Alles selecteren",
"selectShare": "Deel {shareName} selecteren"
"selectShare": "Deel {shareName} selecteren",
"folderCount": "mappen"
},
"storageUsage": {
"title": "Opslaggebruik",

View File

@@ -144,7 +144,13 @@
"back": "Wróć",
"click": "Kliknij, aby",
"creating": "Tworzenie...",
"loadingSimple": "Ładowanie..."
"loadingSimple": "Ładowanie...",
"create": "Utwórz",
"deleting": "Usuwanie...",
"move": "Przenieś",
"rename": "Zmień nazwę",
"search": "Szukaj",
"share": "Udostępnij"
},
"createShare": {
"title": "Utwórz Udostępnienie",
@@ -160,7 +166,13 @@
"create": "Utwórz Udostępnienie",
"success": "Udostępnienie utworzone pomyślnie",
"error": "Nie udało się utworzyć udostępnienia",
"namePlaceholder": "Wprowadź nazwę dla swojego udostępnienia"
"namePlaceholder": "Wprowadź nazwę dla swojego udostępnienia",
"nextSelectFiles": "Dalej: Wybierz pliki",
"searchLabel": "Szukaj",
"tabs": {
"shareDetails": "Szczegóły udostępniania",
"selectFiles": "Wybierz pliki"
}
},
"customization": {
"breadcrumb": "Personalizacja",
@@ -214,7 +226,9 @@
},
"deleteConfirmation": {
"filesToDelete": "Pliki do usunięcia",
"sharesToDelete": "Udostępnienia do usunięcia"
"sharesToDelete": "Udostępnienia do usunięcia",
"foldersToDelete": "Foldery do usunięcia",
"itemsToDelete": "Elementy do usunięcia"
},
"downloadQueue": {
"downloadQueued": "Pobieranie w kolejce: {fileName}",
@@ -322,7 +336,8 @@
"previewFile": "Podgląd pliku",
"addToShare": "Dodaj do udostępnienia",
"removeFromShare": "Usuń z udostępnienia",
"saveChanges": "Zapisz zmiany"
"saveChanges": "Zapisz zmiany",
"editFolder": "Edytuj folder"
},
"files": {
"title": "Wszystkie pliki",
@@ -347,7 +362,18 @@
"grid": "Siatka"
},
"bulkDeleteConfirmation": "Czy na pewno chcesz usunąć {count, plural, =1 {1 plik} other {# plików}}? Ta akcja nie może zostać cofnięta.",
"bulkDeleteTitle": "Usuń Wybrane Pliki"
"bulkDeleteTitle": "Usuń Wybrane Pliki",
"actions": {
"open": "Otwórz",
"rename": "Zmień nazwę",
"delete": "Usuń"
},
"empty": {
"title": "Brak plików lub folderów",
"description": "Prześlij swój pierwszy plik lub utwórz folder, aby rozpocząć"
},
"files": "pliki",
"folders": "foldery"
},
"filesTable": {
"ariaLabel": "Tabela plików",
@@ -377,6 +403,33 @@
"delete": "Usuń wybrane"
}
},
"folderActions": {
"editFolder": "Edytuj folder",
"folderName": "Nazwa folderu",
"folderNamePlaceholder": "Wprowadź nazwę folderu",
"folderDescription": "Opis",
"folderDescriptionPlaceholder": "Wprowadź opis folderu (opcjonalnie)",
"createFolder": "Utwórz nowy folder",
"renameFolder": "Zmień nazwę folderu",
"moveFolder": "Przenieś folder",
"shareFolder": "Udostępnij folder",
"deleteFolder": "Usuń folder",
"moveTo": "Przenieś do",
"selectDestination": "Wybierz folder docelowy",
"rootFolder": "Katalog główny",
"folderCreated": "Folder utworzony pomyślnie",
"folderRenamed": "Nazwa folderu zmieniona pomyślnie",
"folderMoved": "Folder przeniesiony pomyślnie",
"folderDeleted": "Folder usunięty pomyślnie",
"folderShared": "Folder udostępniony pomyślnie",
"createFolderError": "Błąd podczas tworzenia folderu",
"renameFolderError": "Błąd podczas zmiany nazwy folderu",
"moveFolderError": "Błąd podczas przenoszenia folderu",
"deleteFolderError": "Błąd podczas usuwania folderu",
"shareFolderError": "Błąd podczas udostępniania folderu",
"deleteConfirmation": "Czy na pewno chcesz usunąć ten folder?",
"deleteWarning": "Ta operacja nie może zostać cofnięta."
},
"footer": {
"poweredBy": "Zasilane przez",
"kyanHomepage": "Strona główna Kyantech"
@@ -478,6 +531,13 @@
"removeFailed": "Nie udało się usunąć logo"
}
},
"moveItems": {
"itemsToMove": "Elementy do przeniesienia:",
"movingTo": "Przenoszenie do:",
"title": "Przenieś {count, plural, =1 {element} other {elementy}}",
"description": "Przenieś {count, plural, =1 {element} other {elementy}} do nowej lokalizacji",
"success": "Pomyślnie przeniesiono {count} {count, plural, =1 {element} other {elementów}}"
},
"navbar": {
"logoAlt": "Logo aplikacji",
"profileMenu": "Menu profilu",
@@ -1106,7 +1166,10 @@
},
"searchBar": {
"placeholder": "Szukaj plików...",
"results": "Znaleziono {filtered} z {total} plików"
"results": "Znaleziono {filtered} z {total} plików",
"placeholderFolders": "Szukaj folderów...",
"noResults": "Nie znaleziono wyników dla \"{query}\"",
"placeholderFiles": "Szukaj plików..."
},
"settings": {
"groups": {
@@ -1320,7 +1383,17 @@
"editError": "Nie udało się zaktualizować udostępnienia",
"bulkDeleteConfirmation": "Czy na pewno chcesz usunąć {count, plural, =1 {1 udostępnienie} other {# udostępnień}}? Tej operacji nie można cofnąć.",
"bulkDeleteTitle": "Usuń wybrane udostępnienia",
"addDescriptionPlaceholder": "Dodaj opis..."
"addDescriptionPlaceholder": "Dodaj opis...",
"aliasLabel": "Alias linku",
"aliasPlaceholder": "Wprowadź niestandardowy alias",
"copyLink": "Kopiuj link",
"fileTitle": "Udostępnij plik",
"folderTitle": "Udostępnij folder",
"generateLink": "Generuj link",
"linkDescriptionFile": "Wygeneruj niestandardowy link do udostępnienia pliku",
"linkDescriptionFolder": "Wygeneruj niestandardowy link do udostępnienia folderu",
"linkReady": "Twój link udostępniania jest gotowy:",
"linkTitle": "Generuj link"
},
"shareDetails": {
"title": "Szczegóły udostępnienia",
@@ -1447,7 +1520,8 @@
"files": "plików",
"totalSize": "Całkowity rozmiar",
"creating": "Tworzenie...",
"create": "Utwórz udostępnienie"
"create": "Utwórz udostępnienie",
"itemsToShare": "Elementy do udostępnienia ({count} {count, plural, =1 {element} other {elementów}})"
},
"shareSecurity": {
"title": "Ustawienia bezpieczeństwa udostępniania",
@@ -1552,7 +1626,8 @@
"download": "Pobierz wybrany"
},
"selectAll": "Zaznacz wszystko",
"selectShare": "Wybierz udostępnienie {shareName}"
"selectShare": "Wybierz udostępnienie {shareName}",
"folderCount": "foldery"
},
"storageUsage": {
"title": "Użycie pamięci",

View File

@@ -144,7 +144,13 @@
"update": "Atualizar",
"creating": "Criando...",
"click": "Clique para",
"loadingSimple": "Carregando..."
"loadingSimple": "Carregando...",
"create": "Criar",
"deleting": "Excluindo...",
"move": "Mover",
"rename": "Renomear",
"search": "Pesquisar",
"share": "Compartilhar"
},
"createShare": {
"title": "Criar compartilhamento",
@@ -160,7 +166,13 @@
"create": "Criar compartilhamento",
"success": "Compartilhamento criado com sucesso",
"error": "Falha ao criar compartilhamento",
"namePlaceholder": "Digite um nome para seu compartilhamento"
"namePlaceholder": "Digite um nome para seu compartilhamento",
"nextSelectFiles": "Próximo: Selecionar arquivos",
"searchLabel": "Pesquisar",
"tabs": {
"shareDetails": "Detalhes do compartilhamento",
"selectFiles": "Selecionar arquivos"
}
},
"customization": {
"breadcrumb": "Personalização",
@@ -214,7 +226,9 @@
},
"deleteConfirmation": {
"filesToDelete": "Arquivos que serão excluídos",
"sharesToDelete": "Compartilhamentos que serão excluídos"
"sharesToDelete": "Compartilhamentos que serão excluídos",
"foldersToDelete": "Pastas a serem excluídas",
"itemsToDelete": "Itens a serem excluídos"
},
"downloadQueue": {
"downloadQueued": "Download na fila: {fileName}",
@@ -322,7 +336,8 @@
"previewFile": "Visualizar arquivo",
"addToShare": "Adicionar ao compartilhamento",
"removeFromShare": "Remover do compartilhamento",
"saveChanges": "Salvar Alterações"
"saveChanges": "Salvar Alterações",
"editFolder": "Editar pasta"
},
"files": {
"title": "Todos os Arquivos",
@@ -347,7 +362,18 @@
"viewMode": {
"table": "Tabela",
"grid": "Grade"
}
},
"actions": {
"open": "Abrir",
"rename": "Renomear",
"delete": "Excluir"
},
"empty": {
"title": "Nenhum arquivo ou pasta ainda",
"description": "Carregue seu primeiro arquivo ou crie uma pasta para começar"
},
"files": "arquivos",
"folders": "pastas"
},
"filesTable": {
"ariaLabel": "Tabela de arquivos",
@@ -377,6 +403,33 @@
"delete": "Excluir selecionados"
}
},
"folderActions": {
"editFolder": "Editar pasta",
"folderName": "Nome da pasta",
"folderNamePlaceholder": "Digite o nome da pasta",
"folderDescription": "Descrição",
"folderDescriptionPlaceholder": "Digite a descrição da pasta (opcional)",
"createFolder": "Criar nova pasta",
"renameFolder": "Renomear pasta",
"moveFolder": "Mover pasta",
"shareFolder": "Compartilhar pasta",
"deleteFolder": "Excluir pasta",
"moveTo": "Mover para",
"selectDestination": "Selecionar pasta de destino",
"rootFolder": "Raiz",
"folderCreated": "Pasta criada com sucesso",
"folderRenamed": "Pasta renomeada com sucesso",
"folderMoved": "Pasta movida com sucesso",
"folderDeleted": "Pasta excluída com sucesso",
"folderShared": "Pasta compartilhada com sucesso",
"createFolderError": "Erro ao criar pasta",
"renameFolderError": "Erro ao renomear pasta",
"moveFolderError": "Erro ao mover pasta",
"deleteFolderError": "Erro ao excluir pasta",
"shareFolderError": "Erro ao compartilhar pasta",
"deleteConfirmation": "Tem certeza de que deseja excluir esta pasta?",
"deleteWarning": "Esta ação não pode ser desfeita."
},
"footer": {
"poweredBy": "Desenvolvido por",
"kyanHomepage": "Página inicial da Kyantech"
@@ -478,6 +531,13 @@
"removeFailed": "Falha ao remover logo"
}
},
"moveItems": {
"itemsToMove": "Itens para mover:",
"movingTo": "Movendo para:",
"title": "Mover {count, plural, =1 {item} other {itens}}",
"description": "Mover {count, plural, =1 {item} other {itens}} para um novo local",
"success": "Movidos com sucesso {count} {count, plural, =1 {item} other {itens}}"
},
"navbar": {
"logoAlt": "Logo do aplicativo",
"profileMenu": "Menu do Perfil",
@@ -1107,7 +1167,10 @@
},
"searchBar": {
"placeholder": "Buscar arquivos...",
"results": "Encontrados {filtered} de {total} arquivos"
"results": "Encontrados {filtered} de {total} arquivos",
"placeholderFolders": "Pesquisar pastas...",
"noResults": "Nenhum resultado encontrado para \"{query}\"",
"placeholderFiles": "Buscar arquivos..."
},
"settings": {
"groups": {
@@ -1321,7 +1384,17 @@
"manageFilesTitle": "Gerenciar Arquivos",
"manageRecipientsTitle": "Gerenciar Destinatários",
"editSuccess": "Compartilhamento atualizado com sucesso",
"editError": "Falha ao atualizar compartilhamento"
"editError": "Falha ao atualizar compartilhamento",
"aliasLabel": "Alias do link",
"aliasPlaceholder": "Digite alias personalizado",
"copyLink": "Copiar link",
"fileTitle": "Compartilhar arquivo",
"folderTitle": "Compartilhar pasta",
"generateLink": "Gerar link",
"linkDescriptionFile": "Gere um link personalizado para compartilhar o arquivo",
"linkDescriptionFolder": "Gere um link personalizado para compartilhar a pasta",
"linkReady": "Seu link de compartilhamento está pronto:",
"linkTitle": "Gerar link"
},
"shareDetails": {
"title": "Detalhes do Compartilhamento",
@@ -1448,7 +1521,8 @@
"files": "arquivos",
"totalSize": "Tamanho total",
"creating": "Criando...",
"create": "Criar Compartilhamento"
"create": "Criar Compartilhamento",
"itemsToShare": "Itens para compartilhar ({count} {count, plural, =1 {item} other {itens}})"
},
"shareSecurity": {
"subtitle": "Configurar proteção por senha e opções de segurança para este compartilhamento",
@@ -1553,7 +1627,8 @@
"delete": "Excluir",
"downloadShareFiles": "Baixar todos os arquivos",
"viewQrCode": "Visualizar QR Code"
}
},
"folderCount": "pastas"
},
"storageUsage": {
"title": "Uso de armazenamento",

View File

@@ -144,7 +144,13 @@
"update": "Обновить",
"click": "Нажмите для",
"creating": "Создание...",
"loadingSimple": "Загрузка..."
"loadingSimple": "Загрузка...",
"create": "Создать",
"deleting": "Удаление...",
"move": "Переместить",
"rename": "Переименовать",
"search": "Поиск",
"share": "Поделиться"
},
"createShare": {
"title": "Создать общий доступ",
@@ -160,7 +166,13 @@
"error": "Не удалось создать общий доступ",
"descriptionLabel": "Описание",
"descriptionPlaceholder": "Введите описание (опционально)",
"namePlaceholder": "Введите имя для вашего общего доступа"
"namePlaceholder": "Введите имя для вашего общего доступа",
"nextSelectFiles": "Далее: Выбор файлов",
"searchLabel": "Поиск",
"tabs": {
"shareDetails": "Детали общего доступа",
"selectFiles": "Выбрать файлы"
}
},
"customization": {
"breadcrumb": "Настройка",
@@ -214,7 +226,9 @@
},
"deleteConfirmation": {
"filesToDelete": "Файлы для удаления",
"sharesToDelete": "Общие папки, которые будут удалены"
"sharesToDelete": "Общие папки, которые будут удалены",
"foldersToDelete": "Папки для удаления",
"itemsToDelete": "Элементы для удаления"
},
"downloadQueue": {
"downloadQueued": "Загрузка в очереди: {fileName}",
@@ -322,7 +336,8 @@
"previewFile": "Предпросмотр файла",
"addToShare": "Добавить в общий доступ",
"removeFromShare": "Удалить из общего доступа",
"saveChanges": "Сохранить Изменения"
"saveChanges": "Сохранить Изменения",
"editFolder": "Редактировать папку"
},
"files": {
"title": "Все файлы",
@@ -347,7 +362,18 @@
"grid": "Сетка"
},
"bulkDeleteConfirmation": "Вы уверены, что хотите удалить {count, plural, =1 {1 файл} other {# файлов}}? Это действие нельзя отменить.",
"bulkDeleteTitle": "Удалить Выбранные Файлы"
"bulkDeleteTitle": "Удалить Выбранные Файлы",
"actions": {
"open": "Открыть",
"rename": "Переименовать",
"delete": "Удалить"
},
"empty": {
"title": "Пока нет файлов или папок",
"description": "Загрузите свой первый файл или создайте папку для начала работы"
},
"files": "файлы",
"folders": "папки"
},
"filesTable": {
"ariaLabel": "Таблица файлов",
@@ -377,6 +403,33 @@
"delete": "Удалить Выбранные"
}
},
"folderActions": {
"editFolder": "Редактировать папку",
"folderName": "Имя папки",
"folderNamePlaceholder": "Введите имя папки",
"folderDescription": "Описание",
"folderDescriptionPlaceholder": "Введите описание папки (необязательно)",
"createFolder": "Создать новую папку",
"renameFolder": "Переименовать папку",
"moveFolder": "Переместить папку",
"shareFolder": "Поделиться папкой",
"deleteFolder": "Удалить папку",
"moveTo": "Переместить в",
"selectDestination": "Выберите папку назначения",
"rootFolder": "Корень",
"folderCreated": "Папка успешно создана",
"folderRenamed": "Папка успешно переименована",
"folderMoved": "Папка успешно перемещена",
"folderDeleted": "Папка успешно удалена",
"folderShared": "Папка успешно отправлена",
"createFolderError": "Ошибка создания папки",
"renameFolderError": "Ошибка переименования папки",
"moveFolderError": "Ошибка перемещения папки",
"deleteFolderError": "Ошибка удаления папки",
"shareFolderError": "Ошибка обмена папкой",
"deleteConfirmation": "Вы уверены, что хотите удалить эту папку?",
"deleteWarning": "Это действие нельзя отменить."
},
"footer": {
"poweredBy": "При поддержке",
"kyanHomepage": "Домашняя страница Kyantech"
@@ -478,6 +531,13 @@
"removeFailed": "Ошибка удаления логотипа"
}
},
"moveItems": {
"itemsToMove": "Элементы для перемещения:",
"movingTo": "Перемещение в:",
"title": "Переместить {count, plural, =1 {элемент} other {элементы}}",
"description": "Переместить {count, plural, =1 {элемент} other {элементы}} в новое место",
"success": "Успешно перемещено {count} {count, plural, =1 {элемент} other {элементов}}"
},
"navbar": {
"logoAlt": "Логотип приложения",
"profileMenu": "Меню профиля",
@@ -1106,7 +1166,10 @@
},
"searchBar": {
"placeholder": "Поиск файлов...",
"results": "Найдено {filtered} из {total} файлов"
"results": "Найдено {filtered} из {total} файлов",
"placeholderFolders": "Поиск папок...",
"noResults": "Не найдено результатов для \"{query}\"",
"placeholderFiles": "Поиск файлов..."
},
"settings": {
"groups": {
@@ -1320,7 +1383,17 @@
"editError": "Ошибка обновления общего доступа",
"bulkDeleteConfirmation": "Вы уверены, что хотите удалить {count, plural, =1 {1 общую папку} other {# общих папок}}? Это действие нельзя отменить.",
"bulkDeleteTitle": "Удалить Выбранные Общие Папки",
"addDescriptionPlaceholder": "Добавить описание..."
"addDescriptionPlaceholder": "Добавить описание...",
"aliasLabel": "Псевдоним ссылки",
"aliasPlaceholder": "Введите пользовательский псевдоним",
"copyLink": "Копировать ссылку",
"fileTitle": "Поделиться файлом",
"folderTitle": "Поделиться папкой",
"generateLink": "Создать ссылку",
"linkDescriptionFile": "Создайте пользовательскую ссылку для обмена файлом",
"linkDescriptionFolder": "Создайте пользовательскую ссылку для обмена папкой",
"linkReady": "Ваша ссылка для обмена готова:",
"linkTitle": "Создать ссылку"
},
"shareDetails": {
"title": "Детали Общего Доступа",
@@ -1447,7 +1520,8 @@
"files": "файлов",
"totalSize": "Общий размер",
"creating": "Создание...",
"create": "Создать Общий Доступ"
"create": "Создать Общий Доступ",
"itemsToShare": "Элементы для обмена ({count} {count, plural, =1 {элемент} other {элементов}})"
},
"shareSecurity": {
"subtitle": "Настройте защиту паролем и параметры безопасности для этого общего доступа",
@@ -1552,7 +1626,8 @@
"download": "Скачать выбранный"
},
"selectAll": "Выбрать все",
"selectShare": "Выбрать общую папку {shareName}"
"selectShare": "Выбрать общую папку {shareName}",
"folderCount": "папки"
},
"storageUsage": {
"title": "Использование хранилища",

View File

@@ -144,7 +144,13 @@
"update": "Güncelle",
"click": "Tıklayın",
"creating": "Oluşturuluyor...",
"loadingSimple": "Yükleniyor..."
"loadingSimple": "Yükleniyor...",
"create": "Oluştur",
"deleting": "Siliniyor...",
"move": "Taşı",
"rename": "Yeniden Adlandır",
"search": "Ara",
"share": "Paylaş"
},
"createShare": {
"title": "Paylaşım Oluştur",
@@ -160,7 +166,13 @@
"error": "Paylaşım oluşturulamadı",
"descriptionLabel": "Açıklama",
"descriptionPlaceholder": "Açıklama girin (isteğe bağlı)",
"namePlaceholder": "Paylaşımınız için bir ad girin"
"namePlaceholder": "Paylaşımınız için bir ad girin",
"nextSelectFiles": "İleri: Dosyaları Seç",
"searchLabel": "Ara",
"tabs": {
"shareDetails": "Paylaşım Detayları",
"selectFiles": "Dosyaları Seç"
}
},
"customization": {
"breadcrumb": "Özelleştirme",
@@ -214,7 +226,9 @@
},
"deleteConfirmation": {
"filesToDelete": "Silinecek dosyalar",
"sharesToDelete": "Silinecek paylaşımlar"
"sharesToDelete": "Silinecek paylaşımlar",
"foldersToDelete": "Silinecek klasörler",
"itemsToDelete": "Silinecek öğeler"
},
"downloadQueue": {
"downloadQueued": "İndirme sıraya alındı: {fileName}",
@@ -322,7 +336,8 @@
"previewFile": "Dosyayı önizle",
"addToShare": "Paylaşıma ekle",
"removeFromShare": "Paylaşımdan kaldır",
"saveChanges": "Değişiklikleri Kaydet"
"saveChanges": "Değişiklikleri Kaydet",
"editFolder": "Klasörü düzenle"
},
"files": {
"title": "Tüm Dosyalar",
@@ -347,7 +362,18 @@
"grid": "Izgara"
},
"bulkDeleteConfirmation": "{count, plural, =1 {1 dosyayı} other {# dosyayı}} silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
"bulkDeleteTitle": "Seçili Dosyaları Sil"
"bulkDeleteTitle": "Seçili Dosyaları Sil",
"actions": {
"open": "Aç",
"rename": "Yeniden Adlandır",
"delete": "Sil"
},
"empty": {
"title": "Henüz dosya veya klasör yok",
"description": "Başlamak için ilk dosyanızı yükleyin veya bir klasör oluşturun"
},
"files": "dosyalar",
"folders": "klasörler"
},
"filesTable": {
"ariaLabel": "Dosya Tablosu",
@@ -377,6 +403,33 @@
"delete": "Seçilenleri Sil"
}
},
"folderActions": {
"editFolder": "Klasörü düzenle",
"folderName": "Klasör Adı",
"folderNamePlaceholder": "Klasör adını girin",
"folderDescription": "Açıklama",
"folderDescriptionPlaceholder": "Klasör açıklaması girin (isteğe bağlı)",
"createFolder": "Yeni Klasör Oluştur",
"renameFolder": "Klasörü Yeniden Adlandır",
"moveFolder": "Klasörü Taşı",
"shareFolder": "Klasörü Paylaş",
"deleteFolder": "Klasörü Sil",
"moveTo": "Taşı",
"selectDestination": "Hedef klasörü seç",
"rootFolder": "Kök",
"folderCreated": "Klasör başarıyla oluşturuldu",
"folderRenamed": "Klasör başarıyla yeniden adlandırıldı",
"folderMoved": "Klasör başarıyla taşındı",
"folderDeleted": "Klasör başarıyla silindi",
"folderShared": "Klasör başarıyla paylaşıldı",
"createFolderError": "Klasör oluşturulurken hata",
"renameFolderError": "Klasör yeniden adlandırılırken hata",
"moveFolderError": "Klasör taşınırken hata",
"deleteFolderError": "Klasör silinirken hata",
"shareFolderError": "Klasör paylaşılırken hata",
"deleteConfirmation": "Bu klasörü silmek istediğinizden emin misiniz?",
"deleteWarning": "Bu işlem geri alınamaz."
},
"footer": {
"poweredBy": "Tarafından destekleniyor:",
"kyanHomepage": "Kyantech Ana Sayfası"
@@ -478,6 +531,13 @@
"removeFailed": "Logo kaldırılamadı"
}
},
"moveItems": {
"itemsToMove": "Taşınacak öğeler:",
"movingTo": "Taşınıyor:",
"title": "{count, plural, =1 {Öğe} other {Öğeler}} Taşı",
"description": "{count, plural, =1 {Öğeyi} other {Öğeleri}} yeni konuma taşı",
"success": "{count} {count, plural, =1 {öğe} other {öğe}} başarıyla taşındı"
},
"navbar": {
"logoAlt": "Uygulama Logosu",
"profileMenu": "Profil Menüsü",
@@ -1106,7 +1166,10 @@
},
"searchBar": {
"placeholder": "Dosya ara...",
"results": "Toplam {total} dosya içinde {filtered} dosya bulundu"
"results": "Toplam {total} dosya içinde {filtered} dosya bulundu",
"placeholderFolders": "Klasörleri ara...",
"noResults": "\"{query}\" için sonuç bulunamadı",
"placeholderFiles": "Dosya ara..."
},
"settings": {
"groups": {
@@ -1320,7 +1383,17 @@
"editError": "Paylaşım güncelleme başarısız",
"bulkDeleteConfirmation": "{count, plural, =1 {1 paylaşımı} other {# paylaşımı}} silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
"bulkDeleteTitle": "Seçili Paylaşımları Sil",
"addDescriptionPlaceholder": "Açıklama ekle..."
"addDescriptionPlaceholder": "Açıklama ekle...",
"aliasLabel": "Bağlantı Takma Adı",
"aliasPlaceholder": "Özel takma ad girin",
"copyLink": "Bağlantıyı Kopyala",
"fileTitle": "Dosyayı Paylaş",
"folderTitle": "Klasörü Paylaş",
"generateLink": "Bağlantı Oluştur",
"linkDescriptionFile": "Dosyayı paylaşmak için özel bağlantı oluşturun",
"linkDescriptionFolder": "Klasörü paylaşmak için özel bağlantı oluşturun",
"linkReady": "Paylaşım bağlantınız hazır:",
"linkTitle": "Bağlantı Oluştur"
},
"shareDetails": {
"title": "Paylaşım Detayları",
@@ -1447,7 +1520,8 @@
"files": "dosya",
"totalSize": "Toplam boyut",
"creating": "Oluşturuluyor...",
"create": "Paylaşım Oluştur"
"create": "Paylaşım Oluştur",
"itemsToShare": "Paylaşılacak öğeler ({count} {count, plural, =1 {öğe} other {öğe}})"
},
"shareSecurity": {
"subtitle": "Bu paylaşım için şifre koruması ve güvenlik seçeneklerini yapılandırın",
@@ -1552,7 +1626,8 @@
"download": "Seçili indir"
},
"selectAll": "Tümünü seç",
"selectShare": "Paylaşım {shareName} seç"
"selectShare": "Paylaşım {shareName} seç",
"folderCount": "klasörler"
},
"storageUsage": {
"title": "Depolama Kullanımı",

View File

@@ -144,7 +144,13 @@
"update": "更新",
"click": "点击",
"creating": "创建中...",
"loadingSimple": "加载中..."
"loadingSimple": "加载中...",
"create": "创建",
"deleting": "删除中...",
"move": "移动",
"rename": "重命名",
"search": "搜索",
"share": "分享"
},
"createShare": {
"title": "创建分享",
@@ -160,7 +166,13 @@
"create": "创建分享",
"success": "分享创建成功",
"error": "创建分享失败",
"namePlaceholder": "输入分享名称"
"namePlaceholder": "输入分享名称",
"nextSelectFiles": "下一步:选择文件",
"searchLabel": "搜索",
"tabs": {
"shareDetails": "分享详情",
"selectFiles": "选择文件"
}
},
"customization": {
"breadcrumb": "自定义",
@@ -214,7 +226,9 @@
},
"deleteConfirmation": {
"filesToDelete": "要删除的文件",
"sharesToDelete": "将被删除的共享"
"sharesToDelete": "将被删除的共享",
"foldersToDelete": "要删除的文件夹",
"itemsToDelete": "要删除的项目"
},
"downloadQueue": {
"downloadQueued": "已加入下载队列:{fileName}",
@@ -322,7 +336,8 @@
"previewFile": "预览文件",
"addToShare": "添加到共享",
"removeFromShare": "从共享中移除",
"saveChanges": "保存更改"
"saveChanges": "保存更改",
"editFolder": "编辑文件夹"
},
"files": {
"title": "所有文件",
@@ -347,7 +362,18 @@
},
"totalFiles": "{count, plural, =0 {无文件} =1 {1个文件} other {#个文件}}",
"bulkDeleteConfirmation": "您确定要删除 {count, plural, =1 {1 个文件} other {# 个文件}}吗?此操作无法撤销。",
"bulkDeleteTitle": "删除所选文件"
"bulkDeleteTitle": "删除所选文件",
"actions": {
"open": "打开",
"rename": "重命名",
"delete": "删除"
},
"empty": {
"title": "还没有文件或文件夹",
"description": "上传您的第一个文件或创建文件夹以开始使用"
},
"files": "文件",
"folders": "文件夹"
},
"filesTable": {
"ariaLabel": "文件表格",
@@ -377,6 +403,33 @@
"delete": "删除选中项"
}
},
"folderActions": {
"editFolder": "编辑文件夹",
"folderName": "文件夹名称",
"folderNamePlaceholder": "输入文件夹名称",
"folderDescription": "描述",
"folderDescriptionPlaceholder": "输入文件夹描述(可选)",
"createFolder": "创建新文件夹",
"renameFolder": "重命名文件夹",
"moveFolder": "移动文件夹",
"shareFolder": "分享文件夹",
"deleteFolder": "删除文件夹",
"moveTo": "移动到",
"selectDestination": "选择目标文件夹",
"rootFolder": "根目录",
"folderCreated": "文件夹创建成功",
"folderRenamed": "文件夹重命名成功",
"folderMoved": "文件夹移动成功",
"folderDeleted": "文件夹删除成功",
"folderShared": "文件夹分享成功",
"createFolderError": "创建文件夹时出错",
"renameFolderError": "重命名文件夹时出错",
"moveFolderError": "移动文件夹时出错",
"deleteFolderError": "删除文件夹时出错",
"shareFolderError": "分享文件夹时出错",
"deleteConfirmation": "您确定要删除此文件夹吗?",
"deleteWarning": "此操作无法撤销。"
},
"footer": {
"poweredBy": "技术支持:",
"kyanHomepage": "Kyantech 主页"
@@ -478,6 +531,13 @@
"removeFailed": "Logo删除失败"
}
},
"moveItems": {
"itemsToMove": "要移动的项目:",
"movingTo": "移动到:",
"title": "移动 {count, plural, =1 {项目} other {项目}}",
"description": "将 {count, plural, =1 {项目} other {项目}} 移动到新位置",
"success": "成功移动了 {count} 个项目"
},
"navbar": {
"logoAlt": "应用Logo",
"profileMenu": "个人菜单",
@@ -1106,7 +1166,10 @@
},
"searchBar": {
"placeholder": "搜索文件...",
"results": "共{total}个文件,找到{filtered}个"
"results": "共{total}个文件,找到{filtered}个",
"placeholderFolders": "搜索文件夹...",
"noResults": "未找到 \"{query}\" 的搜索结果",
"placeholderFiles": "搜索文件..."
},
"settings": {
"groups": {
@@ -1320,7 +1383,17 @@
"descriptionLabel": "描述",
"bulkDeleteConfirmation": "您确定要删除{count, plural, =1 {1个共享} other {#个共享}}吗?此操作无法撤销。",
"bulkDeleteTitle": "删除选中的共享",
"addDescriptionPlaceholder": "添加描述..."
"addDescriptionPlaceholder": "添加描述...",
"aliasLabel": "链接别名",
"aliasPlaceholder": "输入自定义别名",
"copyLink": "复制链接",
"fileTitle": "分享文件",
"folderTitle": "分享文件夹",
"generateLink": "生成链接",
"linkDescriptionFile": "生成自定义链接以分享文件",
"linkDescriptionFolder": "生成自定义链接以分享文件夹",
"linkReady": "您的分享链接已准备好:",
"linkTitle": "生成链接"
},
"shareDetails": {
"title": "共享详情",
@@ -1447,7 +1520,8 @@
"files": "文件",
"totalSize": "总大小",
"creating": "创建中...",
"create": "创建分享"
"create": "创建分享",
"itemsToShare": "要分享的项目({count} 个项目)"
},
"shareSecurity": {
"subtitle": "为此分享配置密码保护和安全选项",
@@ -1552,7 +1626,8 @@
"download": "选择下载"
},
"selectAll": "全选",
"selectShare": "选择共享 {shareName}"
"selectShare": "选择共享 {shareName}",
"folderCount": "文件夹"
},
"storageUsage": {
"title": "存储使用情况",

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { IconDownload, IconEye } from "@tabler/icons-react";
import { IconDownload, IconEye, IconFolder, IconFolderOpen } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { FilePreviewModal } from "@/components/modals/file-preview-modal";
@@ -7,9 +7,39 @@ import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { getFileIcon } from "@/utils/file-icons";
import { formatFileSize } from "@/utils/format-file-size";
import { ShareFilesTableProps } from "../types";
export function ShareFilesTable({ files, onDownload }: ShareFilesTableProps) {
interface ShareFile {
id: string;
name: string;
size: string | number;
objectName: string;
createdAt: string;
}
interface ShareFolder {
id: string;
name: string;
totalSize?: string | number | null;
createdAt: string;
}
interface ShareFilesTableProps {
files?: ShareFile[];
folders?: ShareFolder[];
onDownload: (objectName: string, fileName: string) => Promise<void>;
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
onNavigateToFolder?: (folderId: string) => void;
enableNavigation?: boolean;
}
export function ShareFilesTable({
files = [],
folders = [],
onDownload,
onDownloadFolder,
onNavigateToFolder,
enableNavigation = false,
}: ShareFilesTableProps) {
const t = useTranslations();
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<{ name: string; objectName: string; type?: string } | null>(null);
@@ -36,6 +66,25 @@ export function ShareFilesTable({ files, onDownload }: ShareFilesTableProps) {
setSelectedFile(null);
};
const handleFolderClick = (folderId: string) => {
if (enableNavigation && onNavigateToFolder) {
onNavigateToFolder(folderId);
}
};
const handleFolderDownload = async (folderId: string, folderName: string) => {
if (enableNavigation && onDownloadFolder) {
await onDownloadFolder(folderId, folderName);
} else {
await onDownload(`folder:${folderId}`, folderName);
}
};
const allItems = [
...folders.map((folder) => ({ ...folder, type: "folder" as const })),
...files.map((file) => ({ ...file, type: "file" as const })),
];
return (
<div className="flex flex-col gap-4">
<div className="rounded-lg border shadow-sm overflow-hidden">
@@ -57,26 +106,89 @@ export function ShareFilesTable({ files, onDownload }: ShareFilesTableProps) {
</TableRow>
</TableHeader>
<TableBody>
{files.map((file) => {
const { icon: FileIcon, color } = getFileIcon(file.name);
{allItems.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="h-32 text-center text-muted-foreground">
<div className="flex flex-col items-center gap-2">
<IconFolderOpen className="h-16 w-16 text-muted-foreground/50" />
<p className="font-medium">
{enableNavigation ? "No files or folders" : "No files or folders shared"}
</p>
<p className="text-sm">{enableNavigation ? "This location is empty" : "This share is empty"}</p>
</div>
</TableCell>
</TableRow>
) : (
allItems.map((item) => {
if (item.type === "folder") {
return (
<TableRow key={file.id} className="hover:bg-muted/50 transition-colors border-0">
<TableRow key={`folder-${item.id}`} className="hover:bg-muted/50 transition-colors border-0">
<TableCell className="h-12 px-4 border-0">
<div className="flex items-center gap-2">
<IconFolder className="h-5 w-5 text-blue-600" />
{enableNavigation ? (
<button
className="truncate max-w-[250px] font-medium text-left hover:underline"
onClick={() => handleFolderClick(item.id)}
>
{item.name}
</button>
) : (
<span className="truncate max-w-[250px] font-medium">{item.name}</span>
)}
</div>
</TableCell>
<TableCell className="h-12 px-4">
{item.totalSize ? formatFileSize(Number(item.totalSize)) : "—"}
</TableCell>
<TableCell className="h-12 px-4">{formatDateTime(item.createdAt)}</TableCell>
<TableCell className="h-12 px-4">
<div className="flex items-center gap-1">
{enableNavigation && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 hover:bg-muted"
onClick={() => handleFolderClick(item.id)}
title="Open folder"
>
<IconFolder className="h-4 w-4" />
<span className="sr-only">Open folder</span>
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8 hover:bg-muted"
onClick={() => handleFolderDownload(item.id, item.name)}
title={t("filesTable.actions.download")}
>
<IconDownload className="h-4 w-4" />
<span className="sr-only">Download folder</span>
</Button>
</div>
</TableCell>
</TableRow>
);
} else {
const { icon: FileIcon, color } = getFileIcon(item.name);
return (
<TableRow key={`file-${item.id}`} className="hover:bg-muted/50 transition-colors border-0">
<TableCell className="h-12 px-4 border-0">
<div className="flex items-center gap-2">
<FileIcon className={`h-5 w-5 ${color}`} />
<span className="truncate max-w-[250px] font-medium">{file.name}</span>
<span className="truncate max-w-[250px] font-medium">{item.name}</span>
</div>
</TableCell>
<TableCell className="h-12 px-4">{formatFileSize(Number(file.size))}</TableCell>
<TableCell className="h-12 px-4">{formatDateTime(file.createdAt)}</TableCell>
<TableCell className="h-12 px-4">{formatFileSize(Number(item.size))}</TableCell>
<TableCell className="h-12 px-4">{formatDateTime(item.createdAt)}</TableCell>
<TableCell className="h-12 px-4">
<div className="flex items-center gap-1">
<Button
size="icon"
variant="ghost"
className="h-8 w-8 hover:bg-muted"
onClick={() => handlePreview({ name: file.name, objectName: file.objectName })}
onClick={() => handlePreview({ name: item.name, objectName: item.objectName })}
>
<IconEye className="h-4 w-4" />
<span className="sr-only">{t("filesTable.actions.preview")}</span>
@@ -85,7 +197,7 @@ export function ShareFilesTable({ files, onDownload }: ShareFilesTableProps) {
size="icon"
variant="ghost"
className="h-8 w-8 hover:bg-muted"
onClick={() => onDownload(file.objectName, file.name)}
onClick={() => onDownload(item.objectName, item.name)}
>
<IconDownload className="h-4 w-4" />
<span className="sr-only">{t("filesTable.actions.download")}</span>
@@ -94,7 +206,9 @@ export function ShareFilesTable({ files, onDownload }: ShareFilesTableProps) {
</TableCell>
</TableRow>
);
})}
}
})
)}
</TableBody>
</Table>
</div>
@@ -103,3 +217,5 @@ export function ShareFilesTable({ files, onDownload }: ShareFilesTableProps) {
</div>
);
}
export const ShareContentTable = ShareFilesTable;

View File

@@ -2,7 +2,7 @@ import { IconLock } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader } from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { PasswordModalProps } from "../types";
@@ -13,7 +13,7 @@ export function PasswordModal({ isOpen, password, isError, onPasswordChange, onS
<Dialog open={isOpen} onOpenChange={() => {}} modal>
<DialogContent>
<DialogHeader className="flex flex-col gap-1">
<h2>{t("share.password.title")}</h2>
<DialogTitle>{t("share.password.title")}</DialogTitle>
<div className="flex items-center gap-2 text-warning text-sm">
<IconLock size={16} />
<p>{t("share.password.protected")}</p>

View File

@@ -1,18 +1,91 @@
import { IconDownload, IconShare } from "@tabler/icons-react";
import { useState } from "react";
import { IconDownload, IconFolderOff, IconShare } from "@tabler/icons-react";
import { format } from "date-fns";
import { useTranslations } from "next-intl";
import { FilesViewManager } from "@/app/files/components/files-view-manager";
import { FilePreviewModal } from "@/components/modals/file-preview-modal";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { ShareDetailsProps } from "../types";
import { ShareFilesTable } from "./files-table";
export function ShareDetails({ share, onDownload, onBulkDownload }: ShareDetailsProps) {
interface File {
id: string;
name: string;
description?: string;
extension: string;
size: number;
objectName: string;
userId: string;
folderId?: string;
createdAt: string;
updatedAt: string;
}
interface Folder {
id: string;
name: string;
description?: string;
objectName: string;
parentId?: string;
userId: string;
createdAt: string;
updatedAt: string;
totalSize?: string;
_count?: {
files: number;
children: number;
};
}
interface ShareDetailsPropsExtended extends Omit<ShareDetailsProps, "onBulkDownload" | "password"> {
onBulkDownload?: () => Promise<void>;
onSelectedItemsBulkDownload?: (files: File[], folders: Folder[]) => Promise<void>;
folders: Folder[];
files: File[];
path: Folder[];
isBrowseLoading: boolean;
searchQuery: string;
navigateToFolder: (folderId?: string) => void;
handleSearch: (query: string) => void;
}
export function ShareDetails({
share,
onDownload,
onBulkDownload,
onSelectedItemsBulkDownload,
folders,
files,
path,
isBrowseLoading,
searchQuery,
navigateToFolder,
handleSearch,
}: ShareDetailsPropsExtended) {
const t = useTranslations();
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<{ name: string; objectName: string; type?: string } | null>(null);
const hasMultipleFiles = share.files && share.files.length > 1;
const shareHasItems = (share.files && share.files.length > 0) || (share.folders && share.folders.length > 0);
const totalShareItems = (share.files?.length || 0) + (share.folders?.length || 0);
const hasMultipleFiles = totalShareItems > 1;
const handleFolderDownload = async (folderId: string, folderName: string) => {
// Use the download handler from the hook which uses toast.promise
await onDownload(`folder:${folderId}`, folderName);
};
return (
<>
<Card>
<CardContent>
<div className="flex flex-col gap-6">
@@ -22,7 +95,7 @@ export function ShareDetails({ share, onDownload, onBulkDownload }: ShareDetails
<IconShare className="w-6 h-6 text-muted-foreground" />
<h1 className="text-2xl font-semibold">{share.name || t("share.details.untitled")}</h1>
</div>
{hasMultipleFiles && (
{shareHasItems && hasMultipleFiles && (
<Button onClick={onBulkDownload} className="flex items-center gap-2">
<IconDownload className="w-4 h-4" />
{t("share.downloadAll")}
@@ -46,9 +119,75 @@ export function ShareDetails({ share, onDownload, onBulkDownload }: ShareDetails
</div>
</div>
<ShareFilesTable files={share.files} onDownload={onDownload} />
<FilesViewManager
files={files}
folders={folders}
searchQuery={searchQuery}
onSearch={handleSearch}
onDownload={onDownload}
onBulkDownload={onSelectedItemsBulkDownload}
isLoading={isBrowseLoading}
isShareMode={true}
emptyStateComponent={() => (
<div className="text-center py-16">
<div className="flex justify-center mb-6">
<IconFolderOff className="h-24 w-24 text-muted-foreground/30" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">{t("fileSelector.noFilesInShare")}</h3>
<p className="text-muted-foreground max-w-sm mx-auto">{t("files.empty.description")}</p>
</div>
)}
breadcrumbs={
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink
className="flex items-center gap-1 cursor-pointer"
onClick={() => navigateToFolder()}
>
<IconShare size={16} />
{t("folderActions.rootFolder")}
</BreadcrumbLink>
</BreadcrumbItem>
{path.map((folder, index) => (
<div key={folder.id} className="contents">
<BreadcrumbSeparator />
<BreadcrumbItem>
{index === path.length - 1 ? (
<BreadcrumbPage>{folder.name}</BreadcrumbPage>
) : (
<BreadcrumbLink className="cursor-pointer" onClick={() => navigateToFolder(folder.id)}>
{folder.name}
</BreadcrumbLink>
)}
</BreadcrumbItem>
</div>
))}
</BreadcrumbList>
</Breadcrumb>
}
onNavigateToFolder={navigateToFolder}
onDownloadFolder={handleFolderDownload}
onPreview={(file) => {
setSelectedFile({ name: file.name, objectName: file.objectName });
setIsPreviewOpen(true);
}}
/>
</div>
</CardContent>
</Card>
{selectedFile && (
<FilePreviewModal
isOpen={isPreviewOpen}
onClose={() => {
setIsPreviewOpen(false);
setSelectedFile(null);
}}
file={selectedFile}
/>
)}
</>
);
}

View File

@@ -1,17 +1,76 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { getShareByAlias } from "@/http/endpoints";
import { getShareByAlias } from "@/http/endpoints/index";
import type { Share } from "@/http/endpoints/shares/types";
import { bulkDownloadWithQueue, downloadFileWithQueue } from "@/utils/download-queue-utils";
import {
bulkDownloadShareWithQueue,
downloadFileWithQueue,
downloadShareFolderWithQueue,
} from "@/utils/download-queue-utils";
const createSlug = (name: string): string => {
return name
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_-]+/g, "-")
.replace(/^-+|-+$/g, "");
};
const createFolderPathSlug = (allFolders: any[], folderId: string): string => {
const path: string[] = [];
let currentId: string | null = folderId;
while (currentId) {
const folder = allFolders.find((f) => f.id === currentId);
if (folder) {
const slug = createSlug(folder.name);
path.unshift(slug || folder.id);
currentId = folder.parentId;
} else {
break;
}
}
return path.join("/");
};
const findFolderByPathSlug = (folders: any[], pathSlug: string): any | null => {
const pathParts = pathSlug.split("/");
let currentFolders = folders.filter((f) => !f.parentId);
let currentFolder: any = null;
for (const slugPart of pathParts) {
currentFolder = currentFolders.find((folder) => {
const slug = createSlug(folder.name);
return slug === slugPart || folder.id === slugPart;
});
if (!currentFolder) return null;
currentFolders = folders.filter((f) => f.parentId === currentFolder.id);
}
return currentFolder;
};
interface ShareBrowseState {
folders: any[];
files: any[];
path: any[];
isLoading: boolean;
error: string | null;
}
export function usePublicShare() {
const t = useTranslations();
const params = useParams();
const searchParams = useSearchParams();
const router = useRouter();
const alias = params?.alias as string;
const [share, setShare] = useState<Share | null>(null);
const [isLoading, setIsLoading] = useState(true);
@@ -19,6 +78,28 @@ export function usePublicShare() {
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false);
const [isPasswordError, setIsPasswordError] = useState(false);
const [browseState, setBrowseState] = useState<ShareBrowseState>({
folders: [],
files: [],
path: [],
isLoading: true,
error: null,
});
const urlFolderSlug = searchParams.get("folder") || null;
const [currentFolderId, setCurrentFolderId] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const getFolderIdFromPathSlug = useCallback((pathSlug: string | null, folders: any[]): string | null => {
if (!pathSlug) return null;
const folder = findFolderByPathSlug(folders, pathSlug);
return folder ? folder.id : null;
}, []);
const getFolderPathSlugFromId = useCallback((folderId: string | null, folders: any[]): string | null => {
if (!folderId) return null;
return createFolderPathSlug(folders, folderId);
}, []);
const loadShare = useCallback(
async (sharePassword?: string) => {
if (!alias) return;
@@ -51,38 +132,196 @@ export function usePublicShare() {
[alias, t]
);
const loadFolderContents = useCallback(
(folderId: string | null) => {
try {
setBrowseState((prev) => ({ ...prev, isLoading: true, error: null }));
if (!share) {
setBrowseState((prev) => ({
...prev,
isLoading: false,
error: "No share data available",
}));
return;
}
const allFiles = share.files || [];
const allFolders = share.folders || [];
const shareFolderIds = new Set(allFolders.map((f) => f.id));
const folders = allFolders.filter((folder: any) => {
if (folderId === null) {
return !folder.parentId || !shareFolderIds.has(folder.parentId);
} else {
return folder.parentId === folderId;
}
});
const files = allFiles.filter((file: any) => (file.folderId || null) === folderId);
const path = [];
if (folderId) {
let currentId = folderId;
while (currentId) {
const folder = allFolders.find((f: any) => f.id === currentId);
if (folder) {
path.unshift(folder);
currentId = (folder as any).parentId;
} else {
break;
}
}
}
setBrowseState({
folders,
files,
path,
isLoading: false,
error: null,
});
} catch (error: any) {
console.error("Error loading folder contents:", error);
setBrowseState((prev) => ({
...prev,
isLoading: false,
error: "Failed to load folder contents",
}));
}
},
[share]
);
const navigateToFolder = useCallback(
(folderId?: string) => {
const targetFolderId = folderId || null;
setCurrentFolderId(targetFolderId);
loadFolderContents(targetFolderId);
const params = new URLSearchParams(searchParams);
if (targetFolderId && share?.folders) {
const folderPathSlug = getFolderPathSlugFromId(targetFolderId, share.folders);
if (folderPathSlug) {
params.set("folder", folderPathSlug);
} else {
params.delete("folder");
}
} else {
params.delete("folder");
}
router.push(`/s/${alias}?${params.toString()}`);
},
[loadFolderContents, searchParams, router, alias, share?.folders, getFolderPathSlugFromId]
);
const handleSearch = useCallback((query: string) => {
setSearchQuery(query);
}, []);
const handlePasswordSubmit = async () => {
await loadShare(password);
};
const handleDownload = async (objectName: string, fileName: string) => {
const handleFolderDownload = async (folderId: string, folderName: string) => {
try {
await downloadFileWithQueue(objectName, fileName, {
onStart: () => toast.success(t("share.messages.downloadStarted")),
onFail: () => toast.error(t("share.errors.downloadFailed")),
if (!share) {
throw new Error("Share data not available");
}
await downloadShareFolderWithQueue(folderId, folderName, share.files || [], share.folders || [], {
silent: true,
showToasts: false,
});
} catch {
// Error already handled in downloadFileWithQueue
} catch (error) {
console.error("Error downloading folder:", error);
throw error;
}
};
const handleDownload = async (objectName: string, fileName: string) => {
try {
if (objectName.startsWith("folder:")) {
const folderId = objectName.replace("folder:", "");
await toast.promise(handleFolderDownload(folderId, fileName), {
loading: t("shareManager.creatingZip"),
success: t("shareManager.zipDownloadSuccess"),
error: t("share.errors.downloadFailed"),
});
} else {
await toast.promise(
downloadFileWithQueue(objectName, fileName, {
silent: true,
showToasts: false,
}),
{
loading: t("share.messages.downloadStarted"),
success: t("shareManager.downloadSuccess"),
error: t("share.errors.downloadFailed"),
}
);
}
} catch {}
};
const handleBulkDownload = async () => {
if (!share || !share.files || share.files.length === 0) {
const totalFiles = share?.files?.length || 0;
const totalFolders = share?.folders?.length || 0;
if (totalFiles === 0 && totalFolders === 0) {
toast.error(t("shareManager.noFilesToDownload"));
return;
}
if (!share) {
toast.error(t("share.errors.loadFailed"));
return;
}
try {
const zipName = `${share.name || t("shareManager.defaultShareName")}.zip`;
toast.promise(
bulkDownloadWithQueue(
share.files.map((file) => ({
// Prepare all items for the share-specific bulk download
const allItems: Array<{
objectName?: string;
name: string;
id?: string;
type?: "file" | "folder";
}> = [];
if (share.files) {
share.files.forEach((file) => {
if (!file.folderId) {
allItems.push({
objectName: file.objectName,
name: file.name,
isReverseShare: false,
})),
zipName
type: "file",
});
}
});
}
if (share.folders) {
const folderIds = new Set(share.folders.map((f) => f.id));
share.folders.forEach((folder) => {
if (!folder.parentId || !folderIds.has(folder.parentId)) {
allItems.push({
id: folder.id,
name: folder.name,
type: "folder",
});
}
});
}
if (allItems.length === 0) {
toast.error(t("shareManager.noFilesToDownload"));
return;
}
toast.promise(
bulkDownloadShareWithQueue(allItems, share.files || [], share.folders || [], zipName, undefined, true).then(
() => {}
),
{
loading: t("shareManager.creatingZip"),
@@ -95,11 +334,100 @@ export function usePublicShare() {
}
};
const handleSelectedItemsBulkDownload = async (files: any[], folders: any[]) => {
if (files.length === 0 && folders.length === 0) {
toast.error(t("shareManager.noFilesToDownload"));
return;
}
if (!share) {
toast.error(t("share.errors.loadFailed"));
return;
}
try {
// Get all file IDs that belong to selected folders
const filesInSelectedFolders = new Set<string>();
for (const folder of folders) {
const folderFiles = share.files?.filter((f) => f.folderId === folder.id) || [];
folderFiles.forEach((f) => filesInSelectedFolders.add(f.id));
// Also check nested folders recursively
const checkNestedFolders = (parentId: string) => {
const nestedFolders = share.folders?.filter((f) => f.parentId === parentId) || [];
for (const nestedFolder of nestedFolders) {
const nestedFiles = share.files?.filter((f) => f.folderId === nestedFolder.id) || [];
nestedFiles.forEach((f) => filesInSelectedFolders.add(f.id));
checkNestedFolders(nestedFolder.id);
}
};
checkNestedFolders(folder.id);
}
const allItems = [
...files
.filter((file) => !filesInSelectedFolders.has(file.id))
.map((file) => ({
objectName: file.objectName,
name: file.name,
type: "file" as const,
})),
// Add only top-level folders (avoid duplicating nested folders)
...folders
.filter((folder) => {
return !folder.parentId || !folders.some((f) => f.id === folder.parentId);
})
.map((folder) => ({
id: folder.id,
name: folder.name,
type: "folder" as const,
})),
];
const zipName = `${share.name || t("shareManager.defaultShareName")}-selected.zip`;
toast.promise(
bulkDownloadShareWithQueue(allItems, share.files || [], share.folders || [], zipName, undefined, false).then(
() => {}
),
{
loading: t("shareManager.creatingZip"),
success: t("shareManager.zipDownloadSuccess"),
error: t("shareManager.zipDownloadError"),
}
);
} catch (error) {
console.error("Error creating ZIP:", error);
toast.error(t("shareManager.zipDownloadError"));
}
};
// Filter content based on search query
const filteredFolders = browseState.folders.filter((folder) =>
folder.name?.toLowerCase().includes(searchQuery.toLowerCase())
);
const filteredFiles = browseState.files.filter((file) =>
file.name?.toLowerCase().includes(searchQuery.toLowerCase())
);
useEffect(() => {
if (alias) {
loadShare();
}, [alias, loadShare]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [alias]);
useEffect(() => {
if (share) {
const resolvedFolderId = getFolderIdFromPathSlug(urlFolderSlug, share.folders || []);
setCurrentFolderId(resolvedFolderId);
loadFolderContents(resolvedFolderId);
}
}, [share, loadFolderContents, urlFolderSlug, getFolderIdFromPathSlug]);
return {
// Original functionality
isLoading,
share,
password,
@@ -109,5 +437,18 @@ export function usePublicShare() {
handlePasswordSubmit,
handleDownload,
handleBulkDownload,
handleSelectedItemsBulkDownload,
// Browse functionality
folders: filteredFolders,
files: filteredFiles,
path: browseState.path,
isBrowseLoading: browseState.isLoading,
browseError: browseState.error,
currentFolderId,
searchQuery,
navigateToFolder,
handleSearch,
reload: () => loadFolderContents(currentFolderId),
};
}

View File

@@ -19,6 +19,14 @@ export default function PublicSharePage() {
handlePasswordSubmit,
handleDownload,
handleBulkDownload,
handleSelectedItemsBulkDownload,
folders,
files,
path,
isBrowseLoading,
searchQuery,
navigateToFolder,
handleSearch,
} = usePublicShare();
if (isLoading) {
@@ -32,7 +40,21 @@ export default function PublicSharePage() {
<main className="flex-1 container mx-auto px-6 py-8">
<div className="max-w-5xl mx-auto space-y-6">
{!isPasswordModalOpen && !share && <ShareNotFound />}
{share && <ShareDetails share={share} onDownload={handleDownload} onBulkDownload={handleBulkDownload} />}
{share && (
<ShareDetails
share={share}
onDownload={handleDownload}
onBulkDownload={handleBulkDownload}
onSelectedItemsBulkDownload={handleSelectedItemsBulkDownload}
folders={folders}
files={files}
path={path}
isBrowseLoading={isBrowseLoading}
searchQuery={searchQuery}
navigateToFolder={navigateToFolder}
handleSearch={handleSearch}
/>
)}
</div>
</main>

View File

@@ -8,11 +8,24 @@ export interface ShareFile {
createdAt: string;
}
export interface ShareFilesTableProps {
files: ShareFile[];
onDownload: (objectName: string, fileName: string) => Promise<void>;
export interface ShareFolder {
id: string;
name: string;
totalSize: string | null;
createdAt: string;
}
export interface ShareFilesTableProps {
files?: ShareFile[];
folders?: ShareFolder[];
onDownload: (objectName: string, fileName: string) => Promise<void>;
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
onNavigateToFolder?: (folderId: string) => void;
enableNavigation?: boolean;
}
export type ShareContentTableProps = ShareFilesTableProps;
export interface PasswordModalProps {
isOpen: boolean;
password: string;
@@ -23,6 +36,7 @@ export interface PasswordModalProps {
export interface ShareDetailsProps {
share: Share;
password?: string;
onDownload: (objectName: string, fileName: string) => Promise<void>;
onBulkDownload?: () => Promise<void>;
}

View File

@@ -8,8 +8,10 @@ import { QrCodeModal } from "@/components/modals/qr-code-modal";
import { ShareActionsModals } from "@/components/modals/share-actions-modals";
import { ShareDetailsModal } from "@/components/modals/share-details-modal";
import { ShareExpirationModal } from "@/components/modals/share-expiration-modal";
import { ShareMultipleFilesModal } from "@/components/modals/share-multiple-files-modal";
import { ShareMultipleItemsModal } from "@/components/modals/share-multiple-items-modal";
import { ShareSecurityModal } from "@/components/modals/share-security-modal";
import { listFiles } from "@/http/endpoints";
import { listFolders } from "@/http/endpoints/folders";
import { SharesModalsProps } from "../types";
export function SharesModals({
@@ -38,7 +40,18 @@ export function SharesModals({
return (
<>
<CreateShareModal isOpen={isCreateModalOpen} onClose={onCloseCreateModal} onSuccess={handleShareSuccess} />
<CreateShareModal
isOpen={isCreateModalOpen}
onClose={onCloseCreateModal}
onSuccess={onSuccess}
getAllFilesAndFolders={async () => {
const [filesResponse, foldersResponse] = await Promise.all([listFiles(), listFolders()]);
return {
files: filesResponse.data.files || [],
folders: foldersResponse.data.folders || [],
};
}}
/>
<ShareActionsModals
shareToDelete={shareManager.shareToDelete}
@@ -55,6 +68,7 @@ export function SharesModals({
onManageRecipients={shareManager.handleManageRecipients}
onSuccess={handleShareSuccess}
onEditFile={fileManager.handleRename}
onEditFolder={shareManager.handleEditFolder}
/>
<QrCodeModal
@@ -109,8 +123,9 @@ export function SharesModals({
onSuccess={handleShareSuccess}
/>
<ShareMultipleFilesModal
<ShareMultipleItemsModal
files={fileManager.filesToShare}
folders={null}
isOpen={!!fileManager.filesToShare}
onClose={() => fileManager.setFilesToShare(null)}
onSuccess={() => {

View File

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

View File

@@ -1,11 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
const url = `${API_BASE_URL}/files`;
export async function GET(req: NextRequest) {
const cookieHeader = req.headers.get("cookie");
const { searchParams } = new URL(req.url);
const queryString = searchParams.toString();
const url = `${API_BASE_URL}/files${queryString ? `?${queryString}` : ""}`;
const apiRes = await fetch(url, {
method: "GET",
headers: {
@@ -34,7 +36,7 @@ export async function POST(req: NextRequest) {
const body = await req.text();
const cookieHeader = req.headers.get("cookie");
const apiRes = await fetch(url, {
const apiRes = await fetch(`${API_BASE_URL}/files`, {
method: "POST",
headers: {
"Content-Type": "application/json",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function GET(req: NextRequest, { params }: { params: Promise<{ shareId: string; folderId: string }> }) {
const cookieHeader = req.headers.get("cookie");
const url = new URL(req.url);
const searchParams = url.searchParams.toString();
const { shareId, folderId } = await params;
const fetchUrl = `${API_BASE_URL}/shares/${shareId}/folders/${folderId}/contents${searchParams ? `?${searchParams}` : ""}`;
const apiRes = await fetch(fetchUrl, {
method: "GET",
headers: {
cookie: cookieHeader || "",
},
redirect: "manual",
});
const resBody = await apiRes.text();
const res = new NextResponse(resBody, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
const setCookie = apiRes.headers.getSetCookie?.() || [];
if (setCookie.length > 0) {
res.headers.set("Set-Cookie", setCookie.join(","));
}
return res;
}

View File

@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function GET(req: NextRequest, { params }: { params: Promise<{ shareId: string; folderId: string }> }) {
const { shareId, folderId } = await params;
const cookieHeader = req.headers.get("cookie");
const url = new URL(req.url);
const searchParams = url.searchParams.toString();
const fetchUrl = `${API_BASE_URL}/shares/${shareId}/folders/${folderId}/download${searchParams ? `?${searchParams}` : ""}`;
const apiRes = await fetch(fetchUrl, {
method: "GET",
headers: {
cookie: cookieHeader || "",
},
redirect: "manual",
});
if (!apiRes.ok) {
const resBody = await apiRes.text();
return new NextResponse(resBody, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
}
const res = new NextResponse(apiRes.body, {
status: apiRes.status,
headers: {
"Content-Type": apiRes.headers.get("Content-Type") || "application/zip",
"Content-Length": apiRes.headers.get("Content-Length") || "",
"Content-Disposition": apiRes.headers.get("Content-Disposition") || "",
"Accept-Ranges": apiRes.headers.get("Accept-Ranges") || "",
"Content-Range": apiRes.headers.get("Content-Range") || "",
},
});
const setCookie = apiRes.headers.getSetCookie?.() || [];
if (setCookie.length > 0) {
res.headers.set("Set-Cookie", setCookie.join(","));
}
return res;
}

View File

@@ -6,7 +6,15 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ sha
const cookieHeader = req.headers.get("cookie");
const body = await req.text();
const { shareId } = await params;
const url = `${API_BASE_URL}/shares/${shareId}/files`;
const requestData = JSON.parse(body);
const itemsBody = {
files: requestData.files || [],
folders: [],
};
const url = `${API_BASE_URL}/shares/${shareId}/items`;
const apiRes = await fetch(url, {
method: "POST",
@@ -14,7 +22,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ sha
"Content-Type": "application/json",
cookie: cookieHeader || "",
},
body,
body: JSON.stringify(itemsBody),
redirect: "manual",
});

View File

@@ -6,7 +6,15 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ s
const cookieHeader = req.headers.get("cookie");
const body = await req.text();
const { shareId } = await params;
const url = `${API_BASE_URL}/shares/${shareId}/files`;
const requestData = JSON.parse(body);
const itemsBody = {
files: requestData.files || [],
folders: [],
};
const url = `${API_BASE_URL}/shares/${shareId}/items`;
const apiRes = await fetch(url, {
method: "DELETE",
@@ -14,7 +22,7 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ s
"Content-Type": "application/json",
cookie: cookieHeader || "",
},
body,
body: JSON.stringify(itemsBody),
redirect: "manual",
});

View File

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

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ shareId: string }> }) {
const cookieHeader = req.headers.get("cookie");
const body = await req.text();
const { shareId } = await params;
const requestData = JSON.parse(body);
const itemsBody = {
files: [],
folders: requestData.folders || [],
};
const url = `${API_BASE_URL}/shares/${shareId}/items`;
const apiRes = await fetch(url, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
cookie: cookieHeader || "",
},
body: JSON.stringify(itemsBody),
redirect: "manual",
});
const resBody = await apiRes.text();
const res = new NextResponse(resBody, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
const setCookie = apiRes.headers.getSetCookie?.() || [];
if (setCookie.length > 0) {
res.headers.set("Set-Cookie", setCookie.join(","));
}
return res;
}

View File

@@ -4,8 +4,11 @@ interface File {
id: string;
name: string;
description?: string;
extension: string;
size: number;
objectName: string;
userId: string;
folderId?: string;
createdAt: string;
updatedAt: string;
}
@@ -42,6 +45,7 @@ export function DashboardFilesView({
return (
<FilesTable
files={files}
folders={[]}
onPreview={onPreview}
onRename={onRename}
onUpdateName={onUpdateName}
@@ -49,9 +53,9 @@ export function DashboardFilesView({
onDownload={onDownload}
onShare={onShare}
onDelete={onDelete}
onBulkDelete={onBulkDelete}
onBulkShare={onBulkShare}
onBulkDownload={onBulkDownload}
onBulkDelete={onBulkDelete ? (files) => onBulkDelete(files) : undefined}
onBulkShare={onBulkShare ? (files) => onBulkShare(files) : undefined}
onBulkDownload={onBulkDownload ? (files) => onBulkDownload(files) : undefined}
setClearSelectionCallback={setClearSelectionCallback}
/>
);

View File

@@ -11,10 +11,11 @@ import { QrCodeModal } from "@/components/modals/qr-code-modal";
import { ShareActionsModals } from "@/components/modals/share-actions-modals";
import { ShareDetailsModal } from "@/components/modals/share-details-modal";
import { ShareExpirationModal } from "@/components/modals/share-expiration-modal";
import { ShareFileModal } from "@/components/modals/share-file-modal";
import { ShareMultipleFilesModal } from "@/components/modals/share-multiple-files-modal";
import { ShareItemModal } from "@/components/modals/share-item-modal";
import { ShareMultipleItemsModal } from "@/components/modals/share-multiple-items-modal";
import { ShareSecurityModal } from "@/components/modals/share-security-modal";
import { UploadFileModal } from "@/components/modals/upload-file-modal";
import { listFiles, listFolders } from "@/http/endpoints";
import { DashboardModalsProps } from "../types";
export function DashboardModals({ modals, fileManager, shareManager, onSuccess }: DashboardModalsProps) {
@@ -41,10 +42,14 @@ export function DashboardModals({ modals, fileManager, shareManager, onSuccess }
onClose={() => fileManager.setPreviewFile(null)}
/>
<ShareFileModal
<ShareItemModal
file={fileManager.fileToShare}
isOpen={!!fileManager.fileToShare}
onClose={() => fileManager.setFileToShare(null)}
folder={fileManager.folderToShare}
isOpen={!!(fileManager.fileToShare || fileManager.folderToShare)}
onClose={() => {
fileManager.setFileToShare(null);
fileManager.setFolderToShare(null);
}}
onSuccess={onSuccess}
/>
@@ -61,11 +66,24 @@ export function DashboardModals({ modals, fileManager, shareManager, onSuccess }
isOpen={fileManager.isBulkDownloadModalOpen}
onClose={() => fileManager.setBulkDownloadModalOpen(false)}
onDownload={(zipName) => {
if (fileManager.filesToDownload) {
fileManager.handleBulkDownloadWithZip(fileManager.filesToDownload, zipName);
if (fileManager.filesToDownload || fileManager.foldersToDownload) {
fileManager.handleBulkDownloadWithZip(fileManager.filesToDownload || [], zipName);
}
}}
fileCount={fileManager.filesToDownload?.length || 0}
items={[
...(fileManager.filesToDownload?.map((file) => ({
id: file.id,
name: file.name,
size: file.size,
type: "file" as const,
})) || []),
...(fileManager.foldersToDownload?.map((folder) => ({
id: folder.id,
name: folder.name,
size: folder.totalSize ? Number(folder.totalSize) : undefined,
type: "folder" as const,
})) || []),
]}
/>
<DeleteConfirmationModal
@@ -87,17 +105,35 @@ export function DashboardModals({ modals, fileManager, shareManager, onSuccess }
itemType="shares"
/>
<ShareMultipleFilesModal
<ShareMultipleItemsModal
files={fileManager.filesToShare}
isOpen={!!fileManager.filesToShare}
onClose={() => fileManager.setFilesToShare(null)}
folders={fileManager.foldersToShare}
isOpen={!!(fileManager.filesToShare || fileManager.foldersToShare)}
onClose={() => {
fileManager.setFilesToShare(null);
fileManager.setFoldersToShare(null);
}}
onSuccess={() => {
fileManager.handleShareBulkSuccess();
onSuccess();
}}
/>
<CreateShareModal isOpen={modals.isCreateModalOpen} onClose={modals.onCloseCreateModal} onSuccess={onSuccess} />
<CreateShareModal
isOpen={modals.isCreateModalOpen}
onClose={modals.onCloseCreateModal}
onSuccess={() => {
modals.onCloseCreateModal();
onSuccess();
}}
getAllFilesAndFolders={async () => {
const [filesResponse, foldersResponse] = await Promise.all([listFiles(), listFolders()]);
return {
files: filesResponse.data.files || [],
folders: foldersResponse.data.folders || [],
};
}}
/>
<ShareActionsModals
shareToDelete={shareManager.shareToDelete}
@@ -113,6 +149,7 @@ export function DashboardModals({ modals, fileManager, shareManager, onSuccess }
onManageFiles={shareManager.handleManageFiles}
onManageRecipients={shareManager.handleManageRecipients}
onEditFile={fileManager.handleRename}
onEditFolder={shareManager.handleEditFolder}
onSuccess={handleShareSuccess}
/>

View File

@@ -1,20 +0,0 @@
import { IconCloudUpload, IconFolder } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import type { EmptyStateProps } from "../types";
export function EmptyState({ onUpload }: EmptyStateProps) {
const t = useTranslations();
return (
<div className="text-center py-6 flex flex-col items-center gap-2">
<IconFolder className="h-8 w-8 text-muted-foreground" />
<p className="text-muted-foreground">{t("emptyState.noFiles")}</p>
<Button variant="default" size="sm" onClick={onUpload}>
<IconCloudUpload className="h-4 w-4" />
{t("emptyState.uploadFile")}
</Button>
</div>
);
}

View File

@@ -1,11 +1,23 @@
import { useTranslations } from "next-intl";
import { Card, CardContent } from "@/components/ui/card";
import { FileListProps } from "../types";
import { EmptyState } from "./empty-state";
import { FilesViewManager } from "./files-view-manager";
import { Header } from "./header";
import { SearchBar } from "./search-bar";
export function FileList({ files, filteredFiles, fileManager, searchQuery, onSearch, onUpload }: FileListProps) {
export function FileList({
files,
filteredFiles,
folders,
filteredFolders,
fileManager,
searchQuery,
onSearch,
onUpload,
}: FileListProps) {
const t = useTranslations();
return (
<Card>
<CardContent>
@@ -13,8 +25,10 @@ export function FileList({ files, filteredFiles, fileManager, searchQuery, onSea
<Header onUpload={onUpload} />
<SearchBar
filteredCount={filteredFiles.length}
filteredFolders={filteredFolders?.length || 0}
searchQuery={searchQuery}
totalFiles={files.length}
totalFolders={folders?.length || 0}
onSearch={onSearch}
/>
@@ -46,7 +60,9 @@ export function FileList({ files, filteredFiles, fileManager, searchQuery, onSea
}}
/>
) : (
<EmptyState onUpload={onUpload} />
<div className="text-center py-6 flex flex-col items-center gap-2">
<p className="text-muted-foreground">{t("files.empty.title")}</p>
</div>
)}
</div>
</CardContent>

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { IconLayoutGrid, IconTable } from "@tabler/icons-react";
import { IconLayoutGrid, IconSearch, IconTable } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { FilesGrid } from "@/components/tables/files-grid";
@@ -11,27 +11,61 @@ interface File {
id: string;
name: string;
description?: string;
extension: string;
size: number;
objectName: string;
userId: string;
folderId?: string;
createdAt: string;
updatedAt: string;
}
interface Folder {
id: string;
name: string;
description?: string;
objectName: string;
parentId?: string;
userId: string;
createdAt: string;
updatedAt: string;
totalSize?: string;
_count?: {
files: number;
children: number;
};
}
interface FilesViewManagerProps {
files: File[];
folders?: Folder[];
searchQuery: string;
onSearch: (query: string) => void;
onPreview: (file: File) => void;
onRename: (file: File) => void;
onUpdateName: (fileId: string, newName: string) => void;
onUpdateDescription: (fileId: string, newDescription: string) => void;
onNavigateToFolder?: (folderId?: string) => void;
onDownload: (objectName: string, fileName: string) => void;
onShare: (file: File) => void;
onDelete: (file: File) => void;
onBulkDelete?: (files: File[]) => void;
onBulkShare?: (files: File[]) => void;
onBulkDownload?: (files: File[]) => void;
breadcrumbs?: React.ReactNode;
isLoading?: boolean;
emptyStateComponent?: React.ComponentType;
isShareMode?: boolean;
onDeleteFolder?: (folder: Folder) => void;
onRenameFolder?: (folder: Folder) => void;
onMoveFolder?: (folder: Folder) => void;
onMoveFile?: (file: File) => void;
onShareFolder?: (folder: Folder) => void;
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
onPreview?: (file: File) => void;
onRename?: (file: File) => void;
onUpdateName?: (fileId: string, newName: string) => void;
onUpdateDescription?: (fileId: string, newDescription: string) => void;
onShare?: (file: File) => void;
onDelete?: (file: File) => void;
onBulkDelete?: (files: File[], folders: Folder[]) => void;
onBulkShare?: (files: File[], folders: Folder[]) => void;
onBulkDownload?: (files: File[], folders: Folder[]) => void;
onBulkMove?: (files: File[], folders: Folder[]) => void;
setClearSelectionCallback?: (callback: () => void) => void;
onUpdateFolderName?: (folderId: string, newName: string) => void;
onUpdateFolderDescription?: (folderId: string, newDescription: string) => void;
}
export type ViewMode = "table" | "grid";
@@ -40,19 +74,34 @@ const VIEW_MODE_KEY = "files-view-mode";
export function FilesViewManager({
files,
folders,
searchQuery,
onSearch,
onNavigateToFolder,
onDownload,
breadcrumbs,
isLoading = false,
emptyStateComponent: EmptyStateComponent,
isShareMode = false,
onDeleteFolder,
onRenameFolder,
onMoveFolder,
onMoveFile,
onShareFolder,
onDownloadFolder,
onPreview,
onRename,
onUpdateName,
onUpdateDescription,
onDownload,
onShare,
onDelete,
onBulkDelete,
onBulkShare,
onBulkDownload,
onBulkMove,
setClearSelectionCallback,
onUpdateFolderName,
onUpdateFolderDescription,
}: FilesViewManagerProps) {
const t = useTranslations();
const [viewMode, setViewMode] = useState<ViewMode>(() => {
@@ -66,31 +115,62 @@ export function FilesViewManager({
localStorage.setItem(VIEW_MODE_KEY, viewMode);
}, [viewMode]);
const commonProps = {
const hasContent = (folders?.length || 0) > 0 || files.length > 0;
const showEmptyState = !hasContent && !searchQuery && !isLoading;
const isFilesMode = !isShareMode && !!(onDeleteFolder || onRenameFolder || onShare || onDelete);
const baseProps = {
files,
folders: folders || [],
onNavigateToFolder,
onDeleteFolder: isShareMode ? undefined : onDeleteFolder,
onRenameFolder: isShareMode ? undefined : onRenameFolder,
onMoveFolder: isShareMode ? undefined : onMoveFolder,
onMoveFile: isShareMode ? undefined : onMoveFile,
onShareFolder: isShareMode ? undefined : onShareFolder,
onDownloadFolder,
onPreview,
onRename,
onUpdateName,
onUpdateDescription,
onRename: isShareMode ? undefined : onRename,
onDownload,
onShare,
onDelete,
onBulkDelete,
onBulkShare,
onShare: isShareMode ? undefined : onShare,
onDelete: isShareMode ? undefined : onDelete,
onBulkDelete: isShareMode ? undefined : onBulkDelete,
onBulkShare: isShareMode ? undefined : onBulkShare,
onBulkDownload,
onBulkMove: isShareMode ? undefined : onBulkMove,
setClearSelectionCallback,
onUpdateFolderName: isShareMode ? undefined : onUpdateFolderName,
onUpdateFolderDescription: isShareMode ? undefined : onUpdateFolderDescription,
showBulkActions: isFilesMode || (isShareMode && !!onBulkDownload),
isShareMode,
};
const tableProps = {
...baseProps,
onUpdateName: isShareMode ? undefined : onUpdateName,
onUpdateDescription: isShareMode ? undefined : onUpdateDescription,
};
const gridProps = baseProps;
return (
<div className="space-y-4">
{/* Breadcrumbs, Search and View Controls */}
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">{breadcrumbs}</div>
<div className="flex items-center gap-4">
<div className="relative">
<IconSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder={t("searchBar.placeholder")}
value={searchQuery}
onChange={(e) => onSearch(e.target.value)}
className="max-w-sm"
className="max-w-sm pl-10"
/>
</div>
<div className="flex items-center border rounded-lg p-1">
<Button
@@ -111,8 +191,33 @@ export function FilesViewManager({
</Button>
</div>
</div>
</div>
{viewMode === "table" ? <FilesTable {...commonProps} /> : <FilesGrid {...commonProps} />}
{isLoading ? (
<div className="text-center py-8">
<div className="animate-spin h-8 w-8 border-2 border-current border-t-transparent rounded-full mx-auto mb-4" />
<p className="text-muted-foreground">Loading...</p>
</div>
) : showEmptyState ? (
EmptyStateComponent ? (
<EmptyStateComponent />
) : (
<div className="text-center py-6 flex flex-col items-center gap-2">
<p className="text-muted-foreground">{t("files.empty.title")}</p>
</div>
)
) : (
<div className="space-y-4">
{viewMode === "table" ? <FilesTable {...tableProps} /> : <FilesGrid {...gridProps} />}
{/* No results message */}
{searchQuery && !hasContent && (
<div className="text-center py-8">
<p className="text-muted-foreground">{t("searchBar.noResults", { query: searchQuery })}</p>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,19 +1,27 @@
import { IconCloudUpload } from "@tabler/icons-react";
import { IconCloudUpload, IconFolderPlus } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import type { HeaderProps } from "../types";
export function Header({ onUpload }: HeaderProps) {
export function Header({ onUpload, onCreateFolder }: HeaderProps) {
const t = useTranslations();
return (
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold">{t("files.title")}</h2>
<div className="flex items-center gap-2">
{onCreateFolder && (
<Button variant="outline" onClick={onCreateFolder}>
<IconFolderPlus className="h-4 w-4" />
{t("folderActions.createFolder")}
</Button>
)}
<Button variant="default" onClick={onUpload}>
<IconCloudUpload className="h-4 w-4" />
{t("files.uploadFile")}
</Button>
</div>
</div>
);
}

View File

@@ -4,9 +4,19 @@ import { useTranslations } from "next-intl";
import { Input } from "@/components/ui/input";
import type { SearchBarProps } from "../types";
export function SearchBar({ searchQuery, onSearch, totalFiles, filteredCount }: SearchBarProps) {
export function SearchBar({
searchQuery,
onSearch,
totalFiles,
totalFolders = 0,
filteredCount,
filteredFolders = 0,
}: SearchBarProps) {
const t = useTranslations();
const totalItems = totalFiles + totalFolders;
const filteredItems = filteredCount + filteredFolders;
return (
<div className="flex items-center gap-2">
<div className="relative max-w-xs">
@@ -20,7 +30,7 @@ export function SearchBar({ searchQuery, onSearch, totalFiles, filteredCount }:
</div>
{searchQuery && (
<span className="text-sm text-muted-foreground">
{t("searchBar.results", { filtered: filteredCount, total: totalFiles })}
{t("searchBar.results", { filtered: filteredItems, total: totalItems })}
</span>
)}
</div>

View File

@@ -0,0 +1,343 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { useEnhancedFileManager } from "@/hooks/use-enhanced-file-manager";
import { listFiles } from "@/http/endpoints";
import { listFolders } from "@/http/endpoints/folders";
const createSlug = (name: string): string => {
return name
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_-]+/g, "-")
.replace(/^-+|-+$/g, "");
};
const createFolderPathSlug = (allFolders: any[], folderId: string): string => {
const path: string[] = [];
let currentId: string | null = folderId;
while (currentId) {
const folder = allFolders.find((f) => f.id === currentId);
if (folder) {
const slug = createSlug(folder.name);
path.unshift(slug || folder.id);
currentId = folder.parentId;
} else {
break;
}
}
return path.join("/");
};
const findFolderByPathSlug = (folders: any[], pathSlug: string): any | null => {
const pathParts = pathSlug.split("/");
let currentFolders = folders.filter((f) => !f.parentId);
let currentFolder: any = null;
for (const slugPart of pathParts) {
currentFolder = currentFolders.find((folder) => {
const slug = createSlug(folder.name);
return slug === slugPart || folder.id === slugPart;
});
if (!currentFolder) return null;
currentFolders = folders.filter((f) => f.parentId === currentFolder.id);
}
return currentFolder;
};
export function useFileBrowser() {
const t = useTranslations();
const router = useRouter();
const searchParams = useSearchParams();
const [files, setFiles] = useState<any[]>([]);
const [folders, setFolders] = useState<any[]>([]);
const [allFiles, setAllFiles] = useState<any[]>([]);
const [allFolders, setAllFolders] = useState<any[]>([]);
const [currentPath, setCurrentPath] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
const [clearSelectionCallback, setClearSelectionCallbackState] = useState<(() => void) | undefined>();
const [dataLoaded, setDataLoaded] = useState(false);
const isNavigatingRef = useRef(false);
const urlFolderSlug = searchParams.get("folder") || null;
const [currentFolderId, setCurrentFolderId] = useState<string | null>(null);
const setClearSelectionCallback = useCallback((callback: () => void) => {
setClearSelectionCallbackState(() => callback);
}, []);
const getFolderIdFromPathSlug = useCallback((pathSlug: string | null, folders: any[]): string | null => {
if (!pathSlug) return null;
const folder = findFolderByPathSlug(folders, pathSlug);
return folder ? folder.id : null;
}, []);
const getFolderPathSlugFromId = useCallback((folderId: string | null, folders: any[]): string | null => {
if (!folderId) return null;
return createFolderPathSlug(folders, folderId);
}, []);
const buildBreadcrumbPath = useCallback((allFolders: any[], folderId: string): any[] => {
const path: any[] = [];
let currentId: string | null = folderId;
while (currentId) {
const folder = allFolders.find((f) => f.id === currentId);
if (folder) {
path.unshift(folder);
currentId = folder.parentId;
} else {
break;
}
}
return path;
}, []);
const buildFolderPath = useCallback((allFolders: any[], folderId: string | null): string => {
if (!folderId) return "";
const pathParts: string[] = [];
let currentId: string | null = folderId;
while (currentId) {
const folder = allFolders.find((f) => f.id === currentId);
if (folder) {
pathParts.unshift(folder.name);
currentId = folder.parentId;
} else {
break;
}
}
return pathParts.join(" / ");
}, []);
const navigateToFolderDirect = useCallback(
(targetFolderId: string | null) => {
const currentFiles = allFiles.filter((file: any) => (file.folderId || null) === targetFolderId);
const currentFolders = allFolders.filter((folder: any) => (folder.parentId || null) === targetFolderId);
const sortedFiles = [...currentFiles].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
const sortedFolders = [...currentFolders].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
setFiles(sortedFiles);
setFolders(sortedFolders);
if (targetFolderId) {
const path = buildBreadcrumbPath(allFolders, targetFolderId);
setCurrentPath(path);
} else {
setCurrentPath([]);
}
const params = new URLSearchParams(searchParams);
if (targetFolderId) {
const folderPathSlug = getFolderPathSlugFromId(targetFolderId, allFolders);
if (folderPathSlug) {
params.set("folder", folderPathSlug);
} else {
params.delete("folder");
}
} else {
params.delete("folder");
}
window.history.pushState({}, "", `/files?${params.toString()}`);
},
[allFiles, allFolders, buildBreadcrumbPath, searchParams, getFolderPathSlugFromId]
);
const navigateToFolder = useCallback(
(folderId?: string) => {
const targetFolderId = folderId || null;
if (dataLoaded && allFiles.length > 0) {
isNavigatingRef.current = true;
navigateToFolderDirect(targetFolderId);
setTimeout(() => {
isNavigatingRef.current = false;
}, 0);
} else {
const params = new URLSearchParams(searchParams);
if (folderId) {
const folderPathSlug = getFolderPathSlugFromId(folderId, allFolders);
if (folderPathSlug) {
params.set("folder", folderPathSlug);
} else {
params.delete("folder");
}
} else {
params.delete("folder");
}
router.push(`/files?${params.toString()}`);
}
},
[dataLoaded, allFiles.length, navigateToFolderDirect, searchParams, router, getFolderPathSlugFromId, allFolders]
);
const navigateToRoot = useCallback(() => {
navigateToFolder();
}, [navigateToFolder]);
const loadFiles = useCallback(async () => {
try {
setIsLoading(true);
const [filesResponse, foldersResponse] = await Promise.all([listFiles(), listFolders()]);
const fetchedFiles = filesResponse.data.files || [];
const fetchedFolders = foldersResponse.data.folders || [];
setAllFiles(fetchedFiles);
setAllFolders(fetchedFolders);
setDataLoaded(true);
const resolvedFolderId = getFolderIdFromPathSlug(urlFolderSlug, fetchedFolders);
setCurrentFolderId(resolvedFolderId);
const currentFiles = fetchedFiles.filter((file: any) => (file.folderId || null) === resolvedFolderId);
const currentFolders = fetchedFolders.filter((folder: any) => (folder.parentId || null) === resolvedFolderId);
const sortedFiles = [...currentFiles].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
const sortedFolders = [...currentFolders].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
setFiles(sortedFiles);
setFolders(sortedFolders);
if (resolvedFolderId) {
const path = buildBreadcrumbPath(fetchedFolders, resolvedFolderId);
setCurrentPath(path);
} else {
setCurrentPath([]);
}
} catch {
toast.error(t("files.loadError"));
} finally {
setIsLoading(false);
}
}, [urlFolderSlug, buildBreadcrumbPath, t, getFolderIdFromPathSlug]);
const fileManager = useEnhancedFileManager(loadFiles, clearSelectionCallback);
const getImmediateChildFoldersWithMatches = useCallback(() => {
if (!searchQuery) return [];
const matchingItems = new Set<string>();
allFiles
.filter((file: any) => file.name.toLowerCase().includes(searchQuery.toLowerCase()))
.forEach((file: any) => {
if (file.folderId) {
let currentId = file.folderId;
while (currentId) {
const folder = allFolders.find((f: any) => f.id === currentId);
if (folder) {
if ((folder.parentId || null) === currentFolderId) {
matchingItems.add(folder.id);
break;
}
currentId = folder.parentId;
} else {
break;
}
}
}
});
allFolders
.filter((folder: any) => folder.name.toLowerCase().includes(searchQuery.toLowerCase()))
.forEach((folder: any) => {
let currentId = folder.id;
while (currentId) {
const folderInPath = allFolders.find((f: any) => f.id === currentId);
if (folderInPath) {
if ((folderInPath.parentId || null) === currentFolderId) {
matchingItems.add(folderInPath.id);
break;
}
currentId = folderInPath.parentId;
} else {
break;
}
}
});
return allFolders
.filter((folder: any) => matchingItems.has(folder.id))
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}, [searchQuery, allFiles, allFolders, currentFolderId]);
const filteredFiles = searchQuery
? allFiles
.filter(
(file: any) =>
file.name.toLowerCase().includes(searchQuery.toLowerCase()) && (file.folderId || null) === currentFolderId
)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
: files;
const filteredFolders = searchQuery ? getImmediateChildFoldersWithMatches() : folders;
useEffect(() => {
if (!isNavigatingRef.current) {
loadFiles();
}
}, [loadFiles]);
return {
isLoading,
files,
folders,
currentPath,
currentFolderId,
searchQuery,
navigateToFolder,
navigateToRoot,
modals: {
isUploadModalOpen,
onOpenUploadModal: () => setIsUploadModalOpen(true),
onCloseUploadModal: () => setIsUploadModalOpen(false),
},
fileManager: {
...fileManager,
setClearSelectionCallback,
} as typeof fileManager & { setClearSelectionCallback: typeof setClearSelectionCallback },
filteredFiles,
filteredFolders,
handleSearch: setSearchQuery,
loadFiles,
allFiles,
allFolders,
buildFolderPath,
};
}
export const useFiles = useFileBrowser;

View File

@@ -1,62 +0,0 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { useEnhancedFileManager } from "@/hooks/use-enhanced-file-manager";
import { listFiles } from "@/http/endpoints";
export function useFiles() {
const t = useTranslations();
const [files, setFiles] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
const [clearSelectionCallback, setClearSelectionCallbackState] = useState<(() => void) | undefined>();
const setClearSelectionCallback = useCallback((callback: () => void) => {
setClearSelectionCallbackState(() => callback);
}, []);
const loadFiles = useCallback(async () => {
try {
const response = await listFiles();
const allFiles = response.data.files || [];
const sortedFiles = [...allFiles].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
setFiles(sortedFiles);
} catch {
toast.error(t("files.loadError"));
} finally {
setIsLoading(false);
}
}, [t]);
const fileManager = useEnhancedFileManager(loadFiles, clearSelectionCallback);
const filteredFiles = files.filter((file) => file.name.toLowerCase().includes(searchQuery.toLowerCase()));
useEffect(() => {
loadFiles();
}, [loadFiles]);
return {
isLoading,
files,
searchQuery,
modals: {
isUploadModalOpen,
onOpenUploadModal: () => setIsUploadModalOpen(true),
onCloseUploadModal: () => setIsUploadModalOpen(false),
},
fileManager: {
...fileManager,
setClearSelectionCallback,
} as typeof fileManager & { setClearSelectionCallback: typeof setClearSelectionCallback },
filteredFiles,
handleSearch: setSearchQuery,
loadFiles,
};
}

View File

@@ -1,20 +1,46 @@
import { useTranslations } from "next-intl";
import { FolderActionsModals } from "@/components/modals";
import { BulkDownloadModal } from "@/components/modals/bulk-download-modal";
import { DeleteConfirmationModal } from "@/components/modals/delete-confirmation-modal";
import { FileActionsModals } from "@/components/modals/file-actions-modals";
import { FilePreviewModal } from "@/components/modals/file-preview-modal";
import { ShareFileModal } from "@/components/modals/share-file-modal";
import { ShareMultipleFilesModal } from "@/components/modals/share-multiple-files-modal";
import { ShareItemModal } from "@/components/modals/share-item-modal";
import { ShareMultipleItemsModal } from "@/components/modals/share-multiple-items-modal";
import { UploadFileModal } from "@/components/modals/upload-file-modal";
import type { FilesModalsProps } from "../types";
export function FilesModals({ fileManager, modals, onSuccess }: FilesModalsProps) {
export function FilesModals({
fileManager,
modals,
onSuccess,
currentFolderId,
}: FilesModalsProps & { currentFolderId?: string | null }) {
const t = useTranslations();
return (
<>
<UploadFileModal isOpen={modals.isUploadModalOpen} onClose={modals.onCloseUploadModal} onSuccess={onSuccess} />
<UploadFileModal
isOpen={modals.isUploadModalOpen}
onClose={modals.onCloseUploadModal}
onSuccess={onSuccess}
currentFolderId={currentFolderId || undefined}
/>
{/* Folder Modals */}
<FolderActionsModals
folderToCreate={fileManager.isCreateFolderModalOpen}
onCloseCreate={() => fileManager.setCreateFolderModalOpen(false)}
onCreateFolder={(name, description) =>
fileManager.handleCreateFolder({ name, description }, currentFolderId || undefined)
}
folderToEdit={fileManager.folderToRename}
onCloseEdit={() => fileManager.setFolderToRename(null)}
onEditFolder={fileManager.handleFolderRename}
folderToDelete={fileManager.folderToDelete}
onCloseDelete={() => fileManager.setFolderToDelete(null)}
onDeleteFolder={fileManager.handleFolderDelete}
/>
<FilePreviewModal
file={fileManager.previewFile || { name: "", objectName: "" }}
@@ -22,10 +48,14 @@ export function FilesModals({ fileManager, modals, onSuccess }: FilesModalsProps
onClose={() => fileManager.setPreviewFile(null)}
/>
<ShareFileModal
<ShareItemModal
file={fileManager.fileToShare}
isOpen={!!fileManager.fileToShare}
onClose={() => fileManager.setFileToShare(null)}
folder={fileManager.folderToShare}
isOpen={!!(fileManager.fileToShare || fileManager.folderToShare)}
onClose={() => {
fileManager.setFileToShare(null);
fileManager.setFolderToShare(null);
}}
onSuccess={onSuccess}
/>
@@ -47,22 +77,52 @@ export function FilesModals({ fileManager, modals, onSuccess }: FilesModalsProps
fileManager.handleBulkDownloadWithZip(fileManager.filesToDownload, zipName);
}
}}
fileCount={fileManager.filesToDownload?.length || 0}
items={[
...(fileManager.filesToDownload?.map((file) => ({
id: file.id,
name: file.name,
size: file.size,
type: "file" as const,
})) || []),
...(fileManager.foldersToDownload?.map((folder) => ({
id: folder.id,
name: folder.name,
size: folder.totalSize ? parseInt(folder.totalSize) : undefined,
type: "folder" as const,
})) || []),
]}
/>
<DeleteConfirmationModal
isOpen={!!fileManager.filesToDelete}
onClose={() => fileManager.setFilesToDelete(null)}
isOpen={!!(fileManager.filesToDelete || fileManager.foldersToDelete)}
onClose={() => {
fileManager.setFilesToDelete(null);
fileManager.setFoldersToDelete(null);
}}
onConfirm={fileManager.handleDeleteBulk}
title={t("files.bulkDeleteTitle")}
description={t("files.bulkDeleteConfirmation", { count: fileManager.filesToDelete?.length || 0 })}
description={t("files.bulkDeleteConfirmation", {
count: (fileManager.filesToDelete?.length || 0) + (fileManager.foldersToDelete?.length || 0),
})}
files={fileManager.filesToDelete?.map((f) => f.name) || []}
folders={fileManager.foldersToDelete?.map((f) => f.name) || []}
itemType={
(fileManager.filesToDelete?.length || 0) > 0 && (fileManager.foldersToDelete?.length || 0) > 0
? "mixed"
: (fileManager.foldersToDelete?.length || 0) > 0
? "files"
: "files"
}
/>
<ShareMultipleFilesModal
<ShareMultipleItemsModal
files={fileManager.filesToShare}
isOpen={!!fileManager.filesToShare}
onClose={() => fileManager.setFilesToShare(null)}
folders={fileManager.foldersToShare}
isOpen={!!(fileManager.filesToShare || fileManager.foldersToShare)}
onClose={() => {
fileManager.setFilesToShare(null);
fileManager.setFoldersToShare(null);
}}
onSuccess={() => {
fileManager.handleShareBulkSuccess();
onSuccess();

View File

@@ -1,27 +1,117 @@
"use client";
import { useState } from "react";
import { IconFolderOpen } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { ProtectedRoute } from "@/components/auth/protected-route";
import { GlobalDropZone } from "@/components/general/global-drop-zone";
import { FileManagerLayout } from "@/components/layout/file-manager-layout";
import { LoadingScreen } from "@/components/layout/loading-screen";
import { MoveItemsModal } from "@/components/modals/move-items-modal";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Card, CardContent } from "@/components/ui/card";
import { moveFile } from "@/http/endpoints/files";
import { listFolders, moveFolder } from "@/http/endpoints/folders";
import { FilesViewManager } from "./components/files-view-manager";
import { Header } from "./components/header";
import { useFiles } from "./hooks/use-files";
import { useFileBrowser } from "./hooks/use-file-browser";
import { FilesModals } from "./modals/files-modals";
interface File {
id: string;
name: string;
description?: string;
extension: string;
size: number;
objectName: string;
userId: string;
folderId?: string;
createdAt: string;
updatedAt: string;
}
interface Folder {
id: string;
name: string;
description?: string;
objectName: string;
parentId?: string;
userId: string;
createdAt: string;
updatedAt: string;
totalSize?: string;
_count?: {
files: number;
children: number;
};
}
export default function FilesPage() {
const t = useTranslations();
const [itemsToMove, setItemsToMove] = useState<{ files: File[]; folders: Folder[] } | null>(null);
const { isLoading, searchQuery, modals, fileManager, filteredFiles, handleSearch, loadFiles } = useFiles();
const {
isLoading,
searchQuery,
currentPath,
fileManager,
filteredFiles,
filteredFolders,
navigateToFolder,
navigateToRoot,
handleSearch,
loadFiles,
modals,
} = useFileBrowser();
if (isLoading) {
return <LoadingScreen />;
const handleMoveFile = (file: any) => {
setItemsToMove({ files: [file], folders: [] });
};
const handleMoveFolder = (folder: any) => {
setItemsToMove({ files: [], folders: [folder] });
};
const handleBulkMove = (files: File[], folders: Folder[]) => {
setItemsToMove({ files, folders });
};
const handleMove = async (targetFolderId: string | null) => {
if (!itemsToMove) return;
try {
if (itemsToMove.files.length > 0) {
await Promise.all(itemsToMove.files.map((file) => moveFile(file.id, { folderId: targetFolderId })));
}
if (itemsToMove.folders.length > 0) {
await Promise.all(itemsToMove.folders.map((folder) => moveFolder(folder.id, { parentId: targetFolderId })));
}
const itemCount = itemsToMove.files.length + itemsToMove.folders.length;
toast.success(t("moveItems.success", { count: itemCount }));
await loadFiles();
setItemsToMove(null);
} catch (error) {
console.error("Error moving items:", error);
toast.error("Failed to move items. Please try again.");
}
};
const handleUploadSuccess = async () => {
await loadFiles();
toast.success("Files uploaded successfully");
};
return (
<ProtectedRoute>
<GlobalDropZone onSuccess={loadFiles}>
@@ -35,19 +125,79 @@ export default function FilesPage() {
<Card>
<CardContent>
<div className="flex flex-col gap-6">
<Header onUpload={modals.onOpenUploadModal} />
<Header
onUpload={modals.onOpenUploadModal}
onCreateFolder={() => fileManager.setCreateFolderModalOpen(true)}
/>
<FilesViewManager
files={filteredFiles}
folders={filteredFolders}
searchQuery={searchQuery}
onSearch={handleSearch}
onDelete={fileManager.setFileToDelete}
onDownload={fileManager.handleDownload}
isLoading={isLoading}
breadcrumbs={
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink className="flex items-center gap-1 cursor-pointer" onClick={navigateToRoot}>
<IconFolderOpen size={16} />
{t("folderActions.rootFolder")}
</BreadcrumbLink>
</BreadcrumbItem>
{currentPath.map((folder, index) => (
<div key={folder.id} className="contents">
<BreadcrumbSeparator />
<BreadcrumbItem>
{index === currentPath.length - 1 ? (
<BreadcrumbPage>{folder.name}</BreadcrumbPage>
) : (
<BreadcrumbLink className="cursor-pointer" onClick={() => navigateToFolder(folder.id)}>
{folder.name}
</BreadcrumbLink>
)}
</BreadcrumbItem>
</div>
))}
</BreadcrumbList>
</Breadcrumb>
}
onNavigateToFolder={navigateToFolder}
onDeleteFolder={(folder) =>
fileManager.setFolderToDelete({
id: folder.id,
name: folder.name,
})
}
onRenameFolder={(folder) =>
fileManager.setFolderToRename({
id: folder.id,
name: folder.name,
description: folder.description || undefined,
})
}
onMoveFolder={handleMoveFolder}
onMoveFile={handleMoveFile}
onShareFolder={fileManager.setFolderToShare}
onDownloadFolder={fileManager.handleSingleFolderDownload}
onPreview={fileManager.setPreviewFile}
onRename={fileManager.setFileToRename}
onShare={fileManager.setFileToShare}
onBulkDelete={fileManager.handleBulkDelete}
onBulkShare={fileManager.handleBulkShare}
onBulkDownload={fileManager.handleBulkDownload}
onDelete={fileManager.setFileToDelete}
onBulkDelete={(files, folders) => {
fileManager.handleBulkDelete(files, folders);
}}
onBulkShare={(files, folders) => {
// Use enhanced bulk share that handles both files and folders
fileManager.handleBulkShare(files, folders);
}}
onBulkDownload={(files, folders) => {
// Use enhanced bulk download that handles both files and folders
fileManager.handleBulkDownload(files, folders);
}}
onBulkMove={handleBulkMove}
setClearSelectionCallback={fileManager.setClearSelectionCallback}
onUpdateName={(fileId, newName) => {
const file = filteredFiles.find((f) => f.id === fileId);
@@ -61,12 +211,48 @@ export default function FilesPage() {
fileManager.handleRename(fileId, file.name, newDescription);
}
}}
onUpdateFolderName={(folderId, newName) => {
const folder = filteredFolders.find((f) => f.id === folderId);
if (folder) {
fileManager.handleFolderRename(folderId, newName, folder.description);
}
}}
onUpdateFolderDescription={(folderId, newDescription) => {
const folder = filteredFolders.find((f) => f.id === folderId);
if (folder) {
fileManager.handleFolderRename(folderId, folder.name, newDescription);
}
}}
emptyStateComponent={() => (
<div className="flex flex-col items-center justify-center py-12 text-center">
<IconFolderOpen size={48} className="text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">{t("files.empty.title")}</h3>
<p className="text-muted-foreground mb-6">{t("files.empty.description")}</p>
</div>
)}
/>
</div>
</CardContent>
</Card>
<FilesModals fileManager={fileManager} modals={modals} onSuccess={loadFiles} />
<FilesModals
fileManager={fileManager}
modals={modals}
onSuccess={handleUploadSuccess}
currentFolderId={currentPath.length > 0 ? currentPath[currentPath.length - 1].id : null}
/>
<MoveItemsModal
isOpen={!!itemsToMove}
onClose={() => setItemsToMove(null)}
onMove={handleMove}
itemsToMove={itemsToMove}
getAllFolders={async () => {
const response = await listFolders();
return response.data.folders || [];
}}
currentFolderId={currentPath.length > 0 ? currentPath[currentPath.length - 1].id : null}
/>
</FileManagerLayout>
</GlobalDropZone>
</ProtectedRoute>

View File

@@ -1,16 +1,15 @@
import { EnhancedFileManagerHook } from "@/hooks/use-enhanced-file-manager";
export interface EmptyStateProps {
onUpload: () => void;
}
export interface HeaderProps {
onUpload: () => void;
onCreateFolder?: () => void;
}
export interface FileListProps {
files: any[];
filteredFiles: any[];
folders?: any[];
filteredFolders?: any[];
fileManager: EnhancedFileManagerHook;
searchQuery: string;
onSearch: (query: string) => void;
@@ -21,7 +20,9 @@ export interface SearchBarProps {
searchQuery: string;
onSearch: (query: string) => void;
totalFiles: number;
totalFolders?: number;
filteredCount: number;
filteredFolders?: number;
}
export interface FilesModalsProps {

View File

@@ -10,12 +10,31 @@ interface File {
id: string;
name: string;
description?: string;
extension: string;
size: number;
objectName: string;
userId: string;
folderId?: string;
createdAt: string;
updatedAt: string;
}
interface Folder {
id: string;
name: string;
description?: string;
objectName: string;
parentId?: string;
userId: string;
createdAt: string;
updatedAt: string;
totalSize?: string;
_count?: {
files: number;
children: number;
};
}
interface FilesViewProps {
files: File[];
onPreview: (file: File) => void;
@@ -25,9 +44,9 @@ interface FilesViewProps {
onDownload: (objectName: string, fileName: string) => void;
onShare: (file: File) => void;
onDelete: (file: File) => void;
onBulkDelete?: (files: File[]) => void;
onBulkShare?: (files: File[]) => void;
onBulkDownload?: (files: File[]) => void;
onBulkDelete?: (files: File[], folders: Folder[]) => void;
onBulkShare?: (files: File[], folders: Folder[]) => void;
onBulkDownload?: (files: File[], folders: Folder[]) => void;
setClearSelectionCallback?: (callback: () => void) => void;
}
@@ -50,21 +69,28 @@ export function FilesView({
const t = useTranslations();
const [viewMode, setViewMode] = useState<ViewMode>("table");
const commonProps = {
const baseProps = {
files,
folders: [],
onPreview,
onRename,
onUpdateName,
onUpdateDescription,
onDownload,
onShare,
onDelete,
onBulkDelete,
onBulkShare,
onBulkDownload,
onBulkDelete: (files: File[], folders: Folder[]) => onBulkDelete?.(files, folders),
onBulkShare: (files: File[], folders: Folder[]) => onBulkShare?.(files, folders),
onBulkDownload: (files: File[], folders: Folder[]) => onBulkDownload?.(files, folders),
setClearSelectionCallback,
};
const tableProps = {
...baseProps,
onUpdateName,
onUpdateDescription,
};
const gridProps = baseProps;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
@@ -95,7 +121,7 @@ export function FilesView({
<div className="text-sm text-muted-foreground">{t("files.totalFiles", { count: files.length })}</div>
</div>
{viewMode === "table" ? <FilesTable {...commonProps} /> : <FilesGrid {...commonProps} />}
{viewMode === "table" ? <FilesTable {...tableProps} /> : <FilesGrid {...gridProps} />}
</div>
);
}

View File

@@ -1,46 +1,76 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { IconCheck, IconEdit, IconEye, IconMinus, IconPlus, IconSearch } from "@tabler/icons-react";
import {
IconCheck,
IconEdit,
IconEye,
IconFile,
IconFolder,
IconFolderOpen,
IconMinus,
IconPlus,
IconSearch,
} from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { FileActionsModals } from "@/components/modals/file-actions-modals";
import { FilePreviewModal } from "@/components/modals/file-preview-modal";
import { FolderActionsModals } from "@/components/modals/folder-actions-modals";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { addFiles, listFiles, removeFiles } from "@/http/endpoints";
import { addFiles, addFolders, listFiles, removeFiles, removeFolders } from "@/http/endpoints";
import { listFolders } from "@/http/endpoints/folders";
import { getFileIcon } from "@/utils/file-icons";
interface FileSelectorProps {
shareId: string;
selectedFiles: string[];
onSave: (files: string[]) => Promise<void>;
selectedFolders?: string[];
onSave: (files: string[], folders: string[]) => Promise<void>;
onEditFile?: (fileId: string, newName: string, description?: string) => Promise<void>;
onEditFolder?: (folderId: string, newName: string, description?: string) => Promise<void>;
}
export function FileSelector({ shareId, selectedFiles, onSave, onEditFile }: FileSelectorProps) {
export function FileSelector({
shareId,
selectedFiles,
selectedFolders = [],
onSave,
onEditFile,
onEditFolder,
}: FileSelectorProps) {
const t = useTranslations();
const [availableFiles, setAvailableFiles] = useState<any[]>([]);
const [shareFiles, setShareFiles] = useState<any[]>([]);
const [availableFolders, setAvailableFolders] = useState<any[]>([]);
const [shareFolders, setShareFolders] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [searchFilter, setSearchFilter] = useState("");
const [shareSearchFilter, setShareSearchFilter] = useState("");
const [previewFile, setPreviewFile] = useState<any>(null);
const [fileToEdit, setFileToEdit] = useState<any>(null);
const [folderToEdit, setFolderToEdit] = useState<any>(null);
const loadFiles = useCallback(async () => {
try {
const response = await listFiles();
const allFiles = response.data.files || [];
const filesResponse = await listFiles();
const allFiles = filesResponse.data.files || [];
setShareFiles(allFiles.filter((file) => selectedFiles.includes(file.id)));
setAvailableFiles(allFiles.filter((file) => !selectedFiles.includes(file.id)));
const foldersResponse = await listFolders();
const allFolders = foldersResponse.data.folders || [];
setShareFolders(allFolders.filter((folder) => selectedFolders.includes(folder.id)));
setAvailableFolders(allFolders.filter((folder) => !selectedFolders.includes(folder.id)));
} catch {
toast.error(t("files.loadError"));
}
}, [selectedFiles, t]);
}, [selectedFiles, selectedFolders, t]);
useEffect(() => {
loadFiles();
@@ -62,6 +92,22 @@ export function FileSelector({ shareId, selectedFiles, onSave, onEditFile }: Fil
}
};
const addFolderToShare = (folderId: string) => {
const folder = availableFolders.find((f) => f.id === folderId);
if (folder) {
setShareFolders([...shareFolders, folder]);
setAvailableFolders(availableFolders.filter((f) => f.id !== folderId));
}
};
const removeFolderFromShare = (folderId: string) => {
const folder = shareFolders.find((f) => f.id === folderId);
if (folder) {
setAvailableFolders([...availableFolders, folder]);
setShareFolders(shareFolders.filter((f) => f.id !== folderId));
}
};
const handleSave = async () => {
try {
setIsLoading(true);
@@ -69,6 +115,11 @@ export function FileSelector({ shareId, selectedFiles, onSave, onEditFile }: Fil
const filesToAdd = shareFiles.filter((file) => !selectedFiles.includes(file.id)).map((file) => file.id);
const filesToRemove = selectedFiles.filter((fileId) => !shareFiles.find((f) => f.id === fileId));
const foldersToAdd = shareFolders
.filter((folder) => !selectedFolders.includes(folder.id))
.map((folder) => folder.id);
const foldersToRemove = selectedFolders.filter((folderId) => !shareFolders.find((f) => f.id === folderId));
if (filesToAdd.length > 0) {
await addFiles(shareId, { files: filesToAdd });
}
@@ -77,7 +128,18 @@ export function FileSelector({ shareId, selectedFiles, onSave, onEditFile }: Fil
await removeFiles(shareId, { files: filesToRemove });
}
await onSave(shareFiles.map((f) => f.id));
if (foldersToAdd.length > 0) {
await addFolders(shareId, { folders: foldersToAdd });
}
if (foldersToRemove.length > 0) {
await removeFolders(shareId, { folders: foldersToRemove });
}
await onSave(
shareFiles.map((f) => f.id),
shareFolders.map((f) => f.id)
);
} catch {
toast.error(t("shareManager.filesUpdateError"));
} finally {
@@ -93,6 +155,14 @@ export function FileSelector({ shareId, selectedFiles, onSave, onEditFile }: Fil
}
};
const handleEditFolder = async (folderId: string, newName: string, description?: string) => {
if (onEditFolder) {
await onEditFolder(folderId, newName, description);
setFolderToEdit(null);
await loadFiles();
}
};
const filteredAvailableFiles = availableFiles.filter((file) =>
file.name.toLowerCase().includes(searchFilter.toLowerCase())
);
@@ -101,6 +171,14 @@ export function FileSelector({ shareId, selectedFiles, onSave, onEditFile }: Fil
file.name.toLowerCase().includes(shareSearchFilter.toLowerCase())
);
const filteredAvailableFolders = availableFolders.filter((folder) =>
folder.name.toLowerCase().includes(searchFilter.toLowerCase())
);
const filteredShareFolders = shareFolders.filter((folder) =>
folder.name.toLowerCase().includes(shareSearchFilter.toLowerCase())
);
const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 B";
const k = 1024;
@@ -166,25 +244,82 @@ export function FileSelector({ shareId, selectedFiles, onSave, onEditFile }: Fil
);
};
const FolderCard = ({ folder, isInShare }: { folder: any; isInShare: boolean }) => {
const formatFileSize = (bytes: string | number) => {
const numBytes = typeof bytes === "string" ? parseInt(bytes) : bytes;
if (numBytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(numBytes) / Math.log(k));
return parseFloat((numBytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
};
return (
<div className="flex items-center gap-3 p-3 bg-background rounded-lg border group hover:border-muted-foreground/20 transition-colors">
<IconFolder className="h-5 w-5 text-blue-600 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate max-w-[260px]" title={folder.name}>
{folder.name}
</div>
{folder.description && (
<div className="text-xs text-muted-foreground truncate max-w-[260px]" title={folder.description}>
{folder.description}
</div>
)}
<div className="text-xs text-muted-foreground">
{folder.totalSize ? formatFileSize(folder.totalSize) : "—"} {folder._count?.files || 0} files
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex gap-1">
{onEditFolder && (
<Button
size="icon"
variant="ghost"
className="h-7 w-7 hover:bg-muted transition-colors"
onClick={() => setFolderToEdit(folder)}
title={t("fileSelector.editFolder")}
>
<IconEdit className="h-4 w-4" />
</Button>
)}
</div>
<div className="ml-1">
<Button
size="icon"
variant={isInShare ? "destructive" : "default"}
className="h-8 w-8 transition-all"
onClick={() => (isInShare ? removeFolderFromShare(folder.id) : addFolderToShare(folder.id))}
title={isInShare ? "Remove from share" : "Add to share"}
>
{isInShare ? <IconMinus className="h-4 w-4" /> : <IconPlus className="h-4 w-4" />}
</Button>
</div>
</div>
</div>
);
};
return (
<>
<div className="space-y-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-medium">{t("fileSelector.shareFiles", { count: shareFiles.length })}</h3>
<p className="text-sm text-muted-foreground">{t("fileSelector.shareFilesDescription")}</p>
<h3 className="text-lg font-medium">Share Contents ({shareFiles.length + shareFolders.length} items)</h3>
<p className="text-sm text-muted-foreground">Files and folders that will be shared</p>
</div>
<Badge variant="secondary" className="bg-blue-500/20 text-blue-700">
{shareFiles.length} {t("fileSelector.fileCount", { count: shareFiles.length })}
{shareFiles.length} files {shareFolders.length} folders
</Badge>
</div>
{shareFiles.length > 0 && (
{(shareFiles.length > 0 || shareFolders.length > 0) && (
<div className="relative">
<IconSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t("fileSelector.searchSelectedFiles")}
placeholder={t("searchBar.placeholder")}
value={shareSearchFilter}
onChange={(e) => setShareSearchFilter(e.target.value)}
className="pl-10"
@@ -192,20 +327,23 @@ export function FileSelector({ shareId, selectedFiles, onSave, onEditFile }: Fil
</div>
)}
{shareFiles.length > 0 ? (
{shareFiles.length > 0 || shareFolders.length > 0 ? (
<div className="grid gap-2 max-h-40 overflow-y-auto border rounded-lg p-3 bg-muted/30">
{filteredShareFiles.map((file) => (
<FileCard key={file.id} file={file} isInShare={true} />
{filteredShareFolders.map((folder) => (
<FolderCard key={`folder-${folder.id}`} folder={folder} isInShare={true} />
))}
{filteredShareFiles.length === 0 && shareSearchFilter && (
{filteredShareFiles.map((file) => (
<FileCard key={`file-${file.id}`} file={file} isInShare={true} />
))}
{filteredShareFiles.length === 0 && filteredShareFolders.length === 0 && shareSearchFilter && (
<div className="text-center py-4 text-muted-foreground">
<p className="text-sm">{t("fileSelector.noFilesFoundWith", { query: shareSearchFilter })}</p>
<p className="text-sm">No items found with "{shareSearchFilter}"</p>
</div>
)}
</div>
) : (
<div className="text-center py-8 text-muted-foreground border rounded-lg bg-muted/20">
<div className="text-4xl mb-2">📁</div>
<IconFolderOpen className="h-16 w-16 mx-auto mb-2 text-muted-foreground/50" />
<p className="font-medium">{t("fileSelector.noFilesInShare")}</p>
<p className="text-sm">{t("fileSelector.addFilesFromList")}</p>
</div>
@@ -216,9 +354,9 @@ export function FileSelector({ shareId, selectedFiles, onSave, onEditFile }: Fil
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-medium">
{t("fileSelector.availableFiles", { count: filteredAvailableFiles.length })}
Available Items ({filteredAvailableFiles.length + filteredAvailableFolders.length})
</h3>
<p className="text-sm text-muted-foreground">{t("fileSelector.availableFilesDescription")}</p>
<p className="text-sm text-muted-foreground">Files and folders you can add to the share</p>
</div>
</div>
@@ -232,21 +370,24 @@ export function FileSelector({ shareId, selectedFiles, onSave, onEditFile }: Fil
/>
</div>
{filteredAvailableFiles.length > 0 ? (
{filteredAvailableFiles.length > 0 || filteredAvailableFolders.length > 0 ? (
<div className="grid gap-2 max-h-60 overflow-y-auto border rounded-lg p-3 bg-muted/10">
{filteredAvailableFolders.map((folder) => (
<FolderCard key={`folder-${folder.id}`} folder={folder} isInShare={false} />
))}
{filteredAvailableFiles.map((file) => (
<FileCard key={file.id} file={file} isInShare={false} />
<FileCard key={`file-${file.id}`} file={file} isInShare={false} />
))}
</div>
) : searchFilter ? (
<div className="text-center py-8 text-muted-foreground border rounded-lg bg-muted/20">
<div className="text-4xl mb-2">🔍</div>
<IconSearch className="h-16 w-16 mx-auto mb-2 text-muted-foreground/50" />
<p className="font-medium">{t("fileSelector.noFilesFound")}</p>
<p className="text-sm">{t("fileSelector.tryDifferentSearch")}</p>
</div>
) : (
<div className="text-center py-8 text-muted-foreground border rounded-lg bg-muted/20">
<div className="text-4xl mb-2">📄</div>
<IconFile className="h-16 w-16 mx-auto mb-2 text-muted-foreground/50" />
<p className="font-medium">{t("fileSelector.allFilesInShare")}</p>
<p className="text-sm">{t("fileSelector.uploadNewFiles")}</p>
</div>
@@ -254,9 +395,7 @@ export function FileSelector({ shareId, selectedFiles, onSave, onEditFile }: Fil
</div>
<div className="flex items-center justify-between pt-4 border-t">
<div className="text-sm text-muted-foreground">
{t("fileSelector.filesSelected", { count: shareFiles.length })}
</div>
<div className="text-sm text-muted-foreground">{shareFiles.length + shareFolders.length} items selected</div>
<Button onClick={handleSave} disabled={isLoading} className="gap-2">
{isLoading ? (
<>
@@ -287,6 +426,18 @@ export function FileSelector({ shareId, selectedFiles, onSave, onEditFile }: Fil
onCloseRename={() => setFileToEdit(null)}
onCloseDelete={() => {}}
/>
<FolderActionsModals
folderToCreate={false}
onCreateFolder={async () => {}}
onCloseCreate={() => {}}
folderToEdit={folderToEdit}
onEditFolder={handleEditFolder}
onCloseEdit={() => setFolderToEdit(null)}
folderToDelete={null}
onDeleteFolder={async () => {}}
onCloseDelete={() => {}}
/>
</>
);
}

View File

@@ -8,7 +8,7 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { checkFile, getPresignedUrl, registerFile } from "@/http/endpoints";
import { checkFile, getFilePresignedUrl, registerFile } from "@/http/endpoints";
import { getSystemInfo } from "@/http/endpoints/app";
import { ChunkedUploader } from "@/utils/chunked-upload";
import { getFileIcon } from "@/utils/file-icons";
@@ -19,6 +19,7 @@ import getErrorData from "@/utils/getErrorData";
interface GlobalDropZoneProps {
onSuccess?: () => void;
children: React.ReactNode;
currentFolderId?: string;
}
enum UploadStatus {
@@ -39,7 +40,7 @@ interface FileUpload {
objectName?: string;
}
export function GlobalDropZone({ onSuccess, children }: GlobalDropZoneProps) {
export function GlobalDropZone({ onSuccess, children, currentFolderId }: GlobalDropZoneProps) {
const t = useTranslations();
const [isDragOver, setIsDragOver] = useState(false);
const [fileUploads, setFileUploads] = useState<FileUpload[]>([]);
@@ -90,6 +91,7 @@ export function GlobalDropZone({ onSuccess, children }: GlobalDropZoneProps) {
objectName: safeObjectName,
size: file.size,
extension: extension,
folderId: currentFolderId,
});
} catch (error) {
console.error("File check failed:", error);
@@ -114,7 +116,7 @@ export function GlobalDropZone({ onSuccess, children }: GlobalDropZoneProps) {
prev.map((u) => (u.id === id ? { ...u, status: UploadStatus.UPLOADING, progress: 0 } : u))
);
const presignedResponse = await getPresignedUrl({
const presignedResponse = await getFilePresignedUrl({
filename: safeObjectName.replace(`.${extension}`, ""),
extension: extension,
});
@@ -153,6 +155,7 @@ export function GlobalDropZone({ onSuccess, children }: GlobalDropZoneProps) {
objectName: finalObjectName,
size: file.size,
extension: extension,
folderId: currentFolderId,
});
} else {
await axios.put(url, file, {
@@ -171,6 +174,7 @@ export function GlobalDropZone({ onSuccess, children }: GlobalDropZoneProps) {
objectName: objectName,
size: file.size,
extension: extension,
folderId: currentFolderId,
});
}
@@ -199,7 +203,7 @@ export function GlobalDropZone({ onSuccess, children }: GlobalDropZoneProps) {
);
}
},
[t, isS3Enabled]
[t, isS3Enabled, currentFolderId]
);
const handleDrop = useCallback(

View File

@@ -9,14 +9,21 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface BulkItem {
id: string;
name: string;
size?: number;
type: "file" | "folder";
}
interface BulkDownloadModalProps {
isOpen: boolean;
onClose: () => void;
onDownload: (zipName: string) => void;
fileCount: number;
items?: BulkItem[];
}
export function BulkDownloadModal({ isOpen, onClose, onDownload, fileCount }: BulkDownloadModalProps) {
export function BulkDownloadModal({ isOpen, onClose, onDownload, items = [] }: BulkDownloadModalProps) {
const t = useTranslations();
const [zipName, setZipName] = useState("");
@@ -53,7 +60,11 @@ export function BulkDownloadModal({ isOpen, onClose, onDownload, fileCount }: Bu
className="w-full"
autoFocus
/>
<p className="text-sm text-muted-foreground">{t("bulkDownload.description", { count: fileCount })}</p>
<p className="text-sm text-muted-foreground">
{t("bulkDownload.description", { count: items.length })}(
{items.filter((item) => item.type === "file").length} files,{" "}
{items.filter((item) => item.type === "folder").length} folders)
</p>
</div>
</form>

View File

@@ -1,25 +1,31 @@
"use client";
import { useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { IconCalendar, IconEye, IconLock, IconShare } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { FileTree, TreeFile, TreeFolder } from "@/components/tables/files-tree";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { createShare } from "@/http/endpoints";
interface CreateShareModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
getAllFilesAndFolders: () => Promise<{ files: any[]; folders: any[] }>;
}
export function CreateShareModal({ isOpen, onClose, onSuccess }: CreateShareModalProps) {
export function CreateShareModal({ isOpen, onClose, onSuccess, getAllFilesAndFolders }: CreateShareModalProps) {
const t = useTranslations();
const [currentTab, setCurrentTab] = useState("details");
const [formData, setFormData] = useState({
name: "",
description: "",
@@ -28,11 +34,78 @@ export function CreateShareModal({ isOpen, onClose, onSuccess }: CreateShareModa
isPasswordProtected: false,
maxViews: "",
});
const [selectedItems, setSelectedItems] = useState<string[]>([]);
const [files, setFiles] = useState<TreeFile[]>([]);
const [folders, setFolders] = useState<TreeFolder[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(false);
const loadData = useCallback(async () => {
try {
setIsLoadingData(true);
const data = await getAllFilesAndFolders();
const treeFiles: TreeFile[] = data.files.map((file) => ({
id: file.id,
name: file.name,
type: "file" as const,
size: file.size,
parentId: file.folderId || null,
}));
const treeFolders: TreeFolder[] = data.folders.map((folder) => ({
id: folder.id,
name: folder.name,
type: "folder" as const,
parentId: folder.parentId || null,
totalSize: folder.totalSize,
}));
setFiles(treeFiles);
setFolders(treeFolders);
} catch (error) {
console.error("Error loading files and folders:", error);
} finally {
setIsLoadingData(false);
}
}, [getAllFilesAndFolders]);
useEffect(() => {
if (isOpen) {
loadData();
setFormData({
name: "",
description: "",
password: "",
expiresAt: "",
isPasswordProtected: false,
maxViews: "",
});
setSelectedItems([]);
setCurrentTab("details");
}
}, [isOpen, loadData]);
const handleSubmit = async () => {
if (!formData.name.trim()) {
toast.error("Share name is required");
return;
}
if (selectedItems.length === 0) {
toast.error("Please select at least one file or folder");
return;
}
try {
setIsLoading(true);
const selectedFiles = selectedItems.filter((id) => files.some((file) => file.id === id));
const selectedFolders = selectedItems.filter((id) => folders.some((folder) => folder.id === id));
await createShare({
name: formData.name,
description: formData.description || undefined,
@@ -47,129 +120,218 @@ export function CreateShareModal({ isOpen, onClose, onSuccess }: CreateShareModa
})()
: undefined,
maxViews: formData.maxViews ? parseInt(formData.maxViews) : undefined,
files: [],
files: selectedFiles,
folders: selectedFolders,
});
toast.success(t("createShare.success"));
onSuccess();
onClose();
setFormData({
name: "",
description: "",
password: "",
expiresAt: "",
isPasswordProtected: false,
maxViews: "",
});
} catch {
} catch (error) {
console.error("Error creating share:", error);
toast.error(t("createShare.error"));
} finally {
setIsLoading(false);
}
};
const handleClose = () => {
if (!isLoading) {
onClose();
}
};
const updateFormData = (field: keyof typeof formData, value: string | boolean) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const selectedCount = selectedItems.length;
const canProceedToFiles = formData.name.trim().length > 0;
const canSubmit = formData.name.trim().length > 0 && selectedCount > 0;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-4xl max-h-[90vh] w-full">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconShare size={20} />
<IconShare className="h-5 w-5" />
{t("createShare.title")}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-6 flex-1 min-h-0 w-full overflow-hidden">
<Tabs value={currentTab} onValueChange={setCurrentTab} className="flex-1">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="details">{t("createShare.tabs.shareDetails")}</TabsTrigger>
<TabsTrigger value="files" disabled={!canProceedToFiles}>
{t("createShare.tabs.selectFiles")}
{selectedCount > 0 && (
<span className="ml-1 text-xs bg-primary text-primary-foreground rounded-full px-2 py-0.5">
{selectedCount}
</span>
)}
</TabsTrigger>
</TabsList>
<TabsContent value="details" className="space-y-4 mt-4">
<div className="space-y-2">
<Label>{t("createShare.nameLabel")}</Label>
<Label htmlFor="share-name">{t("createShare.nameLabel")} *</Label>
<Input
id="share-name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
onPaste={(e) => {
e.preventDefault();
const pastedText = e.clipboardData.getData("text");
setFormData({ ...formData, name: pastedText });
}}
onChange={(e) => updateFormData("name", e.target.value)}
placeholder={t("createShare.namePlaceholder")}
required
/>
</div>
<div className="space-y-2">
<Label>{t("createShare.descriptionLabel")}</Label>
<Input
<Label htmlFor="share-description">{t("createShare.descriptionLabel")}</Label>
<Textarea
id="share-description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
onChange={(e) => updateFormData("description", e.target.value)}
placeholder={t("createShare.descriptionPlaceholder")}
rows={3}
/>
</div>
<div className="space-y-2">
<Label className="flex items-center gap-2">
<IconCalendar size={16} />
{t("createShare.expirationLabel")}
</Label>
<Input
placeholder={t("createShare.expirationPlaceholder")}
type="datetime-local"
value={formData.expiresAt}
onChange={(e) => setFormData({ ...formData, expiresAt: e.target.value })}
onBlur={(e) => {
const value = e.target.value;
if (value && value.length === 10) {
setFormData({ ...formData, expiresAt: value + "T23:59" });
}
}}
/>
</div>
<div className="space-y-2">
<Label className="flex items-center gap-2">
<IconEye size={16} />
{t("createShare.maxViewsLabel")}
</Label>
<Input
min="1"
placeholder={t("createShare.maxViewsPlaceholder")}
type="number"
value={formData.maxViews}
onChange={(e) => setFormData({ ...formData, maxViews: e.target.value })}
/>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center space-x-2">
<Switch
checked={formData.isPasswordProtected}
onCheckedChange={(checked) =>
setFormData({
...formData,
isPasswordProtected: checked,
password: "",
})
}
id="password-protection"
checked={formData.isPasswordProtected}
onCheckedChange={(checked) => updateFormData("isPasswordProtected", checked)}
/>
<Label htmlFor="password-protection" className="flex items-center gap-2">
<IconLock size={16} />
<IconLock className="h-4 w-4" />
{t("createShare.passwordProtection")}
</Label>
</div>
{formData.isPasswordProtected && (
<div className="space-y-2">
<Label>{t("createShare.passwordLabel")}</Label>
<Label htmlFor="share-password">{t("createShare.passwordLabel")}</Label>
<Input
id="share-password"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
onChange={(e) => updateFormData("password", e.target.value)}
placeholder="Enter password"
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="expiration" className="flex items-center gap-2">
<IconCalendar className="h-4 w-4" />
{t("createShare.expirationLabel")}
</Label>
<Input
id="expiration"
type="datetime-local"
value={formData.expiresAt}
onChange={(e) => updateFormData("expiresAt", e.target.value)}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
<div className="space-y-2">
<Label htmlFor="max-views" className="flex items-center gap-2">
<IconEye className="h-4 w-4" />
{t("createShare.maxViewsLabel")}
</Label>
<Input
id="max-views"
type="number"
min="1"
value={formData.maxViews}
onChange={(e) => updateFormData("maxViews", e.target.value)}
placeholder={t("createShare.maxViewsPlaceholder")}
/>
</div>
<div className="flex justify-end">
<Button onClick={() => setCurrentTab("files")} disabled={!canProceedToFiles}>
{t("createShare.nextSelectFiles")}
</Button>
</div>
</TabsContent>
<TabsContent value="files" className="space-y-4 mt-4 flex-1 min-h-0">
<div className="space-y-2">
<Label htmlFor="file-search">{t("common.search")}</Label>
<Input
id="file-search"
type="search"
placeholder={t("searchBar.placeholder")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
disabled={isLoadingData}
/>
</div>
<div className="text-sm text-muted-foreground">
{selectedCount > 0 ? (
<span>{selectedCount} items selected</span>
) : (
<span>Select files and folders to share</span>
)}
</div>
<div className="flex-1 min-h-0 w-full overflow-hidden">
{isLoadingData ? (
<div className="flex items-center justify-center py-8">
<div className="text-sm text-muted-foreground">{t("common.loadingSimple")}</div>
</div>
) : (
<FileTree
files={files.map((file) => ({
id: file.id,
name: file.name,
description: "",
extension: "",
size: file.size?.toString() || "0",
objectName: "",
userId: "",
folderId: file.parentId,
createdAt: "",
updatedAt: "",
}))}
folders={folders.map((folder) => ({
id: folder.id,
name: folder.name,
description: "",
parentId: folder.parentId,
userId: "",
createdAt: "",
updatedAt: "",
totalSize: folder.totalSize,
}))}
selectedItems={selectedItems}
onSelectionChange={setSelectedItems}
showFiles={true}
showFolders={true}
maxHeight="400px"
searchQuery={searchQuery}
/>
)}
</div>
<div className="flex justify-between">
<Button variant="outline" onClick={() => setCurrentTab("details")}>
{t("common.back")}
</Button>
<div className="space-x-2">
<Button variant="outline" onClick={handleClose}>
{t("common.cancel")}
</Button>
<Button disabled={isLoading} onClick={handleSubmit}>
{isLoading ? <div className="animate-spin"></div> : t("createShare.create")}
<Button onClick={handleSubmit} disabled={!canSubmit || isLoading}>
{isLoading ? t("common.creating") : t("createShare.create")}
</Button>
</DialogFooter>
</div>
</div>
</TabsContent>
</Tabs>
</div>
</DialogContent>
</Dialog>
);

View File

@@ -1,6 +1,6 @@
"use client";
import { IconTrash, IconX } from "@tabler/icons-react";
import { IconFolder, IconTrash, IconX } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
@@ -15,8 +15,9 @@ interface DeleteConfirmationModalProps {
onConfirm: () => void;
title: string;
description: string;
files: string[];
itemType?: "files" | "shares";
files?: string[];
folders?: string[];
itemType?: "files" | "shares" | "mixed";
}
export function DeleteConfirmationModal({
@@ -25,7 +26,8 @@ export function DeleteConfirmationModal({
onConfirm,
title,
description,
files,
files = [],
folders = [],
itemType,
}: DeleteConfirmationModalProps) {
const t = useTranslations();
@@ -48,19 +50,39 @@ export function DeleteConfirmationModal({
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{description}</p>
{files.length > 0 && (
{(files.length > 0 || folders.length > 0) && (
<div className="space-y-2">
<p className="text-sm font-medium">
{itemType === "shares" ? t("deleteConfirmation.sharesToDelete") : t("deleteConfirmation.filesToDelete")}
{itemType === "shares"
? t("deleteConfirmation.sharesToDelete")
: folders.length > 0 && files.length > 0
? t("deleteConfirmation.itemsToDelete")
: folders.length > 0
? t("deleteConfirmation.foldersToDelete")
: t("deleteConfirmation.filesToDelete")}
:
</p>
<ScrollArea className="h-32 w-full rounded-md border p-2">
<div className="space-y-1">
{folders.map((folderName, index) => (
<div
key={`folder-${index}`}
className="flex items-center gap-2 p-2 bg-muted/20 rounded text-sm min-w-0"
>
<IconFolder className="h-4 w-4 text-primary flex-shrink-0" />
<span className="flex-1 break-all" title={folderName}>
{truncateFileName(folderName)}
</span>
</div>
))}
{files.map((fileName, index) => {
const { icon: FileIcon, color } = getFileIcon(fileName);
const displayName = truncateFileName(fileName);
return (
<div key={index} className="flex items-center gap-2 p-2 bg-muted/20 rounded text-sm min-w-0">
<div
key={`file-${index}`}
className="flex items-center gap-2 p-2 bg-muted/20 rounded text-sm min-w-0"
>
<FileIcon className={`h-4 w-4 ${color} flex-shrink-0`} />
<span className="flex-1 break-all" title={fileName}>
{displayName}

View File

@@ -0,0 +1,193 @@
import { IconEdit, IconFolderPlus, IconTrash } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
interface FolderToEdit {
id: string;
name: string;
description?: string | null;
}
interface FolderToDelete {
id: string;
name: string;
}
interface FolderActionsModalsProps {
folderToCreate: boolean;
onCreateFolder: (name: string, description?: string) => Promise<void>;
onCloseCreate: () => void;
folderToEdit: FolderToEdit | null;
onEditFolder: (folderId: string, newName: string, description?: string) => Promise<void>;
onCloseEdit: () => void;
folderToDelete: FolderToDelete | null;
onDeleteFolder: (folderId: string) => Promise<void>;
onCloseDelete: () => void;
}
export function FolderActionsModals({
folderToCreate,
onCreateFolder,
onCloseCreate,
folderToEdit,
onEditFolder,
onCloseEdit,
folderToDelete,
onDeleteFolder,
onCloseDelete,
}: FolderActionsModalsProps) {
const t = useTranslations();
return (
<>
<Dialog open={folderToCreate} onOpenChange={() => onCloseCreate()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconFolderPlus size={20} />
{t("folderActions.createFolder")}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<Input
placeholder={t("folderActions.folderNamePlaceholder")}
onKeyUp={(e) => {
if (e.key === "Enter") {
const nameInput = e.currentTarget;
const descInput = document.querySelector(
`textarea[placeholder="${t("folderActions.folderDescriptionPlaceholder")}"]`
) as HTMLTextAreaElement;
if (nameInput.value.trim()) {
onCreateFolder(nameInput.value.trim(), descInput?.value.trim() || undefined);
}
}
}}
/>
<Textarea placeholder={t("folderActions.folderDescriptionPlaceholder")} />
</div>
<DialogFooter>
<Button variant="outline" onClick={onCloseCreate}>
{t("common.cancel")}
</Button>
<Button
onClick={() => {
const nameInput = document.querySelector(
`input[placeholder="${t("folderActions.folderNamePlaceholder")}"]`
) as HTMLInputElement;
const descInput = document.querySelector(
`textarea[placeholder="${t("folderActions.folderDescriptionPlaceholder")}"]`
) as HTMLTextAreaElement;
if (nameInput && nameInput.value.trim()) {
onCreateFolder(nameInput.value.trim(), descInput?.value.trim() || undefined);
}
}}
>
{t("folderActions.createFolder")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={!!folderToEdit} onOpenChange={() => onCloseEdit()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconEdit size={20} />
{t("folderActions.editFolder")}
</DialogTitle>
</DialogHeader>
{folderToEdit && (
<div className="flex flex-col gap-4">
<Input
defaultValue={folderToEdit.name}
placeholder={t("folderActions.folderNamePlaceholder")}
onKeyUp={(e) => {
if (e.key === "Enter" && folderToEdit) {
const nameInput = e.currentTarget;
const descInput = document.querySelector(
`textarea[placeholder="${t("folderActions.folderDescriptionPlaceholder")}"]`
) as HTMLTextAreaElement;
if (nameInput.value.trim()) {
onEditFolder(folderToEdit.id, nameInput.value.trim(), descInput?.value.trim() || undefined);
}
}
}}
/>
<Textarea
defaultValue={folderToEdit.description || ""}
placeholder={t("folderActions.folderDescriptionPlaceholder")}
/>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={onCloseEdit}>
{t("common.cancel")}
</Button>
<Button
onClick={() => {
const nameInput = document.querySelector(
`input[placeholder="${t("folderActions.folderNamePlaceholder")}"]`
) as HTMLInputElement;
const descInput = document.querySelector(
`textarea[placeholder="${t("folderActions.folderDescriptionPlaceholder")}"]`
) as HTMLTextAreaElement;
if (folderToEdit && nameInput && nameInput.value.trim()) {
onEditFolder(folderToEdit.id, nameInput.value.trim(), descInput?.value.trim() || undefined);
}
}}
>
{t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={!!folderToDelete} onOpenChange={() => onCloseDelete()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconTrash size={20} />
{t("folderActions.deleteFolder")}
</DialogTitle>
</DialogHeader>
<DialogDescription>
<p className="text-base font-semibold mb-2 text-foreground">{t("folderActions.deleteConfirmation")}</p>
<p>
{(folderToDelete?.name &&
(folderToDelete.name.length > 50
? folderToDelete.name.substring(0, 50) + "..."
: folderToDelete.name)) ||
""}
</p>
<p className="text-sm mt-2 text-amber-500">{t("folderActions.deleteWarning")}</p>
</DialogDescription>
<DialogFooter>
<Button variant="outline" onClick={onCloseDelete}>
{t("common.cancel")}
</Button>
<Button variant="destructive" onClick={() => folderToDelete && onDeleteFolder(folderToDelete.id)}>
{t("common.delete")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -2,8 +2,8 @@ export { QrCodeModal } from "./qr-code-modal";
export { UploadFileModal } from "./upload-file-modal";
export { CreateShareModal } from "./create-share-modal";
export { ShareSecurityModal } from "./share-security-modal";
export { ShareFileModal } from "./share-file-modal";
export { ShareMultipleFilesModal } from "./share-multiple-files-modal";
export { ShareItemModal } from "./share-item-modal";
export { ShareMultipleItemsModal } from "./share-multiple-items-modal";
export { ShareDetailsModal } from "./share-details-modal";
export { FilePreviewModal } from "./file-preview-modal";
export { GenerateShareLinkModal } from "./generate-share-link-modal";
@@ -11,3 +11,7 @@ export { ImageEditModal } from "./image-edit-modal";
export { DeleteConfirmationModal } from "./delete-confirmation-modal";
export { BulkDownloadModal } from "./bulk-download-modal";
export { ShareExpirationModal } from "./share-expiration-modal";
export { MoveItemsModal } from "./move-items-modal";
export { FolderActionsModals } from "./folder-actions-modals";

View File

@@ -0,0 +1,242 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { IconFolder } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { FileTree, TreeFolder } from "@/components/tables/files-tree";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { getFileIcon } from "@/utils/file-icons";
interface MoveItemsModalProps {
isOpen: boolean;
onClose: () => void;
onMove: (targetFolderId: string | null) => Promise<void>;
itemsToMove: { files: any[]; folders: any[] } | null;
title?: string;
description?: string;
getAllFolders: () => Promise<any[]>;
currentFolderId?: string | null;
}
export function MoveItemsModal({
isOpen,
onClose,
onMove,
itemsToMove,
title,
description,
getAllFolders,
currentFolderId = null,
}: MoveItemsModalProps) {
const t = useTranslations();
const [selectedItems, setSelectedItems] = useState<string[]>([]);
const [folders, setFolders] = useState<TreeFolder[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isMoving, setIsMoving] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const loadFolders = useCallback(async () => {
try {
setIsLoading(true);
const data = await getAllFolders();
const excludedIds = new Set(itemsToMove?.folders.map((f) => f.id) || []);
const treeFolders: TreeFolder[] = data
.filter((folder) => !excludedIds.has(folder.id))
.map((folder) => ({
id: folder.id,
name: folder.name,
type: "folder" as const,
parentId: folder.parentId || null,
totalSize: folder.totalSize,
}));
setFolders(treeFolders);
} catch (error) {
console.error("Error loading folders:", error);
} finally {
setIsLoading(false);
}
}, [getAllFolders, itemsToMove]);
useEffect(() => {
if (isOpen) {
loadFolders();
if (currentFolderId) {
setSelectedItems([currentFolderId]);
} else {
setSelectedItems(["root"]);
}
}
}, [isOpen, loadFolders, currentFolderId]);
const firstItemToMove = useMemo(() => {
if (!itemsToMove) return null;
if (itemsToMove.files.length > 0) return itemsToMove.files[0];
if (itemsToMove.folders.length > 0) return itemsToMove.folders[0];
return null;
}, [itemsToMove]);
const foldersWithRoot = useMemo(() => {
const rootFolder: TreeFolder = {
id: "root",
name: t("folderActions.rootFolder"),
type: "folder" as const,
parentId: null,
};
return [rootFolder, ...folders];
}, [folders, t]);
const handleMove = async () => {
try {
setIsMoving(true);
const targetFolderId = selectedItems.length > 0 && selectedItems[0] !== "root" ? selectedItems[0] : null;
await onMove(targetFolderId);
onClose();
} catch (error) {
console.error("Error moving items:", error);
} finally {
setIsMoving(false);
}
};
const handleClose = () => {
if (!isMoving) {
setSelectedItems([]);
setSearchQuery("");
onClose();
}
};
const handleSelectionChange = (newSelection: string[]) => {
setSelectedItems(newSelection);
};
const selectedFolder =
selectedItems.length > 0 && selectedItems[0] !== "root"
? folders.find((f) => f.id === selectedItems[0])?.name
: null;
const itemCount = (itemsToMove?.files.length || 0) + (itemsToMove?.folders.length || 0);
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl max-h-[80vh] w-full">
<DialogHeader>
<DialogTitle>{title || t("moveItems.title", { count: itemCount })}</DialogTitle>
<DialogDescription>{description || t("moveItems.description", { count: itemCount })}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 flex-1 min-h-0 w-full overflow-hidden">
{/* Items being moved */}
<div className="text-sm">
<div className="font-medium mb-2">{t("moveItems.itemsToMove")}</div>
<div className="max-h-20 overflow-y-auto text-muted-foreground">
{itemsToMove?.folders.map((folder) => (
<div key={folder.id} className="flex items-center gap-2 truncate">
<IconFolder className="h-4 w-4 text-primary flex-shrink-0" />
<span className="truncate">{folder.name}</span>
</div>
))}
{itemsToMove?.files.map((file) => {
const { icon: FileIcon, color } = getFileIcon(file.name);
return (
<div key={file.id} className="flex items-center gap-2 truncate">
<FileIcon className={`h-4 w-4 ${color} flex-shrink-0`} />
<span className="truncate">{file.name}</span>
</div>
);
})}
</div>
</div>
{/* Search */}
<div className="space-y-2">
<Label htmlFor="search">{t("common.search")}</Label>
<Input
id="search"
type="search"
placeholder={t("searchBar.placeholderFolders")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
disabled={isLoading}
/>
</div>
{/* Destination Selection */}
<div className="space-y-2">
<Label>{t("folderActions.selectDestination")}</Label>
<div className="text-sm text-muted-foreground mb-2">
{selectedItems.length > 0 && selectedItems[0] !== "root" ? (
<span>
{t("moveItems.movingTo")} {selectedFolder}
</span>
) : (
<span>
{t("moveItems.movingTo")} {t("folderActions.rootFolder")}
</span>
)}
</div>
</div>
{/* Folder Tree */}
<div className="flex-1 min-h-0 w-full overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="text-sm text-muted-foreground">{t("common.loadingSimple")}</div>
</div>
) : (
<FileTree
files={[]}
folders={foldersWithRoot.map((folder: TreeFolder) => ({
id: folder.id,
name: folder.name,
type: "folder" as const,
parentId: folder.parentId || null,
description: "",
userId: "",
createdAt: "",
updatedAt: "",
totalSize: folder.totalSize,
}))}
selectedItems={selectedItems}
onSelectionChange={handleSelectionChange}
showFiles={false}
showFolders={true}
maxHeight="300px"
singleSelection={true}
useCheckboxAsRadio={true}
searchQuery={searchQuery}
autoExpandToItem={firstItemToMove?.id || null}
/>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose} disabled={isMoving}>
{t("common.cancel")}
</Button>
<Button onClick={handleMove} disabled={isLoading || isMoving}>
{isMoving ? t("common.move") + "..." : t("common.move")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,11 +1,11 @@
"use client";
import { useEffect, useState } from "react";
import React, { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { FileSelector } from "@/components/general/file-selector";
import { RecipientSelector } from "@/components/general/recipient-selector";
import { FileTree, TreeFile, TreeFolder } from "@/components/tables/files-tree";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -18,7 +18,8 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { updateSharePassword } from "@/http/endpoints";
import { addFiles, addFolders, removeFiles, removeFolders, updateSharePassword } from "@/http/endpoints";
import { listFolders } from "@/http/endpoints/folders";
export interface ShareActionsModalsProps {
shareToDelete: any;
@@ -31,9 +32,10 @@ export interface ShareActionsModalsProps {
onCloseManageRecipients: () => void;
onDelete: (shareId: string) => Promise<void>;
onEdit: (shareId: string, data: any) => Promise<void>;
onManageFiles: (shareId: string, files: string[]) => Promise<void>;
onManageFiles: (shareId: string, files: string[], folders: string[]) => Promise<void>;
onManageRecipients: (shareId: string, recipients: string[]) => Promise<void>;
onEditFile?: (fileId: string, newName: string, description?: string) => Promise<void>;
onEditFolder?: (folderId: string, newName: string, description?: string) => Promise<void>;
onSuccess: () => void;
}
@@ -48,12 +50,39 @@ export function ShareActionsModals({
onCloseManageRecipients,
onDelete,
onEdit,
onManageFiles,
onEditFile,
onSuccess,
}: ShareActionsModalsProps) {
const t = useTranslations();
const [isLoading, setIsLoading] = useState(false);
const [allFiles, setAllFiles] = useState<any[]>([]);
const [allFolders, setAllFolders] = useState<any[]>([]);
const [manageFilesSelectedItems, setManageFilesSelectedItems] = useState<string[]>([]);
const [manageFilesTreeFiles, setManageFilesTreeFiles] = useState<TreeFile[]>([]);
const [manageFilesTreeFolders, setManageFilesTreeFolders] = useState<TreeFolder[]>([]);
const [isManageFilesLoading, setIsManageFilesLoading] = useState(false);
const [isManageFilesSaving, setIsManageFilesSaving] = useState(false);
const [manageFilesSearchQuery, setManageFilesSearchQuery] = useState("");
React.useEffect(() => {
const loadAllData = async () => {
try {
const [allFoldersResponse, allFilesResponse] = await Promise.all([
listFolders(),
fetch("/api/files?recursive=true").then((res) => res.json()),
]);
setAllFolders(allFoldersResponse.data.folders || []);
setAllFiles(allFilesResponse.files || []);
} catch (error) {
console.error("Error loading all files and folders:", error);
setAllFolders([]);
setAllFiles([]);
}
};
loadAllData();
}, []);
const [editForm, setEditForm] = useState({
name: "",
description: "",
@@ -76,6 +105,45 @@ export function ShareActionsModals({
}
}, [shareToEdit]);
const loadManageFilesData = useCallback(async () => {
try {
setIsManageFilesLoading(true);
const treeFiles: TreeFile[] = allFiles.map((file) => ({
id: file.id,
name: file.name,
type: "file" as const,
size: file.size,
parentId: file.folderId || null,
}));
const treeFolders: TreeFolder[] = allFolders.map((folder) => ({
id: folder.id,
name: folder.name,
type: "folder" as const,
parentId: folder.parentId || null,
totalSize: folder.totalSize,
}));
setManageFilesTreeFiles(treeFiles);
setManageFilesTreeFolders(treeFolders);
} catch (error) {
console.error("Error loading files and folders:", error);
} finally {
setIsManageFilesLoading(false);
}
}, [allFiles, allFolders]);
useEffect(() => {
if (shareToManageFiles) {
loadManageFilesData();
const initialSelectedFiles = shareToManageFiles?.files?.map((f: any) => f.id) || [];
const initialSelectedFolders = shareToManageFiles?.folders?.map((f: any) => f.id) || [];
setManageFilesSelectedItems([...initialSelectedFiles, ...initialSelectedFolders]);
}
}, [shareToManageFiles, loadManageFilesData]);
const handleDelete = async () => {
if (!shareToDelete) return;
setIsLoading(true);
@@ -113,6 +181,64 @@ export function ShareActionsModals({
}
};
const handleManageFilesSave = async () => {
if (!shareToManageFiles?.id) return;
try {
setIsManageFilesSaving(true);
const selectedFiles = manageFilesSelectedItems.filter((id) =>
manageFilesTreeFiles.some((file) => file.id === id)
);
const selectedFolders = manageFilesSelectedItems.filter((id) =>
manageFilesTreeFolders.some((folder) => folder.id === id)
);
const currentFileIds = shareToManageFiles.files?.map((f: any) => f.id) || [];
const currentFolderIds = shareToManageFiles.folders?.map((f: any) => f.id) || [];
const filesToAdd = selectedFiles.filter((id: string) => !currentFileIds.includes(id));
const filesToRemove = currentFileIds.filter((id: string) => !selectedFiles.includes(id));
const foldersToAdd = selectedFolders.filter((id: string) => !currentFolderIds.includes(id));
const foldersToRemove = currentFolderIds.filter((id: string) => !selectedFolders.includes(id));
const promises = [];
if (filesToAdd.length > 0) {
promises.push(addFiles(shareToManageFiles.id, { files: filesToAdd }));
}
if (filesToRemove.length > 0) {
promises.push(removeFiles(shareToManageFiles.id, { files: filesToRemove }));
}
if (foldersToAdd.length > 0) {
promises.push(addFolders(shareToManageFiles.id, { folders: foldersToAdd }));
}
if (foldersToRemove.length > 0) {
promises.push(removeFolders(shareToManageFiles.id, { folders: foldersToRemove }));
}
await Promise.all(promises);
onSuccess();
onCloseManageFiles();
toast.success(t("shareActions.editSuccess"));
} catch (error) {
console.error("Error updating share files:", error);
toast.error(t("shareActions.editError"));
throw error;
} finally {
setIsManageFilesSaving(false);
}
};
const handleManageFilesClose = () => {
if (!isManageFilesSaving) {
setManageFilesSelectedItems([]);
setManageFilesSearchQuery("");
onCloseManageFiles();
}
};
return (
<>
<Dialog open={!!shareToDelete} onOpenChange={() => onCloseDelete()}>
@@ -139,31 +265,31 @@ export function ShareActionsModals({
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="grid w-full items-center gap-1.5">
<Label>{t("shareActions.nameLabel")}</Label>
<Label>{t("createShare.nameLabel")}</Label>
<Input value={editForm.name} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })} />
</div>
<div className="grid w-full items-center gap-1.5">
<Label>{t("shareActions.descriptionLabel")}</Label>
<Label>{t("createShare.descriptionLabel")}</Label>
<Input
value={editForm.description}
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
placeholder={t("shareActions.descriptionPlaceholder")}
placeholder={t("createShare.descriptionPlaceholder")}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label>{t("shareActions.expirationLabel")}</Label>
<Label>{t("createShare.expirationLabel")}</Label>
<Input
placeholder={t("shareActions.expirationPlaceholder")}
placeholder={t("createShare.expirationPlaceholder")}
type="datetime-local"
value={editForm.expiresAt}
onChange={(e) => setEditForm({ ...editForm, expiresAt: e.target.value })}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label>{t("shareActions.maxViewsLabel")}</Label>
<Label>{t("createShare.maxViewsLabel")}</Label>
<Input
min="1"
placeholder={t("shareActions.maxViewsPlaceholder")}
placeholder={t("createShare.maxViewsPlaceholder")}
type="number"
value={editForm.maxViews}
onChange={(e) => setEditForm({ ...editForm, maxViews: e.target.value })}
@@ -180,20 +306,20 @@ export function ShareActionsModals({
})
}
/>
<Label>{t("shareActions.passwordProtection")}</Label>
<Label>{t("createShare.passwordProtection")}</Label>
</div>
{editForm.isPasswordProtected && (
<div className="grid w-full items-center gap-1.5">
<Label>
{shareToEdit?.security?.hasPassword
? t("shareActions.newPasswordLabel")
: t("shareActions.passwordLabel")}
: t("createShare.passwordLabel")}
</Label>
<Input
placeholder={
shareToEdit?.security?.hasPassword
? t("shareActions.newPasswordPlaceholder")
: t("shareActions.passwordPlaceholder")
: t("createShare.passwordLabel")
}
type="password"
value={editForm.password}
@@ -213,22 +339,84 @@ export function ShareActionsModals({
</DialogContent>
</Dialog>
<Dialog open={!!shareToManageFiles} onOpenChange={() => onCloseManageFiles()}>
<DialogContent className="sm:max-w-[450px] md:max-w-[550px] lg:max-w-[650px] max-h-[80vh] overflow-hidden">
<Dialog open={!!shareToManageFiles} onOpenChange={handleManageFilesClose}>
<DialogContent className="max-w-2xl max-h-[80vh] w-full">
<DialogHeader>
<DialogTitle>{t("shareActions.manageFilesTitle")}</DialogTitle>
<DialogDescription>Select files and folders to include in this share</DialogDescription>
</DialogHeader>
<div className="overflow-y-auto max-h-[calc(80vh-120px)]">
<FileSelector
selectedFiles={shareToManageFiles?.files?.map((file: { id: string }) => file.id) || []}
shareId={shareToManageFiles?.id}
onSave={async (files) => {
await onManageFiles(shareToManageFiles?.id, files);
onSuccess();
}}
onEditFile={onEditFile}
<div className="flex flex-col gap-4 flex-1 min-h-0 w-full overflow-hidden">
{/* Search */}
<div className="space-y-2">
<Label htmlFor="manage-files-search">{t("common.search")}</Label>
<Input
id="manage-files-search"
type="search"
placeholder={t("searchBar.placeholder")}
value={manageFilesSearchQuery}
onChange={(e) => setManageFilesSearchQuery(e.target.value)}
disabled={isManageFilesLoading}
/>
</div>
{/* Selection Count */}
<div className="text-sm text-muted-foreground">
{manageFilesSelectedItems.length > 0 && <span>{manageFilesSelectedItems.length} items selected</span>}
</div>
{/* File Tree */}
<div className="flex-1 min-h-0 w-full overflow-hidden">
{isManageFilesLoading ? (
<div className="flex items-center justify-center py-8">
<div className="text-sm text-muted-foreground">{t("common.loadingSimple")}</div>
</div>
) : (
<FileTree
files={manageFilesTreeFiles.map((file) => ({
id: file.id,
name: file.name,
description: "",
extension: "",
size: file.size?.toString() || "0",
objectName: "",
userId: "",
folderId: file.parentId,
createdAt: "",
updatedAt: "",
}))}
folders={manageFilesTreeFolders.map((folder) => ({
id: folder.id,
name: folder.name,
description: "",
parentId: folder.parentId,
userId: "",
createdAt: "",
updatedAt: "",
totalSize: folder.totalSize,
}))}
selectedItems={manageFilesSelectedItems}
onSelectionChange={setManageFilesSelectedItems}
showFiles={true}
showFolders={true}
maxHeight="400px"
searchQuery={manageFilesSearchQuery}
/>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleManageFilesClose} disabled={isManageFilesSaving}>
{t("common.cancel")}
</Button>
<Button
onClick={handleManageFilesSave}
disabled={isManageFilesLoading || isManageFilesSaving || manageFilesSelectedItems.length === 0}
>
{isManageFilesSaving ? t("common.saving") : t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -217,36 +217,29 @@ export function ShareDetailsModal({
const downloadQRCode = () => {
setIsDownloading(true);
// Get the SVG element
const svg = document.getElementById("share-details-qr-code");
if (!svg) {
setIsDownloading(false);
return;
}
// Create a canvas
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
// Set dimensions (with some padding)
const padding = 20;
canvas.width = 200 + padding * 2;
canvas.height = 200 + padding * 2;
// Fill white background
if (ctx) {
ctx.fillStyle = "#FFFFFF";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Convert SVG to data URL
const svgData = new XMLSerializer().serializeToString(svg);
const img = new Image();
img.onload = () => {
// Draw the image in the center of the canvas with padding
ctx.drawImage(img, padding, padding, 200, 200);
// Create a download link
const link = document.createElement("a");
link.download = `${share?.name?.replace(/[^a-z0-9]/gi, "-").toLowerCase() || "share"}-qr-code.png`;
link.href = canvas.toDataURL("image/png");

View File

@@ -11,7 +11,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { addFiles, createShare, createShareAlias } from "@/http/endpoints";
import { createShare, createShareAlias, listFiles, listFolders } from "@/http/endpoints";
import { customNanoid } from "@/lib/utils";
interface File {
@@ -24,16 +24,28 @@ interface File {
updatedAt: string;
}
interface ShareFileModalProps {
interface Folder {
id: string;
name: string;
description?: string;
objectName: string;
parentId?: string;
userId: string;
createdAt: string;
updatedAt: string;
}
interface ShareItemModalProps {
isOpen: boolean;
file: File | null;
file?: File | null;
folder?: Folder | null;
onClose: () => void;
onSuccess: () => void;
}
const generateCustomId = () => customNanoid(10, "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ");
export function ShareFileModal({ isOpen, file, onClose, onSuccess }: ShareFileModalProps) {
export function ShareItemModal({ isOpen, file, folder, onClose, onSuccess }: ShareItemModalProps) {
const t = useTranslations();
const [step, setStep] = useState<"create" | "link">("create");
const [shareId, setShareId] = useState<string | null>(null);
@@ -49,10 +61,14 @@ export function ShareFileModal({ isOpen, file, onClose, onSuccess }: ShareFileMo
const [generatedLink, setGeneratedLink] = useState("");
const [isLoading, setIsLoading] = useState(false);
const item = file || folder;
const itemType = file ? "file" : "folder";
useEffect(() => {
if (isOpen && file) {
if (isOpen && item) {
const baseName = file ? file.name.split(".")[0] : folder!.name;
setFormData({
name: `${file.name.split(".")[0]}`,
name: baseName,
description: "",
password: "",
expiresAt: "",
@@ -64,27 +80,70 @@ export function ShareFileModal({ isOpen, file, onClose, onSuccess }: ShareFileMo
setShareId(null);
setGeneratedLink("");
}
}, [isOpen, file]);
}, [isOpen, item, file, folder]);
const getAllFolderContents = async (folderId: string): Promise<{ files: string[]; folders: string[] }> => {
try {
const [filesResponse, foldersResponse] = await Promise.all([listFiles(), listFolders()]);
const allFiles = filesResponse.data.files || [];
const allFolders = foldersResponse.data.folders || [];
const collectContents = (parentId: string): { files: string[]; folders: string[] } => {
const folderFiles = allFiles.filter((f: any) => f.folderId === parentId).map((f: any) => f.id);
const subFolders = allFolders.filter((f: any) => f.parentId === parentId);
const subFolderIds = subFolders.map((f: any) => f.id);
let allSubFiles: string[] = [...folderFiles];
let allSubFolders: string[] = [...subFolderIds];
subFolders.forEach((subFolder: any) => {
const subContents = collectContents(subFolder.id);
allSubFiles = [...allSubFiles, ...subContents.files];
allSubFolders = [...allSubFolders, ...subContents.folders];
});
return { files: allSubFiles, folders: allSubFolders };
};
return collectContents(folderId);
} catch (error) {
console.error("Error getting folder contents:", error);
return { files: [], folders: [] };
}
};
const handleCreateShare = async () => {
if (!file) return;
if (!item) return;
try {
setIsLoading(true);
let filesToShare: string[] = [];
let foldersToShare: string[] = [];
if (file) {
filesToShare = [file.id];
} else if (folder) {
const folderContents = await getAllFolderContents(folder.id);
filesToShare = folderContents.files;
foldersToShare = [folder.id, ...folderContents.folders];
}
const shareResponse = await createShare({
name: formData.name,
description: formData.description || undefined,
password: formData.isPasswordProtected ? formData.password : undefined,
expiration: formData.expiresAt ? new Date(formData.expiresAt).toISOString() : undefined,
maxViews: formData.maxViews ? parseInt(formData.maxViews) : undefined,
files: [],
files: filesToShare,
folders: foldersToShare,
});
const newShareId = shareResponse.data.share.id;
setShareId(newShareId);
await addFiles(newShareId, { files: [file.id] });
toast.success(t("createShare.success"));
setStep("link");
} catch {
@@ -125,10 +184,10 @@ export function ShareFileModal({ isOpen, file, onClose, onSuccess }: ShareFileMo
};
const downloadQRCode = () => {
const canvas = document.getElementById("share-file-qr-code") as HTMLCanvasElement;
const canvas = document.getElementById("share-item-qr-code") as HTMLCanvasElement;
if (canvas) {
const link = document.createElement("a");
link.download = "share-file-qr-code.png";
link.download = `share-${itemType}-qr-code.png`;
link.href = canvas.toDataURL("image/png");
document.body.appendChild(link);
link.click();
@@ -166,12 +225,12 @@ export function ShareFileModal({ isOpen, file, onClose, onSuccess }: ShareFileMo
{step === "create" ? (
<>
<IconShare size={20} />
{t("shareFile.title")}
{itemType === "file" ? t("shareActions.fileTitle") : t("shareActions.folderTitle")}
</>
) : (
<>
<IconLink size={20} />
{t("shareFile.linkTitle")}
{t("shareActions.linkTitle")}
</>
)}
</DialogTitle>
@@ -180,30 +239,30 @@ export function ShareFileModal({ isOpen, file, onClose, onSuccess }: ShareFileMo
{step === "create" && (
<div className="flex flex-col gap-4">
<div className="space-y-2">
<Label>{t("shareFile.nameLabel")}</Label>
<Label>{t("createShare.nameLabel")}</Label>
<Input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder={t("shareFile.namePlaceholder")}
placeholder={t("createShare.namePlaceholder")}
/>
</div>
<div className="space-y-2">
<Label>{t("shareFile.descriptionLabel")}</Label>
<Label>{t("createShare.descriptionLabel")}</Label>
<Input
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder={t("shareFile.descriptionPlaceholder")}
placeholder={t("createShare.descriptionPlaceholder")}
/>
</div>
<div className="space-y-2">
<Label className="flex items-center gap-2">
<IconCalendar size={16} />
{t("shareFile.expirationLabel")}
{t("createShare.expirationLabel")}
</Label>
<Input
placeholder={t("shareFile.expirationPlaceholder")}
placeholder={t("createShare.expirationPlaceholder")}
type="datetime-local"
value={formData.expiresAt}
onChange={(e) => setFormData({ ...formData, expiresAt: e.target.value })}
@@ -213,11 +272,11 @@ export function ShareFileModal({ isOpen, file, onClose, onSuccess }: ShareFileMo
<div className="space-y-2">
<Label className="flex items-center gap-2">
<IconEye size={16} />
{t("shareFile.maxViewsLabel")}
{t("createShare.maxViewsLabel")}
</Label>
<Input
min="1"
placeholder={t("shareFile.maxViewsPlaceholder")}
placeholder={t("createShare.maxViewsPlaceholder")}
type="number"
value={formData.maxViews}
onChange={(e) => setFormData({ ...formData, maxViews: e.target.value })}
@@ -238,18 +297,18 @@ export function ShareFileModal({ isOpen, file, onClose, onSuccess }: ShareFileMo
/>
<Label htmlFor="password-protection" className="flex items-center gap-2">
<IconLock size={16} />
{t("shareFile.passwordProtection")}
{t("createShare.passwordProtection")}
</Label>
</div>
{formData.isPasswordProtected && (
<div className="space-y-2">
<Label>{t("shareFile.passwordLabel")}</Label>
<Label>{t("createShare.passwordLabel")}</Label>
<Input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder={t("shareFile.passwordPlaceholder")}
placeholder={t("createShare.passwordLabel")}
/>
</div>
)}
@@ -260,11 +319,15 @@ export function ShareFileModal({ isOpen, file, onClose, onSuccess }: ShareFileMo
<div className="space-y-4">
{!generatedLink ? (
<>
<p className="text-sm text-muted-foreground">{t("shareFile.linkDescription")}</p>
<p className="text-sm text-muted-foreground">
{itemType === "file"
? t("shareActions.linkDescriptionFile")
: t("shareActions.linkDescriptionFolder")}
</p>
<div className="space-y-2">
<Label>{t("shareFile.aliasLabel")}</Label>
<Label>{t("shareActions.aliasLabel")}</Label>
<Input
placeholder={t("shareFile.aliasPlaceholder")}
placeholder={t("shareActions.aliasPlaceholder")}
value={alias}
onChange={(e) => setAlias(e.target.value)}
/>
@@ -276,7 +339,7 @@ export function ShareFileModal({ isOpen, file, onClose, onSuccess }: ShareFileMo
<div className="p-4 bg-white rounded-lg">
<svg style={{ display: "none" }} /> {/* For SSR safety */}
<QRCode
id="share-file-qr-code"
id="share-item-qr-code"
value={generatedLink}
size={250}
level="H"
@@ -285,10 +348,10 @@ export function ShareFileModal({ isOpen, file, onClose, onSuccess }: ShareFileMo
/>
</div>
</div>
<p className="text-sm text-muted-foreground">{t("shareFile.linkReady")}</p>
<p className="text-sm text-muted-foreground">{t("shareActions.linkReady")}</p>
<div className="flex gap-2">
<Input readOnly value={generatedLink} className="flex-1" />
<Button size="icon" variant="outline" onClick={handleCopyLink} title={t("shareFile.copyLink")}>
<Button size="icon" variant="outline" onClick={handleCopyLink} title={t("shareActions.copyLink")}>
<IconCopy className="h-4 w-4" />
</Button>
</div>
@@ -304,7 +367,7 @@ export function ShareFileModal({ isOpen, file, onClose, onSuccess }: ShareFileMo
{t("common.cancel")}
</Button>
<Button disabled={isLoading || !formData.name.trim()} onClick={handleCreateShare}>
{isLoading ? <div className="animate-spin"></div> : t("shareFile.createShare")}
{isLoading ? <div className="animate-spin"></div> : t("createShare.create")}
</Button>
</>
)}
@@ -315,7 +378,7 @@ export function ShareFileModal({ isOpen, file, onClose, onSuccess }: ShareFileMo
{t("common.back")}
</Button>
<Button disabled={!alias || isLoading} onClick={handleGenerateLink}>
{isLoading ? <div className="animate-spin"></div> : t("shareFile.generateLink")}
{isLoading ? <div className="animate-spin"></div> : t("shareActions.generateLink")}
</Button>
</>
)}

View File

@@ -1,7 +1,16 @@
"use client";
import { useEffect, useState } from "react";
import { IconCalendar, IconCopy, IconDownload, IconEye, IconLink, IconLock, IconShare } from "@tabler/icons-react";
import {
IconCalendar,
IconCopy,
IconDownload,
IconEye,
IconFolder,
IconLink,
IconLock,
IconShare,
} from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import QRCode from "react-qr-code";
import { toast } from "sonner";
@@ -12,8 +21,9 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
import { addFiles, createShare, createShareAlias } from "@/http/endpoints";
import { createShare, createShareAlias, listFiles, listFolders } from "@/http/endpoints";
import { customNanoid } from "@/lib/utils";
import { getFileIcon } from "@/utils/file-icons";
interface BulkFile {
id: string;
@@ -25,8 +35,35 @@ interface BulkFile {
updatedAt: string;
}
interface ShareMultipleFilesModalProps {
interface BulkFolder {
id: string;
name: string;
description?: string;
objectName: string;
parentId?: string;
userId: string;
createdAt: string;
updatedAt: string;
totalSize?: string;
_count?: {
files: number;
children: number;
};
}
interface BulkItem {
id: string;
name: string;
description?: string;
size?: number;
type: "file" | "folder";
createdAt: string;
updatedAt: string;
}
interface ShareMultipleItemsModalProps {
files: BulkFile[] | null;
folders: BulkFolder[] | null;
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
@@ -34,7 +71,7 @@ interface ShareMultipleFilesModalProps {
const generateCustomId = () => customNanoid(10, "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ");
export function ShareMultipleFilesModal({ files, isOpen, onClose, onSuccess }: ShareMultipleFilesModalProps) {
export function ShareMultipleItemsModal({ files, folders, isOpen, onClose, onSuccess }: ShareMultipleItemsModalProps) {
const t = useTranslations();
const [step, setStep] = useState<"create" | "link">("create");
const [shareId, setShareId] = useState<string | null>(null);
@@ -51,9 +88,27 @@ export function ShareMultipleFilesModal({ files, isOpen, onClose, onSuccess }: S
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (isOpen && files && files.length > 0) {
if (isOpen && ((files && files.length > 0) || (folders && folders.length > 0))) {
const fileCount = files ? files.length : 0;
const folderCount = folders ? folders.length : 0;
const totalCount = fileCount + folderCount;
let defaultName = "";
if (totalCount === 1) {
if (fileCount === 1 && files) {
defaultName = files[0].name.split(".")[0];
} else if (folderCount === 1 && folders) {
defaultName = folders[0].name;
}
} else {
const items = [];
if (fileCount > 0) items.push(`${fileCount} files`);
if (folderCount > 0) items.push(`${folderCount} folders`);
defaultName = `${items.join(" and ")} shared`;
}
setFormData({
name: files.length === 1 ? `${files[0].name.split(".")[0]}` : `${files.length} arquivos compartilhados`,
name: defaultName,
description: "",
password: "",
expiresAt: "",
@@ -65,27 +120,73 @@ export function ShareMultipleFilesModal({ files, isOpen, onClose, onSuccess }: S
setShareId(null);
setGeneratedLink("");
}
}, [isOpen, files]);
}, [isOpen, files, folders]);
const getAllFolderContents = async (folderId: string): Promise<{ files: string[]; folders: string[] }> => {
try {
const [filesResponse, foldersResponse] = await Promise.all([listFiles(), listFolders()]);
const allFiles = filesResponse.data.files || [];
const allFolders = foldersResponse.data.folders || [];
const collectContents = (parentId: string): { files: string[]; folders: string[] } => {
const folderFiles = allFiles.filter((f: any) => f.folderId === parentId).map((f: any) => f.id);
const subFolders = allFolders.filter((f: any) => f.parentId === parentId);
const subFolderIds = subFolders.map((f: any) => f.id);
let allSubFiles: string[] = [...folderFiles];
let allSubFolders: string[] = [...subFolderIds];
subFolders.forEach((subFolder: any) => {
const subContents = collectContents(subFolder.id);
allSubFiles = [...allSubFiles, ...subContents.files];
allSubFolders = [...allSubFolders, ...subContents.folders];
});
return { files: allSubFiles, folders: allSubFolders };
};
return collectContents(folderId);
} catch (error) {
console.error("Error getting folder contents:", error);
return { files: [], folders: [] };
}
};
const handleCreateShare = async () => {
if (!files || files.length === 0) return;
const fileCount = files ? files.length : 0;
const folderCount = folders ? folders.length : 0;
if (fileCount === 0 && folderCount === 0) return;
try {
setIsLoading(true);
let allFilesToShare: string[] = files ? files.map((f) => f.id) : [];
let allFoldersToShare: string[] = folders ? folders.map((f) => f.id) : [];
if (folders && folders.length > 0) {
for (const folder of folders) {
const folderContents = await getAllFolderContents(folder.id);
allFilesToShare = [...allFilesToShare, ...folderContents.files];
allFoldersToShare = [...allFoldersToShare, ...folderContents.folders];
}
}
const shareResponse = await createShare({
name: formData.name,
description: formData.description || undefined,
password: formData.isPasswordProtected ? formData.password : undefined,
expiration: formData.expiresAt ? new Date(formData.expiresAt).toISOString() : undefined,
maxViews: formData.maxViews ? parseInt(formData.maxViews) : undefined,
files: [],
files: allFilesToShare,
folders: allFoldersToShare,
});
const newShareId = shareResponse.data.share.id;
setShareId(newShareId);
await addFiles(newShareId, { files: files.map((f) => f.id) });
toast.success(t("createShare.success"));
setStep("link");
} catch {
@@ -151,9 +252,34 @@ export function ShareMultipleFilesModal({ files, isOpen, onClose, onSuccess }: S
handleClose();
};
if (!files) return null;
if (!files && !folders) return null;
const totalSize = files.reduce((sum, file) => sum + file.size, 0);
const filesList = files || [];
const foldersList = folders || [];
const allItems: BulkItem[] = [
...filesList.map((file) => ({
id: file.id,
name: file.name,
description: file.description,
size: file.size,
type: "file" as const,
createdAt: file.createdAt,
updatedAt: file.updatedAt,
})),
...foldersList.map((folder) => ({
id: folder.id,
name: folder.name,
description: folder.description,
size: folder.totalSize ? parseInt(folder.totalSize) : undefined,
type: "folder" as const,
createdAt: folder.createdAt,
updatedAt: folder.updatedAt,
})),
];
const totalSize =
filesList.reduce((sum, file) => sum + file.size, 0) +
foldersList.reduce((sum, folder) => sum + (folder.totalSize ? parseInt(folder.totalSize) : 0), 0);
const formatFileSize = (bytes: number) => {
const sizes = ["B", "KB", "MB", "GB"];
if (bytes === 0) return "0 B";
@@ -174,7 +300,7 @@ export function ShareMultipleFilesModal({ files, isOpen, onClose, onSuccess }: S
) : (
<>
<IconLink size={20} />
{t("shareFile.linkTitle")}
{t("shareActions.linkTitle")}
</>
)}
</DialogTitle>
@@ -205,10 +331,10 @@ export function ShareMultipleFilesModal({ files, isOpen, onClose, onSuccess }: S
<div className="space-y-2">
<Label className="flex items-center gap-2">
<IconCalendar size={16} />
{t("shareFile.expirationLabel")}
{t("createShare.expirationLabel")}
</Label>
<Input
placeholder={t("shareFile.expirationPlaceholder")}
placeholder={t("createShare.expirationPlaceholder")}
type="datetime-local"
value={formData.expiresAt}
onChange={(e) => setFormData({ ...formData, expiresAt: e.target.value })}
@@ -218,11 +344,11 @@ export function ShareMultipleFilesModal({ files, isOpen, onClose, onSuccess }: S
<div className="space-y-2">
<Label className="flex items-center gap-2">
<IconEye size={16} />
{t("shareFile.maxViewsLabel")}
{t("createShare.maxViewsLabel")}
</Label>
<Input
min="1"
placeholder={t("shareFile.maxViewsPlaceholder")}
placeholder={t("createShare.maxViewsPlaceholder")}
type="number"
value={formData.maxViews}
onChange={(e) => setFormData({ ...formData, maxViews: e.target.value })}
@@ -243,38 +369,48 @@ export function ShareMultipleFilesModal({ files, isOpen, onClose, onSuccess }: S
/>
<Label htmlFor="password-protection" className="flex items-center gap-2">
<IconLock size={16} />
{t("shareFile.passwordProtection")}
{t("createShare.passwordProtection")}
</Label>
</div>
{formData.isPasswordProtected && (
<div className="space-y-2">
<Label>{t("shareFile.passwordLabel")}</Label>
<Label>{t("createShare.passwordLabel")}</Label>
<Input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder={t("shareFile.passwordPlaceholder")}
placeholder={t("createShare.passwordLabel")}
/>
</div>
)}
<div className="space-y-2">
<Label>
{t("shareMultipleFiles.filesToShare")} ({files.length} {t("shareMultipleFiles.files")})
</Label>
<Label>{t("shareMultipleFiles.itemsToShare", { count: allItems.length })}</Label>
<ScrollArea className="h-32 w-full rounded-md border p-2 bg-muted/30">
<div className="space-y-1">
{files.map((file) => (
<div key={file.id} className="flex justify-between items-center text-sm">
<span className="truncate flex-1">{file.name}</span>
<span className="text-muted-foreground ml-2">{formatFileSize(file.size)}</span>
{allItems.map((item) => {
const isFolder = item.type === "folder";
const { icon: FileIcon, color } = isFolder
? { icon: IconFolder, color: "text-primary" }
: getFileIcon(item.name);
return (
<div key={item.id} className="flex justify-between items-center text-sm">
<div className="flex items-center gap-2 truncate flex-1">
<FileIcon className={`h-4 w-4 ${color} flex-shrink-0`} />
<span className="truncate">{item.name}</span>
</div>
))}
<span className="text-muted-foreground ml-2">
{item.size ? formatFileSize(item.size) : "—"}
</span>
</div>
);
})}
</div>
</ScrollArea>
<p className="text-xs text-muted-foreground">
{t("shareMultipleFiles.totalSize")}: {formatFileSize(totalSize)}
Total size: {formatFileSize(totalSize)} ({filesList.length} files, {foldersList.length} folders)
</p>
</div>
</div>
@@ -284,11 +420,11 @@ export function ShareMultipleFilesModal({ files, isOpen, onClose, onSuccess }: S
<div className="space-y-4">
{!generatedLink ? (
<>
<p className="text-sm text-muted-foreground">{t("shareFile.linkDescription")}</p>
<p className="text-sm text-muted-foreground">{t("shareActions.linkDescriptionFile")}</p>
<div className="space-y-2">
<Label>{t("shareFile.aliasLabel")}</Label>
<Label>{t("shareActions.aliasLabel")}</Label>
<Input
placeholder={t("shareFile.aliasPlaceholder")}
placeholder={t("shareActions.aliasPlaceholder")}
value={alias}
onChange={(e) => setAlias(e.target.value)}
/>
@@ -309,10 +445,10 @@ export function ShareMultipleFilesModal({ files, isOpen, onClose, onSuccess }: S
/>
</div>
</div>
<p className="text-sm text-muted-foreground">{t("shareFile.linkReady")}</p>
<p className="text-sm text-muted-foreground">{t("shareActions.linkReady")}</p>
<div className="flex gap-2">
<Input readOnly value={generatedLink} className="flex-1" />
<Button variant="outline" size="icon" onClick={handleCopyLink} title={t("shareFile.copyLink")}>
<Button variant="outline" size="icon" onClick={handleCopyLink} title={t("shareActions.copyLink")}>
<IconCopy className="h-4 w-4" />
</Button>
</div>
@@ -345,7 +481,7 @@ export function ShareMultipleFilesModal({ files, isOpen, onClose, onSuccess }: S
{t("common.back")}
</Button>
<Button disabled={!alias || isLoading} onClick={handleGenerateLink}>
{isLoading ? <div className="animate-spin"></div> : t("shareFile.generateLink")}
{isLoading ? <div className="animate-spin"></div> : t("shareActions.generateLink")}
</Button>
</>
)}

View File

@@ -9,7 +9,7 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Progress } from "@/components/ui/progress";
import { checkFile, getPresignedUrl, registerFile } from "@/http/endpoints";
import { checkFile, getFilePresignedUrl, registerFile } from "@/http/endpoints";
import { getSystemInfo } from "@/http/endpoints/app";
import { ChunkedUploader } from "@/utils/chunked-upload";
import { getFileIcon } from "@/utils/file-icons";
@@ -21,6 +21,7 @@ interface UploadFileModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
currentFolderId?: string;
}
enum UploadStatus {
@@ -82,7 +83,7 @@ function ConfirmationModal({ isOpen, onConfirm, onCancel, uploadsInProgress }: C
);
}
export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalProps) {
export function UploadFileModal({ isOpen, onClose, onSuccess, currentFolderId }: UploadFileModalProps) {
const t = useTranslations();
const [fileUploads, setFileUploads] = useState<FileUpload[]>([]);
const [isDragOver, setIsDragOver] = useState(false);
@@ -220,7 +221,6 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
if (upload.objectName && upload.status === UploadStatus.UPLOADING) {
try {
// await deleteUploadedFile(upload.objectName);
} catch (error) {
console.error("Failed to delete uploaded file:", error);
}
@@ -245,6 +245,7 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
objectName: safeObjectName,
size: file.size,
extension: extension,
folderId: currentFolderId,
});
} catch (error) {
console.error("File check failed:", error);
@@ -269,7 +270,7 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
prev.map((u) => (u.id === id ? { ...u, status: UploadStatus.UPLOADING, progress: 0 } : u))
);
const presignedResponse = await getPresignedUrl({
const presignedResponse = await getFilePresignedUrl({
filename: safeObjectName.replace(`.${extension}`, ""),
extension: extension,
});
@@ -308,6 +309,7 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
objectName: finalObjectName,
size: file.size,
extension: extension,
folderId: currentFolderId,
});
} else {
await axios.put(url, file, {
@@ -329,6 +331,7 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
objectName: objectName,
size: file.size,
extension: extension,
folderId: currentFolderId,
});
}
@@ -378,13 +381,12 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
const errorCount = currentUploads.filter((u) => u.status === UploadStatus.ERROR).length;
if (successCount > 0) {
toast.success(
errorCount > 0
? t("uploadFile.partialSuccess", { success: successCount, error: errorCount })
: t("uploadFile.allSuccess", { count: successCount })
);
if (errorCount > 0) {
toast.error(t("uploadFile.partialSuccess", { success: successCount, error: errorCount }));
}
setHasShownSuccessToast(true);
onSuccess?.();
setTimeout(() => onSuccess?.(), 0);
}
}

View File

@@ -1,10 +1,12 @@
import { useEffect, useRef, useState } from "react";
import {
IconArrowsMove,
IconChevronDown,
IconDotsVertical,
IconDownload,
IconEdit,
IconEye,
IconFolder,
IconShare,
IconTrash,
} from "@tabler/icons-react";
@@ -29,29 +31,59 @@ interface File {
id: string;
name: string;
description?: string;
extension: string;
size: number;
objectName: string;
userId: string;
folderId?: string;
createdAt: string;
updatedAt: string;
}
interface Folder {
id: string;
name: string;
description?: string;
objectName: string;
parentId?: string;
userId: string;
createdAt: string;
updatedAt: string;
totalSize?: string;
_count?: {
files: number;
children: number;
};
}
interface FilesGridProps {
files: File[];
onPreview: (file: File) => void;
onRename: (file: File) => void;
onUpdateName: (fileId: string, newName: string) => void;
onUpdateDescription: (fileId: string, newDescription: string) => void;
folders?: Folder[];
onPreview?: (file: File) => void;
onRename?: (file: File) => void;
onDownload: (objectName: string, fileName: string) => void;
onShare: (file: File) => void;
onDelete: (file: File) => void;
onBulkDelete?: (files: File[]) => void;
onBulkShare?: (files: File[]) => void;
onBulkDownload?: (files: File[]) => void;
onShare?: (file: File) => void;
onDelete?: (file: File) => void;
onBulkDelete?: (files: File[], folders: Folder[]) => void;
onBulkShare?: (files: File[], folders: Folder[]) => void;
onBulkDownload?: (files: File[], folders: Folder[]) => void;
onBulkMove?: (files: File[], folders: Folder[]) => void;
setClearSelectionCallback?: (callback: () => void) => void;
onNavigateToFolder?: (folderId: string) => void;
onRenameFolder?: (folder: Folder) => void;
onDeleteFolder?: (folder: Folder) => void;
onShareFolder?: (folder: Folder) => void;
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
onMoveFolder?: (folder: Folder) => void;
onMoveFile?: (file: File) => void;
showBulkActions?: boolean;
isShareMode?: boolean;
}
export function FilesGrid({
files,
folders = [],
onPreview,
onRename,
onDownload,
@@ -60,11 +92,23 @@ export function FilesGrid({
onBulkDelete,
onBulkShare,
onBulkDownload,
onBulkMove,
setClearSelectionCallback,
onNavigateToFolder,
onRenameFolder,
onDeleteFolder,
onShareFolder,
onDownloadFolder,
onMoveFolder,
onMoveFile,
showBulkActions = true,
isShareMode = false,
}: FilesGridProps) {
const t = useTranslations();
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [selectedFolders, setSelectedFolders] = useState<Set<string>>(new Set());
const [filePreviewUrls, setFilePreviewUrls] = useState<Record<string, string>>({});
const loadingUrls = useRef<Set<string>>(new Set());
@@ -79,10 +123,19 @@ export function FilesGrid({
}, []);
useEffect(() => {
const clearSelection = () => setSelectedFiles(new Set());
const clearSelection = () => {
setSelectedFiles(new Set());
setSelectedFolders(new Set());
};
setClearSelectionCallback?.(clearSelection);
}, [setClearSelectionCallback]);
const folderIds = folders?.map((f) => f.id).join(",");
useEffect(() => {
setSelectedFolders(new Set());
}, [folderIds]);
const isImageFile = (fileName: string) => {
const imageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
return imageExtensions.some((ext) => fileName.toLowerCase().endsWith(ext));
@@ -143,8 +196,10 @@ export function FilesGrid({
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedFiles(new Set(files.map((file) => file.id)));
setSelectedFolders(new Set(folders.map((folder) => folder.id)));
} else {
setSelectedFiles(new Set());
setSelectedFolders(new Set());
}
};
@@ -163,44 +218,67 @@ export function FilesGrid({
return files.filter((file) => selectedFiles.has(file.id));
};
const isAllSelected = files.length > 0 && selectedFiles.size === files.length;
const getSelectedFolders = () => {
return folders.filter((folder) => selectedFolders.has(folder.id));
};
const handleBulkAction = (action: "delete" | "share" | "download") => {
const totalItems = files.length + folders.length;
const selectedItems = selectedFiles.size + selectedFolders.size;
const isAllSelected = totalItems > 0 && selectedItems === totalItems;
const handleBulkAction = (action: "delete" | "share" | "download" | "move") => {
const selectedFileObjects = getSelectedFiles();
const selectedFolderObjects = getSelectedFolders();
if (selectedFileObjects.length === 0) return;
if (selectedFileObjects.length === 0 && selectedFolderObjects.length === 0) return;
switch (action) {
case "delete":
if (onBulkDelete) {
onBulkDelete(selectedFileObjects);
onBulkDelete(selectedFileObjects, selectedFolderObjects);
}
break;
case "share":
if (onBulkShare) {
onBulkShare(selectedFileObjects);
onBulkShare(selectedFileObjects, selectedFolderObjects);
}
break;
case "download":
if (onBulkDownload) {
onBulkDownload(selectedFileObjects);
onBulkDownload(selectedFileObjects, selectedFolderObjects);
}
break;
case "move":
if (onBulkMove) {
onBulkMove(selectedFileObjects, selectedFolderObjects);
}
break;
}
};
const showBulkActions = selectedFiles.size > 0 && (onBulkDelete || onBulkShare || onBulkDownload);
const shouldShowBulkActions =
showBulkActions &&
(selectedFiles.size > 0 || selectedFolders.size > 0) &&
(isShareMode ? onBulkDownload : onBulkDelete || onBulkShare || onBulkDownload || onBulkMove);
return (
<div className="space-y-4">
{showBulkActions && (
{shouldShowBulkActions && (
<div className="flex items-center justify-between p-4 bg-muted/30 border rounded-lg">
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-foreground">
{t("filesTable.bulkActions.selected", { count: selectedFiles.size })}
{t("filesTable.bulkActions.selected", { count: selectedItems })}
</span>
</div>
<div className="flex items-center gap-2">
{isShareMode ? (
onBulkDownload && (
<Button variant="default" size="sm" className="gap-2" onClick={() => handleBulkAction("download")}>
<IconDownload className="h-4 w-4" />
{t("filesTable.bulkActions.download")}
</Button>
)
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="default" size="sm" className="gap-2">
@@ -209,6 +287,12 @@ export function FilesGrid({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
{onBulkMove && (
<DropdownMenuItem className="cursor-pointer py-2" onClick={() => handleBulkAction("move")}>
<IconArrowsMove className="h-4 w-4" />
{t("common.move")}
</DropdownMenuItem>
)}
{onBulkDownload && (
<DropdownMenuItem className="cursor-pointer py-2" onClick={() => handleBulkAction("download")}>
<IconDownload className="h-4 w-4" />
@@ -232,7 +316,15 @@ export function FilesGrid({
)}
</DropdownMenuContent>
</DropdownMenu>
<Button variant="outline" size="sm" onClick={() => setSelectedFiles(new Set())}>
)}
<Button
variant="outline"
size="sm"
onClick={() => {
setSelectedFiles(new Set());
setSelectedFolders(new Set());
}}
>
{t("common.cancel")}
</Button>
</div>
@@ -244,7 +336,152 @@ export function FilesGrid({
<span className="text-sm text-muted-foreground">{t("filesTable.selectAll")}</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{/* Render folders first */}
{folders.map((folder) => {
const isSelected = selectedFolders.has(folder.id);
return (
<div
key={`folder-${folder.id}`}
className={`relative group border rounded-lg p-3 hover:bg-muted/50 transition-colors cursor-pointer ${
isSelected ? "ring-2 ring-primary bg-muted/50" : ""
}`}
onClick={() => onNavigateToFolder?.(folder.id)}
>
<div className="absolute top-2 left-2 z-10 checkbox-wrapper">
<Checkbox
checked={isSelected}
onCheckedChange={(checked: boolean) => {
const newSelected = new Set(selectedFolders);
if (checked) {
newSelected.add(folder.id);
} else {
newSelected.delete(folder.id);
}
setSelectedFolders(newSelected);
}}
aria-label={`Select folder ${folder.name}`}
className="bg-background border-2"
onClick={(e) => e.stopPropagation()}
/>
</div>
<div className="absolute top-2 right-2 z-10">
{isShareMode ? (
onDownloadFolder && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 hover:bg-background/80"
onClick={(e) => {
e.stopPropagation();
onDownloadFolder(folder.id, folder.name);
}}
>
<IconDownload className="h-4 w-4" />
<span className="sr-only">{t("filesTable.actions.download")}</span>
</Button>
)
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={(e) => e.stopPropagation()}>
<IconDotsVertical className="h-4 w-4" />
<span className="sr-only">{t("filesTable.actions.menu")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
{onRenameFolder && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onRenameFolder(folder);
}}
>
<IconEdit className="h-4 w-4" />
{t("filesTable.actions.edit")}
</DropdownMenuItem>
)}
{onMoveFolder && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onMoveFolder(folder);
}}
>
<IconArrowsMove className="h-4 w-4" />
{t("common.move")}
</DropdownMenuItem>
)}
{onShareFolder && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onShareFolder(folder);
}}
>
<IconShare className="h-4 w-4" />
{t("filesTable.actions.share")}
</DropdownMenuItem>
)}
{onDownloadFolder && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onDownloadFolder(folder.id, folder.name);
}}
>
<IconDownload className="h-4 w-4" />
{t("filesTable.actions.download")}
</DropdownMenuItem>
)}
{onDeleteFolder && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDeleteFolder(folder);
}}
className="cursor-pointer py-2 text-destructive focus:text-destructive"
>
<IconTrash className="h-4 w-4" />
{t("filesTable.actions.delete")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="flex flex-col items-center space-y-3">
<div className="w-16 h-16 flex items-center justify-center bg-muted/30 rounded-lg overflow-hidden">
<IconFolder className="h-10 w-10 text-primary" />
</div>
<div className="w-full space-y-1">
<p className="text-sm font-medium truncate text-left" title={folder.name}>
{folder.name}
</p>
{folder.description && (
<p className="text-xs text-muted-foreground truncate text-left" title={folder.description}>
{folder.description}
</p>
)}
<div className="text-xs text-muted-foreground space-y-1 text-left">
<p>{folder.totalSize ? formatFileSize(Number(folder.totalSize)) : "—"}</p>
<p>{formatDateTime(folder.createdAt)}</p>
</div>
</div>
</div>
</div>
);
})}
{/* Render files */}
{files.map((file) => {
const { icon: FileIcon, color } = getFileIcon(file.name);
const isSelected = selectedFiles.has(file.id);
@@ -260,11 +497,14 @@ export function FilesGrid({
onClick={(e) => {
if (
(e.target as HTMLElement).closest(".checkbox-wrapper") ||
(e.target as HTMLElement).closest("button") ||
(e.target as HTMLElement).closest('[role="menuitem"]')
) {
return;
}
if (onPreview) {
onPreview(file);
}
}}
>
<div className="absolute top-2 left-2 z-10 checkbox-wrapper">
@@ -279,6 +519,20 @@ export function FilesGrid({
</div>
<div className="absolute top-2 right-2 z-10">
{isShareMode ? (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 hover:bg-background/80"
onClick={(e) => {
e.stopPropagation();
onDownload(file.objectName, file.name);
}}
>
<IconDownload className="h-4 w-4" />
<span className="sr-only">{t("filesTable.actions.download")}</span>
</Button>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={(e) => e.stopPropagation()}>
@@ -291,22 +545,24 @@ export function FilesGrid({
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onPreview(file);
onPreview?.(file);
}}
>
<IconEye className="h-4 w-4" />
{t("filesTable.actions.preview")}
</DropdownMenuItem>
{onRename && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onRename(file);
onRename?.(file);
}}
>
<IconEdit className="h-4 w-4" />
{t("filesTable.actions.edit")}
</DropdownMenuItem>
)}
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
@@ -317,28 +573,45 @@ export function FilesGrid({
<IconDownload className="h-4 w-4" />
{t("filesTable.actions.download")}
</DropdownMenuItem>
{onShare && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onShare(file);
onShare?.(file);
}}
>
<IconShare className="h-4 w-4" />
{t("filesTable.actions.share")}
</DropdownMenuItem>
)}
{onMoveFile && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onMoveFile?.(file);
}}
>
<IconArrowsMove className="h-4 w-4" />
{t("common.move")}
</DropdownMenuItem>
)}
{onDelete && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete(file);
onDelete?.(file);
}}
className="cursor-pointer py-2 text-destructive focus:text-destructive"
>
<IconTrash className="h-4 w-4" />
{t("filesTable.actions.delete")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="flex flex-col items-center space-y-3">

View File

@@ -1,11 +1,13 @@
import { useEffect, useRef, useState } from "react";
import {
IconArrowsMove,
IconCheck,
IconChevronDown,
IconDotsVertical,
IconDownload,
IconEdit,
IconEye,
IconFolder,
IconShare,
IconTrash,
IconX,
@@ -29,29 +31,62 @@ interface File {
id: string;
name: string;
description?: string;
extension: string;
size: number;
objectName: string;
userId: string;
folderId?: string;
createdAt: string;
updatedAt: string;
}
interface Folder {
id: string;
name: string;
description?: string;
objectName: string;
parentId?: string;
userId: string;
createdAt: string;
updatedAt: string;
totalSize?: string;
_count?: {
files: number;
children: number;
};
}
interface FilesTableProps {
files: File[];
onPreview: (file: File) => void;
onRename: (file: File) => void;
onUpdateName: (fileId: string, newName: string) => void;
onUpdateDescription: (fileId: string, newDescription: string) => void;
folders?: Folder[];
onPreview?: (file: File) => void;
onRename?: (file: File) => void;
onUpdateName?: (fileId: string, newName: string) => void;
onUpdateDescription?: (fileId: string, newDescription: string) => void;
onDownload: (objectName: string, fileName: string) => void;
onShare: (file: File) => void;
onDelete: (file: File) => void;
onBulkDelete?: (files: File[]) => void;
onBulkShare?: (files: File[]) => void;
onBulkDownload?: (files: File[]) => void;
onShare?: (file: File) => void;
onDelete?: (file: File) => void;
onBulkDelete?: (files: File[], folders: Folder[]) => void;
onBulkShare?: (files: File[], folders: Folder[]) => void;
onBulkDownload?: (files: File[], folders: Folder[]) => void;
onBulkMove?: (files: File[], folders: Folder[]) => void;
setClearSelectionCallback?: (callback: () => void) => void;
onNavigateToFolder?: (folderId: string) => void;
onRenameFolder?: (folder: Folder) => void;
onDeleteFolder?: (folder: Folder) => void;
onShareFolder?: (folder: Folder) => void;
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
onMoveFolder?: (folder: Folder) => void;
onMoveFile?: (file: File) => void;
onUpdateFolderName?: (folderId: string, newName: string) => void;
onUpdateFolderDescription?: (folderId: string, newDescription: string) => void;
showBulkActions?: boolean;
isShareMode?: boolean;
}
export function FilesTable({
files,
folders = [],
onPreview,
onRename,
onUpdateName,
@@ -62,16 +97,41 @@ export function FilesTable({
onBulkDelete,
onBulkShare,
onBulkDownload,
onBulkMove,
setClearSelectionCallback,
onNavigateToFolder,
onRenameFolder,
onDeleteFolder,
onShareFolder,
onDownloadFolder,
onMoveFolder,
onMoveFile,
onUpdateFolderName,
onUpdateFolderDescription,
showBulkActions = true,
isShareMode = false,
}: FilesTableProps) {
const t = useTranslations();
const [editingField, setEditingField] = useState<{ fileId: string; field: "name" | "description" } | null>(null);
const [editingFolderField, setEditingFolderField] = useState<{
folderId: string;
field: "name" | "description";
} | null>(null);
const [editValue, setEditValue] = useState("");
const [hoveredField, setHoveredField] = useState<{ fileId: string; field: "name" | "description" } | null>(null);
const [hoveredFolderField, setHoveredFolderField] = useState<{
folderId: string;
field: "name" | "description";
} | null>(null);
const [pendingChanges, setPendingChanges] = useState<{ [fileId: string]: { name?: string; description?: string } }>(
{}
);
const [pendingFolderChanges, setPendingFolderChanges] = useState<{
[folderId: string]: { name?: string; description?: string };
}>({});
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [selectedFolders, setSelectedFolders] = useState<Set<string>>(new Set());
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@@ -85,12 +145,25 @@ export function FilesTable({
setPendingChanges({});
}, [files]);
useEffect(() => {
setPendingFolderChanges({});
}, [folders]);
const fileIds = files?.map((f) => f.id).join(",");
useEffect(() => {
setSelectedFiles(new Set());
}, [files]);
}, [fileIds]);
const folderIds = folders?.map((f) => f.id).join(",");
useEffect(() => {
setSelectedFolders(new Set());
}, [folderIds]);
useEffect(() => {
const clearSelection = () => setSelectedFiles(new Set());
const clearSelection = () => {
setSelectedFiles(new Set());
setSelectedFolders(new Set());
};
setClearSelectionCallback?.(clearSelection);
}, [setClearSelectionCallback]);
@@ -104,6 +177,38 @@ export function FilesTable({
};
};
const startEditFolder = (folderId: string, field: "name" | "description", currentValue: string) => {
setEditingFolderField({ folderId, field });
setEditValue(currentValue || "");
};
const saveEditFolder = () => {
if (!editingFolderField) return;
const { folderId, field } = editingFolderField;
setPendingFolderChanges((prev) => ({
...prev,
[folderId]: { ...prev[folderId], [field]: editValue },
}));
if (field === "name") {
onUpdateFolderName?.(folderId, editValue);
} else {
onUpdateFolderDescription?.(folderId, editValue);
}
setEditingFolderField(null);
setEditValue("");
setHoveredFolderField(null);
};
const cancelEditFolder = () => {
setEditingFolderField(null);
setEditValue("");
setHoveredFolderField(null);
};
const startEdit = (fileId: string, field: "name" | "description", currentValue: string) => {
setEditingField({ fileId, field });
if (field === "name") {
@@ -129,7 +234,7 @@ export function FilesTable({
[fileId]: { ...prev[fileId], name: newFullName },
}));
onUpdateName(fileId, newFullName);
onUpdateName?.(fileId, newFullName);
}
} else {
setPendingChanges((prev) => ({
@@ -137,24 +242,34 @@ export function FilesTable({
[fileId]: { ...prev[fileId], description: editValue },
}));
onUpdateDescription(fileId, editValue);
onUpdateDescription?.(fileId, editValue);
}
setEditingField(null);
setEditValue("");
setHoveredField(null);
};
const cancelEdit = () => {
setEditingField(null);
setEditValue("");
setHoveredField(null);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
if (editingFolderField) {
saveEditFolder();
} else {
saveEdit();
}
} else if (e.key === "Escape") {
if (editingFolderField) {
cancelEditFolder();
} else {
cancelEdit();
}
}
};
const formatDateTime = (dateString: string) => {
@@ -178,11 +293,21 @@ export function FilesTable({
return field === "name" ? file.name : file.description;
};
const getDisplayFolderValue = (folder: Folder, field: "name" | "description") => {
const pendingChange = pendingFolderChanges[folder.id];
if (pendingChange && pendingChange[field] !== undefined) {
return pendingChange[field];
}
return field === "name" ? folder.name : folder.description;
};
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedFiles(new Set(files.map((file) => file.id)));
setSelectedFolders(new Set(folders.map((folder) => folder.id)));
} else {
setSelectedFiles(new Set());
setSelectedFolders(new Set());
}
};
@@ -196,48 +321,81 @@ export function FilesTable({
setSelectedFiles(newSelected);
};
const handleSelectFolder = (folderId: string, checked: boolean) => {
const newSelected = new Set(selectedFolders);
if (checked) {
newSelected.add(folderId);
} else {
newSelected.delete(folderId);
}
setSelectedFolders(newSelected);
};
const getSelectedFiles = () => {
return files.filter((file) => selectedFiles.has(file.id));
};
const isAllSelected = files.length > 0 && selectedFiles.size === files.length;
const getSelectedFolders = () => {
return folders.filter((folder) => selectedFolders.has(folder.id));
};
const handleBulkAction = (action: "delete" | "share" | "download") => {
const totalItems = files.length + folders.length;
const selectedItems = selectedFiles.size + selectedFolders.size;
const isAllSelected = totalItems > 0 && selectedItems === totalItems;
const handleBulkAction = (action: "delete" | "share" | "download" | "move") => {
const selectedFileObjects = getSelectedFiles();
const selectedFolderObjects = getSelectedFolders();
if (selectedFileObjects.length === 0) return;
if (selectedFileObjects.length === 0 && selectedFolderObjects.length === 0) return;
switch (action) {
case "delete":
if (onBulkDelete) {
onBulkDelete(selectedFileObjects);
onBulkDelete(selectedFileObjects, selectedFolderObjects);
}
break;
case "share":
if (onBulkShare) {
onBulkShare(selectedFileObjects);
onBulkShare(selectedFileObjects, selectedFolderObjects);
}
break;
case "download":
if (onBulkDownload) {
onBulkDownload(selectedFileObjects);
onBulkDownload(selectedFileObjects, selectedFolderObjects);
}
break;
case "move":
if (onBulkMove) {
onBulkMove(selectedFileObjects, selectedFolderObjects);
}
break;
}
};
const showBulkActions = selectedFiles.size > 0 && (onBulkDelete || onBulkShare || onBulkDownload);
const shouldShowBulkActions =
showBulkActions &&
(selectedFiles.size > 0 || selectedFolders.size > 0) &&
(isShareMode ? onBulkDownload : onBulkDelete || onBulkShare || onBulkDownload || onBulkMove);
return (
<div className="space-y-4">
{showBulkActions && (
{shouldShowBulkActions && (
<div className="flex items-center justify-between p-4 bg-muted/30 border rounded-lg">
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-foreground">
{t("filesTable.bulkActions.selected", { count: selectedFiles.size })}
{t("filesTable.bulkActions.selected", { count: selectedFiles.size + selectedFolders.size })}
</span>
</div>
<div className="flex items-center gap-2">
{isShareMode ? (
onBulkDownload && (
<Button variant="default" size="sm" className="gap-2" onClick={() => handleBulkAction("download")}>
<IconDownload className="h-4 w-4" />
{t("filesTable.bulkActions.download")}
</Button>
)
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="default" size="sm" className="gap-2">
@@ -246,6 +404,12 @@ export function FilesTable({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
{onBulkMove && (
<DropdownMenuItem className="cursor-pointer py-2" onClick={() => handleBulkAction("move")}>
<IconArrowsMove className="h-4 w-4" />
{t("common.move")}
</DropdownMenuItem>
)}
{onBulkDownload && (
<DropdownMenuItem className="cursor-pointer py-2" onClick={() => handleBulkAction("download")}>
<IconDownload className="h-4 w-4" />
@@ -269,7 +433,15 @@ export function FilesTable({
)}
</DropdownMenuContent>
</DropdownMenu>
<Button variant="outline" size="sm" onClick={() => setSelectedFiles(new Set())}>
)}
<Button
variant="outline"
size="sm"
onClick={() => {
setSelectedFiles(new Set());
setSelectedFolders(new Set());
}}
>
{t("common.cancel")}
</Button>
</div>
@@ -280,13 +452,15 @@ export function FilesTable({
<Table>
<TableHeader>
<TableRow className="border-b-0">
<TableHead className="h-10 w-[50px] text-xs font-bold text-muted-foreground bg-muted/50 px-4 rounded-tl-lg">
{showBulkActions && (
<TableHead className="h-10 text-xs font-bold text-muted-foreground bg-muted/50 px-4 w-12">
<Checkbox
checked={isAllSelected}
onCheckedChange={handleSelectAll}
aria-label={t("filesTable.selectAll")}
/>
</TableHead>
)}
<TableHead className="h-10 text-xs font-bold text-muted-foreground bg-muted/50 px-4">
{t("filesTable.columns.name")}
</TableHead>
@@ -308,6 +482,240 @@ export function FilesTable({
</TableRow>
</TableHeader>
<TableBody>
{folders.map((folder) => {
const isSelected = selectedFolders.has(folder.id);
const isEditingName = editingFolderField?.folderId === folder.id && editingFolderField?.field === "name";
const isEditingDescription =
editingFolderField?.folderId === folder.id && editingFolderField?.field === "description";
const isHoveringName = hoveredFolderField?.folderId === folder.id && hoveredFolderField?.field === "name";
const isHoveringDescriptionField =
hoveredFolderField?.folderId === folder.id && hoveredFolderField?.field === "description";
const displayName = getDisplayFolderValue(folder, "name") || folder.name;
const displayDescription = getDisplayFolderValue(folder, "description");
return (
<TableRow key={folder.id} className="group hover:bg-muted/50 transition-colors border-0">
{showBulkActions && (
<TableCell className="h-12 px-4 border-0">
<Checkbox
checked={isSelected}
onCheckedChange={(checked: boolean) => handleSelectFolder(folder.id, checked)}
aria-label={`Select folder ${folder.name}`}
/>
</TableCell>
)}
<TableCell className="h-12 px-4 border-0">
<div className="flex items-center gap-2">
<div
className="flex items-center gap-2 cursor-pointer hover:opacity-80 transition-opacity"
onClick={(e) => {
e.stopPropagation();
onNavigateToFolder?.(folder.id);
}}
onMouseEnter={() => setHoveredFolderField({ folderId: folder.id, field: "name" })}
onMouseLeave={() => setHoveredFolderField(null)}
>
<IconFolder className="h-5.5 w-5.5 text-primary" />
<div className="flex items-center gap-1 min-w-0 flex-1">
{isEditingName ? (
<div className="flex items-center gap-1 flex-1">
<Input
ref={inputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
className="h-8 text-sm font-medium"
onClick={(e) => e.stopPropagation()}
/>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-green-600 hover:text-green-700 flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
saveEditFolder();
}}
>
<IconCheck className="h-3 w-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-red-600 hover:text-red-700 flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
cancelEditFolder();
}}
>
<IconX className="h-3 w-3" />
</Button>
</div>
) : (
<div className="flex items-center gap-1 flex-1">
<span
className="font-medium text-sm text-foreground/90 truncate max-w-[150px]"
title={displayName}
>
{displayName}
</span>
<div className="w-6 flex justify-center flex-shrink-0">
{isHoveringName && !isShareMode && (
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-muted-foreground hover:text-foreground hidden sm:block"
onClick={(e) => {
e.stopPropagation();
startEditFolder(folder.id, "name", folder.name);
}}
>
<IconEdit className="h-3 w-3" />
</Button>
)}
</div>
</div>
)}
</div>
</div>
</div>
</TableCell>
<TableCell
className="h-12 px-4"
onMouseEnter={() => setHoveredFolderField({ folderId: folder.id, field: "description" })}
onMouseLeave={() => setHoveredFolderField(null)}
>
<div className="flex items-center gap-1">
{isEditingDescription ? (
<div className="flex items-center gap-1 flex-1">
<Input
ref={inputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
className="h-8 text-sm"
onClick={(e) => e.stopPropagation()}
/>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-green-600 hover:text-green-700 flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
saveEditFolder();
}}
>
<IconCheck className="h-3 w-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-red-600 hover:text-red-700 flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
cancelEditFolder();
}}
>
<IconX className="h-3 w-3" />
</Button>
</div>
) : (
<div className="flex items-center gap-1 flex-1 min-w-0">
<span
className="text-muted-foreground truncate max-w-[150px]"
title={displayDescription || "-"}
>
{displayDescription || "-"}
</span>
<div className="w-6 flex justify-center flex-shrink-0">
{isHoveringDescriptionField && !isShareMode && (
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-muted-foreground hover:text-foreground hidden sm:block"
onClick={(e) => {
e.stopPropagation();
startEditFolder(folder.id, "description", folder.description || "");
}}
>
<IconEdit className="h-3 w-3" />
</Button>
)}
</div>
</div>
)}
</div>
</TableCell>
<TableCell className="h-12 px-4">
{folder.totalSize ? formatFileSize(Number(folder.totalSize)) : "—"}
</TableCell>
<TableCell className="h-12 px-4">{formatDateTime(folder.createdAt)}</TableCell>
<TableCell className="h-12 px-4">{formatDateTime(folder.updatedAt)}</TableCell>
<TableCell className="h-12 px-4 text-right">
{isShareMode ? (
onDownloadFolder && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 hover:bg-muted"
onClick={() => onDownloadFolder(folder.id, folder.name)}
>
<IconDownload className="h-4 w-4" />
<span className="sr-only">{t("filesTable.actions.download")}</span>
</Button>
)
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-muted cursor-pointer">
<IconDotsVertical className="h-4 w-4" />
<span className="sr-only">Folder actions menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
{onRenameFolder && (
<DropdownMenuItem className="cursor-pointer py-2" onClick={() => onRenameFolder(folder)}>
<IconEdit className="h-4 w-4" />
{t("filesTable.actions.edit")}
</DropdownMenuItem>
)}
{onMoveFolder && (
<DropdownMenuItem className="cursor-pointer py-2" onClick={() => onMoveFolder(folder)}>
<IconArrowsMove className="h-4 w-4" />
Move
</DropdownMenuItem>
)}
{onDownloadFolder && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={() => onDownloadFolder(folder.id, folder.name)}
>
<IconDownload className="h-4 w-4" />
{t("filesTable.actions.download")}
</DropdownMenuItem>
)}
{onShareFolder && (
<DropdownMenuItem className="cursor-pointer py-2" onClick={() => onShareFolder(folder)}>
<IconShare className="h-4 w-4" />
{t("filesTable.actions.share")}
</DropdownMenuItem>
)}
{onDeleteFolder && (
<DropdownMenuItem
onClick={() => onDeleteFolder(folder)}
className="cursor-pointer py-2 text-destructive focus:text-destructive"
>
<IconTrash className="h-4 w-4" />
{t("filesTable.actions.delete")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
);
})}
{files.map((file) => {
const { icon: FileIcon, color } = getFileIcon(file.name);
const isEditingName = editingField?.fileId === file.id && editingField?.field === "name";
@@ -320,28 +728,46 @@ export function FilesTable({
const displayDescription = getDisplayValue(file, "description");
return (
<TableRow key={file.id} className="hover:bg-muted/50 transition-colors border-0">
<TableRow
key={file.id}
className="group hover:bg-muted/50 transition-colors border-0 cursor-pointer"
onClick={(e) => {
if (
(e.target as HTMLElement).closest(".checkbox-wrapper") ||
(e.target as HTMLElement).closest("button") ||
(e.target as HTMLElement).closest('[role="menuitem"]')
) {
return;
}
if (onPreview) {
onPreview(file);
}
}}
>
{showBulkActions && (
<TableCell className="h-12 px-4 border-0">
<div className="checkbox-wrapper">
<Checkbox
checked={isSelected}
onCheckedChange={(checked: boolean) => handleSelectFile(file.id, checked)}
aria-label={t("filesTable.selectFile", { fileName: file.name })}
/>
</div>
</TableCell>
)}
<TableCell className="h-12 px-4 border-0">
<div className="flex items-center gap-2">
<FileIcon
className={`h-5.5 w-5.5 ${color} cursor-pointer hover:opacity-80 transition-opacity`}
<div
className="flex items-center gap-2 cursor-pointer hover:opacity-80 transition-opacity"
onClick={(e) => {
e.stopPropagation();
onPreview(file);
onPreview?.(file);
}}
/>
<div
className="flex items-center gap-1 min-w-0 flex-1"
onMouseEnter={() => setHoveredField({ fileId: file.id, field: "name" })}
onMouseLeave={() => setHoveredField(null)}
>
<FileIcon className={`h-5.5 w-5.5 ${color}`} />
<div className="flex items-center gap-1 min-w-0 flex-1">
{isEditingName ? (
<div className="flex items-center gap-1 flex-1">
<Input
@@ -381,7 +807,7 @@ export function FilesTable({
{displayName}
</span>
<div className="w-6 flex justify-center flex-shrink-0">
{isHoveringName && (
{isHoveringName && !isShareMode && (
<Button
size="icon"
variant="ghost"
@@ -399,6 +825,7 @@ export function FilesTable({
)}
</div>
</div>
</div>
</TableCell>
<TableCell className="h-12 px-4">
<div
@@ -449,7 +876,7 @@ export function FilesTable({
{displayDescription || "-"}
</span>
<div className="w-6 flex justify-center flex-shrink-0">
{isHoveringDescription && (
{isHoveringDescription && !isShareMode && (
<Button
size="icon"
variant="ghost"
@@ -471,6 +898,17 @@ export function FilesTable({
<TableCell className="h-12 px-4">{formatDateTime(file.createdAt)}</TableCell>
<TableCell className="h-12 px-4">{formatDateTime(file.updatedAt || file.createdAt)}</TableCell>
<TableCell className="h-12 px-4 text-right">
{isShareMode ? (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 hover:bg-muted"
onClick={() => onDownload(file.objectName, file.name)}
>
<IconDownload className="h-4 w-4" />
<span className="sr-only">{t("filesTable.actions.download")}</span>
</Button>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-muted cursor-pointer">
@@ -479,14 +917,24 @@ export function FilesTable({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
{onPreview && (
<DropdownMenuItem className="cursor-pointer py-2" onClick={() => onPreview(file)}>
<IconEye className="h-4 w-4" />
{t("filesTable.actions.preview")}
</DropdownMenuItem>
)}
{onRename && (
<DropdownMenuItem className="cursor-pointer py-2" onClick={() => onRename(file)}>
<IconEdit className="h-4 w-4" />
{t("filesTable.actions.edit")}
</DropdownMenuItem>
)}
{onMoveFile && (
<DropdownMenuItem className="cursor-pointer py-2" onClick={() => onMoveFile(file)}>
<IconArrowsMove className="h-4 w-4" />
{t("common.move")}
</DropdownMenuItem>
)}
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={() => onDownload(file.objectName, file.name)}
@@ -494,10 +942,13 @@ export function FilesTable({
<IconDownload className="h-4 w-4" />
{t("filesTable.actions.download")}
</DropdownMenuItem>
{onShare && (
<DropdownMenuItem className="cursor-pointer py-2" onClick={() => onShare(file)}>
<IconShare className="h-4 w-4" />
{t("filesTable.actions.share")}
</DropdownMenuItem>
)}
{onDelete && (
<DropdownMenuItem
onClick={() => onDelete(file)}
className="cursor-pointer py-2 text-destructive focus:text-destructive"
@@ -505,8 +956,10 @@ export function FilesTable({
<IconTrash className="h-4 w-4" />
{t("filesTable.actions.delete")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
);

View File

@@ -0,0 +1,513 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { IconChevronDown, IconChevronRight, IconFolder, IconFolderOpen } from "@tabler/icons-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import type { FileItem } from "@/http/endpoints/files/types";
import type { FolderItem } from "@/http/endpoints/folders/types";
import { cn } from "@/lib/utils";
import { getFileIcon } from "@/utils/file-icons";
export interface TreeFile {
id: string;
name: string;
type: "file";
size?: number;
parentId: string | null;
}
export interface TreeFolder {
id: string;
name: string;
type: "folder";
parentId: string | null;
totalSize?: string;
}
export type TreeItem = TreeFile | TreeFolder;
export interface FileTreeProps {
files: FileItem[];
folders: FolderItem[];
selectedItems: string[];
onSelectionChange: (selectedIds: string[]) => void;
showFiles?: boolean;
showFolders?: boolean;
className?: string;
maxHeight?: string;
singleSelection?: boolean;
useRadioButtons?: boolean;
useCheckboxAsRadio?: boolean;
searchQuery?: string;
autoExpandToItem?: string | null;
}
interface TreeNode {
item: TreeItem;
children: TreeNode[];
level: number;
}
interface TreeNodeProps {
node: TreeNode;
isExpanded: boolean;
isSelected: boolean;
isIndeterminate: boolean;
onToggleExpand: (nodeId: string) => void;
onToggleSelect: (nodeId: string) => void;
expandedFolders: Set<string>;
selectedSet: Set<string>;
showFiles: boolean;
showFolders: boolean;
singleSelection?: boolean;
useRadioButtons?: boolean;
useCheckboxAsRadio?: boolean;
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}
function TreeNodeComponent({
node,
isExpanded,
isSelected,
isIndeterminate,
onToggleExpand,
onToggleSelect,
expandedFolders,
selectedSet,
showFiles,
showFolders,
singleSelection = false,
useRadioButtons = false,
useCheckboxAsRadio = false,
}: TreeNodeProps) {
const { item, children, level } = node;
const isFolder = item.type === "folder";
const hasChildren = children.length > 0;
const shouldShow = (isFolder && showFolders) || (!isFolder && showFiles);
if (!shouldShow) return null;
const paddingLeft = level * 20 + 8;
return (
<div>
<div
className={cn(
"flex items-center gap-2 py-1 px-2 hover:bg-muted/50 rounded-sm w-full min-w-0",
isSelected && "bg-muted"
)}
style={{ paddingLeft }}
>
<div className="w-4 h-4 flex items-center justify-center flex-shrink-0">
{isFolder && hasChildren && (
<Button variant="ghost" size="sm" className="h-4 w-4 p-0" onClick={() => onToggleExpand(item.id)}>
{isExpanded ? <IconChevronDown className="h-3 w-3" /> : <IconChevronRight className="h-3 w-3" />}
</Button>
)}
</div>
{useRadioButtons ? (
<input
type="radio"
name="tree-selection"
checked={isSelected}
onChange={() => onToggleSelect(item.id)}
className="flex-shrink-0"
/>
) : (
<Checkbox
checked={isSelected}
ref={(ref) => {
if (ref && ref.querySelector) {
const checkbox = ref.querySelector('input[type="checkbox"]') as HTMLInputElement;
if (checkbox && !useCheckboxAsRadio) {
checkbox.indeterminate = isIndeterminate;
}
}
}}
onCheckedChange={() => onToggleSelect(item.id)}
className="flex-shrink-0"
/>
)}
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden">
{isFolder ? (
isExpanded ? (
<IconFolderOpen className="h-4 w-4 flex-shrink-0 text-primary" />
) : (
<IconFolder className="h-4 w-4 flex-shrink-0 text-primary" />
)
) : (
(() => {
const { icon: FileIcon, color } = getFileIcon(item.name);
return <FileIcon className={`h-4 w-4 flex-shrink-0 ${color}`} />;
})()
)}
<span className="truncate text-sm">{item.name}</span>
{!isFolder && item.size && (
<span className="text-xs text-muted-foreground flex-shrink-0">({formatFileSize(item.size)})</span>
)}
{isFolder && (item as TreeFolder).totalSize && (
<span className="text-xs text-muted-foreground flex-shrink-0">
({formatFileSize(Number((item as TreeFolder).totalSize!))})
</span>
)}
</div>
</div>
{isFolder && isExpanded && hasChildren && (
<div>
{children.map((childNode) => (
<TreeNodeComponent
key={childNode.item.id}
node={childNode}
isExpanded={expandedFolders.has(childNode.item.id)}
isSelected={selectedSet.has(childNode.item.id)}
isIndeterminate={false}
onToggleExpand={onToggleExpand}
onToggleSelect={onToggleSelect}
expandedFolders={expandedFolders}
selectedSet={selectedSet}
showFiles={showFiles}
showFolders={showFolders}
singleSelection={singleSelection}
useRadioButtons={useRadioButtons}
useCheckboxAsRadio={useCheckboxAsRadio}
/>
))}
</div>
)}
</div>
);
}
export function FileTree({
files,
folders,
selectedItems,
onSelectionChange,
showFiles = true,
showFolders = true,
className,
maxHeight = "400px",
singleSelection = false,
useRadioButtons = false,
useCheckboxAsRadio = false,
searchQuery = "",
autoExpandToItem = null,
}: FileTreeProps) {
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const selectedSet = useMemo(() => new Set(selectedItems), [selectedItems]);
const convertToTreeItems = useCallback((): TreeItem[] => {
let treeFolders: TreeItem[] = folders.map((folder) => ({
id: folder.id,
name: folder.name,
type: "folder" as const,
parentId: folder.parentId,
totalSize: folder.totalSize,
}));
let treeFiles: TreeItem[] = files.map((file) => ({
id: file.id,
name: file.name,
type: "file" as const,
size: parseInt(file.size),
parentId: file.folderId,
}));
if (searchQuery.trim()) {
const searchLower = searchQuery.toLowerCase();
const getMatchingItems = (allItems: TreeItem[]): TreeItem[] => {
const matching = new Set<string>();
allItems.forEach((item) => {
if (item.name.toLowerCase().includes(searchLower)) {
matching.add(item.id);
}
});
const addParents = (itemId: string) => {
const item = allItems.find((i) => i.id === itemId);
if (item && item.parentId) {
matching.add(item.parentId);
addParents(item.parentId);
}
};
matching.forEach((itemId) => addParents(itemId));
return allItems.filter((item) => matching.has(item.id));
};
const allItems = [...treeFolders, ...treeFiles];
const filteredItems = getMatchingItems(allItems);
treeFolders = filteredItems.filter((item) => item.type === "folder") as TreeFolder[];
treeFiles = filteredItems.filter((item) => item.type === "file") as TreeFile[];
}
return [...treeFolders, ...treeFiles];
}, [files, folders, searchQuery]);
useEffect(() => {
if (autoExpandToItem) {
const allItems = convertToTreeItems();
const item = allItems.find((i) => i.id === autoExpandToItem);
if (item && item.parentId) {
const pathToRoot: string[] = [];
let currentItem: TreeItem | undefined = item;
while (currentItem && currentItem.parentId) {
pathToRoot.push(currentItem.parentId);
currentItem = allItems.find((i) => i.id === currentItem!.parentId);
}
if (pathToRoot.length > 0) {
setExpandedFolders(new Set(pathToRoot));
}
}
}
}, [autoExpandToItem, convertToTreeItems]);
const tree = useMemo(() => {
const allItems = convertToTreeItems();
const itemMap = new Map<string, TreeItem>();
const childrenMap = new Map<string, TreeItem[]>();
allItems.forEach((item) => {
itemMap.set(item.id, item);
childrenMap.set(item.id, []);
});
allItems.forEach((item) => {
if (item.parentId) {
const parentChildren = childrenMap.get(item.parentId);
if (parentChildren) {
parentChildren.push(item);
}
}
});
function buildTreeNode(item: TreeItem, level: number): TreeNode {
const children = childrenMap.get(item.id) || [];
return {
item,
children: children
.sort((a, b) => {
if (a.type !== b.type) {
return a.type === "folder" ? -1 : 1;
}
return a.name.localeCompare(b.name);
})
.map((child) => buildTreeNode(child, level + 1)),
level,
};
}
const rootItems = allItems.filter((item) => !item.parentId);
return rootItems
.sort((a, b) => {
if (a.type !== b.type) {
return a.type === "folder" ? -1 : 1;
}
return a.name.localeCompare(b.name);
})
.map((item) => buildTreeNode(item, 0));
}, [convertToTreeItems]);
const getDescendants = useCallback(
(folderId: string): string[] => {
const descendants: string[] = [];
const allItems = convertToTreeItems();
function collectDescendants(parentId: string) {
allItems.forEach((item) => {
if (item.parentId === parentId) {
descendants.push(item.id);
if (item.type === "folder") {
collectDescendants(item.id);
}
}
});
}
collectDescendants(folderId);
return descendants;
},
[convertToTreeItems]
);
const getAllAncestors = useCallback(
(itemId: string): string[] => {
const ancestors: string[] = [];
const allItems = convertToTreeItems();
let currentItem = allItems.find((i) => i.id === itemId);
while (currentItem && currentItem.parentId) {
ancestors.push(currentItem.parentId);
currentItem = allItems.find((i) => i.id === currentItem!.parentId);
}
return ancestors;
},
[convertToTreeItems]
);
const handleToggleExpand = useCallback((folderId: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
if (next.has(folderId)) {
next.delete(folderId);
} else {
next.add(folderId);
}
return next;
});
}, []);
const handleToggleSelect = useCallback(
(itemId: string) => {
const allItems = convertToTreeItems();
const item = allItems.find((i) => i.id === itemId);
if (!item) return;
if (singleSelection || useCheckboxAsRadio) {
onSelectionChange([itemId]);
return;
}
const newSelection = new Set(selectedItems);
if (selectedSet.has(itemId)) {
newSelection.delete(itemId);
if (item.type === "folder") {
const descendants = getDescendants(itemId);
descendants.forEach((id) => newSelection.delete(id));
} else {
const ancestors = getAllAncestors(itemId);
const allItems = convertToTreeItems();
ancestors.forEach((ancestorId) => {
const ancestorDescendants = getDescendants(ancestorId);
const selectedDescendants = ancestorDescendants.filter((id) => id !== itemId && newSelection.has(id));
if (selectedDescendants.length === 0) {
const ancestorSiblings = allItems.filter((i) => {
const ancestor = allItems.find((a) => a.id === ancestorId);
return ancestor && i.parentId === ancestor.parentId && i.id !== ancestorId;
});
const selectedSiblings = ancestorSiblings.filter((sibling) => newSelection.has(sibling.id));
if (selectedSiblings.length === 0) {
newSelection.delete(ancestorId);
}
}
});
}
} else {
newSelection.add(itemId);
const ancestors = getAllAncestors(itemId);
ancestors.forEach((ancestorId) => {
newSelection.add(ancestorId);
});
if (item.type === "folder") {
const descendants = getDescendants(itemId);
const allItems = convertToTreeItems();
descendants.forEach((id) => {
const descendantItem = allItems.find((i) => i.id === id);
if (descendantItem) {
if ((descendantItem.type === "folder" && showFolders) || (descendantItem.type === "file" && showFiles)) {
newSelection.add(id);
}
}
});
}
}
onSelectionChange(Array.from(newSelection));
},
[
selectedItems,
selectedSet,
getDescendants,
getAllAncestors,
onSelectionChange,
showFiles,
showFolders,
convertToTreeItems,
singleSelection,
useCheckboxAsRadio,
]
);
const isIndeterminate = useCallback(
(folderId: string): boolean => {
const descendants = getDescendants(folderId);
const allItems = convertToTreeItems();
const visibleDescendants = descendants.filter((id) => {
const item = allItems.find((i) => i.id === id);
if (!item) return false;
return (item.type === "folder" && showFolders) || (item.type === "file" && showFiles);
});
if (visibleDescendants.length === 0) return false;
const selectedDescendants = visibleDescendants.filter((id) => selectedSet.has(id));
return selectedDescendants.length > 0 && selectedDescendants.length < visibleDescendants.length;
},
[getDescendants, selectedSet, showFiles, showFolders, convertToTreeItems]
);
if (tree.length === 0) {
return (
<div className={cn("flex items-center justify-center py-8 text-muted-foreground", className)}>
<p>No items to display</p>
</div>
);
}
return (
<div className={cn("border rounded-md w-full", className)}>
<div className="overflow-auto p-2 w-full" style={{ maxHeight }}>
{tree.map((node) => (
<TreeNodeComponent
key={node.item.id}
node={node}
isExpanded={expandedFolders.has(node.item.id)}
isSelected={selectedSet.has(node.item.id)}
isIndeterminate={node.item.type === "folder" ? isIndeterminate(node.item.id) : false}
onToggleExpand={handleToggleExpand}
onToggleSelect={handleToggleSelect}
expandedFolders={expandedFolders}
selectedSet={selectedSet}
showFiles={showFiles}
showFolders={showFolders}
singleSelection={singleSelection}
useRadioButtons={useRadioButtons}
useCheckboxAsRadio={useCheckboxAsRadio}
/>
))}
</div>
</div>
);
}

View File

@@ -527,7 +527,8 @@ export function SharesTable({
onMouseLeave={() => setHoveredField(null)}
>
<span className="text-sm">
{share.files?.length || 0} {t("sharesTable.filesCount")}
{share.files?.length || 0} {t("sharesTable.filesCount")} {share.folders?.length || 0}{" "}
{t("sharesTable.folderCount")}
</span>
<div className="w-6 flex justify-center flex-shrink-0">
{isHoveringFiles && onManageFiles && (

View File

@@ -3,6 +3,7 @@ import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { deleteFile, getDownloadUrl, updateFile } from "@/http/endpoints";
import { deleteFolder, registerFolder, updateFolder } from "@/http/endpoints/folders";
import { useDownloadQueue } from "./use-download-queue";
import { usePushNotifications } from "./use-push-notifications";
@@ -33,6 +34,28 @@ interface FileToShare {
updatedAt: string;
}
interface FolderToRename {
id: string;
name: string;
description?: string;
}
interface FolderToDelete {
id: string;
name: string;
}
interface FolderToShare {
id: string;
name: string;
description?: string;
objectName: string;
parentId?: string;
userId: string;
createdAt: string;
updatedAt: string;
}
interface BulkFile {
id: string;
name: string;
@@ -41,6 +64,23 @@ interface BulkFile {
objectName: string;
createdAt: string;
updatedAt: string;
relativePath?: string;
}
interface BulkFolder {
id: string;
name: string;
description?: string;
objectName: string;
parentId?: string;
userId: string;
createdAt: string;
updatedAt: string;
totalSize?: string;
_count?: {
files: number;
children: number;
};
}
interface PendingDownload {
@@ -59,8 +99,18 @@ export interface EnhancedFileManagerHook {
filesToDelete: BulkFile[] | null;
filesToShare: BulkFile[] | null;
filesToDownload: BulkFile[] | null;
foldersToDelete: BulkFolder[] | null;
isBulkDownloadModalOpen: boolean;
pendingDownloads: PendingDownload[];
folderToDelete: FolderToDelete | null;
folderToRename: FolderToRename | null;
folderToShare: FolderToShare | null;
isCreateFolderModalOpen: boolean;
foldersToShare: BulkFolder[] | null;
foldersToDownload: BulkFolder[] | null;
setFileToDelete: (file: any) => void;
setFileToRename: (file: any) => void;
setPreviewFile: (file: PreviewFile | null) => void;
@@ -68,16 +118,30 @@ export interface EnhancedFileManagerHook {
setFilesToDelete: (files: BulkFile[] | null) => void;
setFilesToShare: (files: BulkFile[] | null) => void;
setFilesToDownload: (files: BulkFile[] | null) => void;
setFoldersToDelete: (folders: BulkFolder[] | null) => void;
setBulkDownloadModalOpen: (open: boolean) => void;
setFolderToDelete: (folder: FolderToDelete | null) => void;
setFolderToRename: (folder: FolderToRename | null) => void;
setFolderToShare: (folder: FolderToShare | null) => void;
setCreateFolderModalOpen: (open: boolean) => void;
setFoldersToShare: (folders: BulkFolder[] | null) => void;
setFoldersToDownload: (folders: BulkFolder[] | null) => void;
handleDelete: (fileId: string) => Promise<void>;
handleDownload: (objectName: string, fileName: string) => Promise<void>;
handleRename: (fileId: string, newName: string, description?: string) => Promise<void>;
handleBulkDelete: (files: BulkFile[]) => void;
handleBulkShare: (files: BulkFile[]) => void;
handleBulkDownload: (files: BulkFile[]) => void;
handleBulkDelete: (files: BulkFile[], folders?: BulkFolder[]) => void;
handleBulkShare: (files: BulkFile[], folders?: BulkFolder[]) => void;
handleBulkDownload: (files: BulkFile[], folders?: BulkFolder[]) => void;
handleBulkDownloadWithZip: (files: BulkFile[], zipName: string) => Promise<void>;
handleDeleteBulk: () => Promise<void>;
handleShareBulkSuccess: () => void;
handleCreateFolder: (data: { name: string; description?: string }, parentId?: string) => Promise<void>;
handleFolderDelete: (folderId: string) => Promise<void>;
handleFolderRename: (folderId: string, newName: string, description?: string) => Promise<void>;
clearSelection?: () => void;
setClearSelectionCallback?: (callback: () => void) => void;
getDownloadStatus: (objectName: string) => PendingDownload | null;
@@ -97,10 +161,19 @@ export function useEnhancedFileManager(onRefresh: () => Promise<void>, clearSele
const [filesToDelete, setFilesToDelete] = useState<BulkFile[] | null>(null);
const [filesToShare, setFilesToShare] = useState<BulkFile[] | null>(null);
const [filesToDownload, setFilesToDownload] = useState<BulkFile[] | null>(null);
const [foldersToDelete, setFoldersToDelete] = useState<BulkFolder[] | null>(null);
const [folderToDelete, setFolderToDelete] = useState<FolderToDelete | null>(null);
const [folderToRename, setFolderToRename] = useState<FolderToRename | null>(null);
const [folderToShare, setFolderToShare] = useState<FolderToShare | null>(null);
const [isCreateFolderModalOpen, setCreateFolderModalOpen] = useState(false);
const [isBulkDownloadModalOpen, setBulkDownloadModalOpen] = useState(false);
const [pendingDownloads, setPendingDownloads] = useState<PendingDownload[]>([]);
const [clearSelectionCallback, setClearSelectionCallbackState] = useState<(() => void) | null>(null);
const [foldersToShare, setFoldersToShare] = useState<BulkFolder[] | null>(null);
const [foldersToDownload, setFoldersToDownload] = useState<BulkFolder[] | null>(null);
const startActualDownload = async (
downloadId: string,
objectName: string,
@@ -192,48 +265,23 @@ export function useEnhancedFileManager(onRefresh: () => Promise<void>, clearSele
setClearSelectionCallbackState(() => callback);
}, []);
const generateDownloadId = useCallback(() => {
return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
}, []);
const handleDownload = async (objectName: string, fileName: string) => {
const downloadId = generateDownloadId();
try {
const encodedObjectName = encodeURIComponent(objectName);
const response = await getDownloadUrl(encodedObjectName);
const { downloadFileWithQueue } = await import("@/utils/download-queue-utils");
if (response.status === 202) {
const pendingDownload: PendingDownload = {
downloadId,
fileName,
objectName,
startTime: Date.now(),
status: "queued",
};
setPendingDownloads((prev) => [...prev, pendingDownload]);
toast.info(t("downloadQueue.downloadQueued", { fileName }), {
description: t("downloadQueue.queuedDescription"),
duration: 5000,
});
} else {
await startActualDownload(downloadId, objectName, fileName, response.data.url);
await toast.promise(
downloadFileWithQueue(objectName, fileName, {
silent: true,
showToasts: false,
}),
{
loading: t("share.messages.downloadStarted"),
success: t("shareManager.downloadSuccess"),
error: t("share.errors.downloadFailed"),
}
} catch (error: any) {
setPendingDownloads((prev) => prev.filter((d) => d.downloadId !== downloadId));
if (error.response?.status === 503) {
toast.error(t("downloadQueue.queueFull"), {
description: t("downloadQueue.queueFullDescription"),
});
} else {
toast.error(t("files.downloadError"), {
description: error.response?.data?.message || error.message,
});
}
throw error;
);
} catch (error) {
console.error("Download error:", error);
}
};
@@ -287,150 +335,172 @@ export function useEnhancedFileManager(onRefresh: () => Promise<void>, clearSele
}
};
const handleBulkDelete = (files: BulkFile[]) => {
setFilesToDelete(files);
const handleBulkDelete = (files: BulkFile[], folders?: BulkFolder[]) => {
setFilesToDelete(files.length > 0 ? files : null);
setFoldersToDelete(folders && folders.length > 0 ? folders : null);
};
const handleBulkShare = (files: BulkFile[]) => {
const handleBulkShare = (files: BulkFile[], folders?: BulkFolder[]) => {
setFilesToShare(files);
setFoldersToShare(folders || null);
};
const handleShareBulkSuccess = () => {
setFilesToShare(null);
setFoldersToShare(null);
if (clearSelectionCallback) {
clearSelectionCallback();
}
};
const handleBulkDownload = (files: BulkFile[]) => {
const handleBulkDownload = (files: BulkFile[], folders?: BulkFolder[]) => {
setFilesToDownload(files);
setFoldersToDownload(folders || null);
setBulkDownloadModalOpen(true);
if (clearSelectionCallback) {
clearSelectionCallback();
}
};
const downloadFileAsBlobSilent = async (objectName: string, fileName: string): Promise<Blob> => {
const handleSingleFolderDownload = async (folderId: string, folderName: string) => {
try {
const encodedObjectName = encodeURIComponent(objectName);
const response = await getDownloadUrl(encodedObjectName);
const { downloadFolderWithQueue } = await import("@/utils/download-queue-utils");
if (response.status === 202) {
const { downloadFileAsBlobWithQueue } = await import("@/utils/download-queue-utils");
return await downloadFileAsBlobWithQueue(objectName, fileName, false);
} else {
const fetchResponse = await fetch(response.data.url);
if (!fetchResponse.ok) throw new Error(`Failed to download ${fileName}`);
return await fetchResponse.blob();
await toast.promise(
downloadFolderWithQueue(folderId, folderName, {
silent: true,
showToasts: false,
}),
{
loading: t("shareManager.creatingZip"),
success: t("shareManager.zipDownloadSuccess"),
error: t("share.errors.downloadFailed"),
}
} catch (error: any) {
throw error;
);
} catch (error) {
console.error("Error downloading folder:", error);
}
};
const handleBulkDownloadWithZip = async (files: BulkFile[], zipName: string) => {
try {
const folders = foldersToDownload || [];
const { bulkDownloadWithQueue } = await import("@/utils/download-queue-utils");
const allItems = [
...files.map((file) => ({
objectName: file.objectName,
name: file.relativePath || file.name,
isReverseShare: false,
type: "file" as const,
})),
...folders.map((folder) => ({
id: folder.id,
name: folder.name,
type: "folder" as const,
})),
];
if (allItems.length === 0) {
toast.error(t("shareManager.noFilesToDownload"));
return;
}
toast.promise(
(async () => {
const JSZip = (await import("jszip")).default;
const zip = new JSZip();
let bulkDownloadId: string | null = null;
let shouldShowInQueue = false;
if (files.length > 0) {
try {
const testFile = files[0];
const encodedObjectName = encodeURIComponent(testFile.objectName);
const testResponse = await getDownloadUrl(encodedObjectName);
if (testResponse.status === 202) {
shouldShowInQueue = true;
}
} catch (error) {
console.error("Error checking if file is queued:", error);
shouldShowInQueue = true;
}
}
if (shouldShowInQueue) {
bulkDownloadId = generateDownloadId();
const bulkPendingDownload: PendingDownload = {
downloadId: bulkDownloadId,
fileName: zipName.endsWith(".zip") ? zipName : `${zipName}.zip`,
objectName: "bulk-download",
startTime: Date.now(),
status: "pending",
};
setPendingDownloads((prev) => [...prev, bulkPendingDownload]);
setPendingDownloads((prev) =>
prev.map((d) => (d.downloadId === bulkDownloadId ? { ...d, status: "downloading" } : d))
);
}
const downloadPromises = files.map(async (file) => {
try {
const blob = await downloadFileAsBlobSilent(file.objectName, file.name);
zip.file(file.name, blob);
} catch (error) {
console.error(`Error downloading file ${file.name}:`, error);
throw error;
}
});
await Promise.all(downloadPromises);
const zipBlob = await zip.generateAsync({ type: "blob" });
const url = URL.createObjectURL(zipBlob);
const a = document.createElement("a");
a.href = url;
a.download = zipName.endsWith(".zip") ? zipName : `${zipName}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
if (bulkDownloadId && shouldShowInQueue) {
setPendingDownloads((prev) =>
prev.map((d) => (d.downloadId === bulkDownloadId ? { ...d, status: "completed" } : d))
);
setTimeout(() => {
setPendingDownloads((prev) => prev.filter((d) => d.downloadId !== bulkDownloadId));
}, 5000);
}
bulkDownloadWithQueue(allItems, zipName, undefined, false).then(() => {
setBulkDownloadModalOpen(false);
setFilesToDownload(null);
setFoldersToDownload(null);
if (clearSelectionCallback) {
clearSelectionCallback();
}
})(),
}),
{
loading: t("shareManager.creatingZip"),
success: t("shareManager.zipDownloadSuccess"),
error: t("shareManager.zipDownloadError"),
}
);
} catch (error: any) {
console.error("Error creating ZIP:", error);
} catch (error) {
console.error("Error in bulk download:", error);
setBulkDownloadModalOpen(false);
setFilesToDownload(null);
setFoldersToDownload(null);
}
};
const handleDeleteBulk = async () => {
if (!filesToDelete) return;
if (!filesToDelete && !foldersToDelete) return;
try {
const deletePromises = filesToDelete.map((file) => deleteFile(file.id));
const deletePromises = [];
if (filesToDelete) {
deletePromises.push(...filesToDelete.map((file) => deleteFile(file.id)));
}
if (foldersToDelete) {
deletePromises.push(...foldersToDelete.map((folder) => deleteFolder(folder.id)));
}
await Promise.all(deletePromises);
toast.success(t("files.bulkDeleteSuccess", { count: filesToDelete.length }));
const totalCount = (filesToDelete?.length || 0) + (foldersToDelete?.length || 0);
toast.success(t("files.bulkDeleteSuccess", { count: totalCount }));
setFilesToDelete(null);
setFoldersToDelete(null);
onRefresh();
} catch (error) {
console.error("Failed to delete items:", error);
toast.error(t("files.bulkDeleteError"));
}
};
const handleCreateFolder = async (data: { name: string; description?: string }, parentId?: string) => {
try {
const folderData = {
name: data.name,
description: data.description,
objectName: `folders/${Date.now()}-${data.name}`,
parentId: parentId || undefined,
};
await registerFolder(folderData);
toast.success(t("folderActions.folderCreated"));
await onRefresh();
setCreateFolderModalOpen(false);
} catch (error) {
console.error("Error creating folder:", error);
toast.error(t("folderActions.createFolderError"));
throw error;
}
};
const handleFolderRename = async (folderId: string, newName: string, description?: string) => {
try {
await updateFolder(folderId, { name: newName, description });
toast.success(t("folderActions.folderRenamed"));
await onRefresh();
setFolderToRename(null);
} catch (error) {
console.error("Error renaming folder:", error);
toast.error(t("folderActions.renameFolderError"));
}
};
const handleFolderDelete = async (folderId: string) => {
try {
await deleteFolder(folderId);
toast.success(t("folderActions.folderDeleted"));
await onRefresh();
setFolderToDelete(null);
if (clearSelectionCallback) {
clearSelectionCallback();
}
} catch (error) {
console.error("Failed to delete files:", error);
toast.error(t("files.bulkDeleteError"));
console.error("Error deleting folder:", error);
toast.error(t("folderActions.deleteFolderError"));
}
};
@@ -449,6 +519,8 @@ export function useEnhancedFileManager(onRefresh: () => Promise<void>, clearSele
setFilesToShare,
filesToDownload,
setFilesToDownload,
foldersToDelete,
setFoldersToDelete,
isBulkDownloadModalOpen,
setBulkDownloadModalOpen,
pendingDownloads,
@@ -461,9 +533,28 @@ export function useEnhancedFileManager(onRefresh: () => Promise<void>, clearSele
handleBulkDownloadWithZip,
handleDeleteBulk,
handleShareBulkSuccess,
folderToDelete,
setFolderToDelete,
folderToRename,
setFolderToRename,
folderToShare,
setFolderToShare,
isCreateFolderModalOpen,
setCreateFolderModalOpen,
handleCreateFolder,
handleFolderRename,
handleFolderDelete,
foldersToShare,
setFoldersToShare,
foldersToDownload,
setFoldersToDownload,
clearSelection,
setClearSelectionCallback,
getDownloadStatus,
handleSingleFolderDownload,
cancelPendingDownload,
isDownloadPending,
};

View File

@@ -4,16 +4,10 @@ import { useCallback, useState } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import {
addFiles,
addRecipients,
createShareAlias,
deleteShare,
notifyRecipients,
updateShare,
} from "@/http/endpoints";
import { addRecipients, createShareAlias, deleteShare, notifyRecipients, updateShare } from "@/http/endpoints";
import { updateFolder } from "@/http/endpoints/folders";
import type { Share } from "@/http/endpoints/shares/types";
import { bulkDownloadWithQueue, downloadFileWithQueue } from "@/utils/download-queue-utils";
import { bulkDownloadShareWithQueue, downloadFileWithQueue } from "@/utils/download-queue-utils";
export interface ShareManagerHook {
shareToDelete: Share | null;
@@ -47,11 +41,12 @@ export interface ShareManagerHook {
handleUpdateDescription: (shareId: string, newDescription: string) => Promise<void>;
handleUpdateSecurity: (share: Share) => Promise<void>;
handleUpdateExpiration: (share: Share) => Promise<void>;
handleManageFiles: (shareId: string, files: any[]) => Promise<void>;
handleManageFiles: () => Promise<void>;
handleManageRecipients: (shareId: string, recipients: any[]) => Promise<void>;
handleGenerateLink: (shareId: string, alias: string) => Promise<void>;
handleNotifyRecipients: (share: Share) => Promise<void>;
setClearSelectionCallback?: (callback: () => void) => void;
handleEditFolder: (folderId: string, newName: string, description?: string) => Promise<void>;
}
export function useShareManager(onSuccess: () => void) {
@@ -147,9 +142,8 @@ export function useShareManager(onSuccess: () => void) {
setShareToManageExpiration(share);
};
const handleManageFiles = async (shareId: string, files: string[]) => {
const handleManageFiles = async () => {
try {
await addFiles(shareId, { files });
toast.success(t("shareManager.filesUpdateSuccess"));
onSuccess();
setShareToManageFiles(null);
@@ -196,38 +190,63 @@ export function useShareManager(onSuccess: () => void) {
const handleBulkDownloadWithZip = async (shares: Share[], zipName: string) => {
try {
const allFiles: Array<{ objectName: string; name: string; shareName: string }> = [];
shares.forEach((share) => {
if (shares.length === 1) {
const share = shares[0];
const allItems: Array<{
objectName?: string;
name: string;
id?: string;
type?: "file" | "folder";
}> = [];
if (share.files) {
share.files.forEach((file) => {
allFiles.push({
if (!file.folderId) {
allItems.push({
objectName: file.objectName,
name: shares.length > 1 ? `${share.name || t("shareManager.defaultShareName")}/${file.name}` : file.name,
shareName: share.name || t("shareManager.defaultShareName"),
});
name: file.name,
type: "file",
});
}
});
}
if (share.folders) {
const folderIds = new Set(share.folders.map((f) => f.id));
share.folders.forEach((folder) => {
if (!folder.parentId || !folderIds.has(folder.parentId)) {
allItems.push({
id: folder.id,
name: folder.name,
type: "folder",
});
}
});
}
if (allItems.length === 0) {
toast.error(t("shareManager.noFilesToDownload"));
return;
}
toast.promise(
bulkDownloadWithQueue(
allFiles.map((file) => ({
objectName: file.objectName,
name: file.name,
isReverseShare: false,
})),
zipName
).then(() => {
bulkDownloadShareWithQueue(allItems, share.files || [], share.folders || [], zipName, undefined, true).then(
() => {
if (clearSelectionCallback) {
clearSelectionCallback();
}
}),
}
),
{
loading: t("shareManager.creatingZip"),
success: t("shareManager.zipDownloadSuccess"),
error: t("shareManager.zipDownloadError"),
}
);
} else {
toast.error("Multiple share download not yet supported - please download shares individually");
}
} catch (error) {
console.error("Error creating ZIP:", error);
}
@@ -243,12 +262,15 @@ export function useShareManager(onSuccess: () => void) {
};
const handleDownloadShareFiles = async (share: Share) => {
if (!share.files || share.files.length === 0) {
const totalFiles = share.files?.length || 0;
const totalFolders = share.folders?.length || 0;
if (totalFiles === 0 && totalFolders === 0) {
toast.error(t("shareManager.noFilesToDownload"));
return;
}
if (share.files.length === 1) {
if (totalFiles === 1 && totalFolders === 0) {
const file = share.files[0];
try {
await downloadFileWithQueue(file.objectName, file.name, {
@@ -257,7 +279,6 @@ export function useShareManager(onSuccess: () => void) {
});
} catch (error) {
console.error("Download error:", error);
// Error already handled in downloadFileWithQueue
}
} else {
const zipName = t("shareManager.singleShareZipName", {
@@ -304,5 +325,14 @@ export function useShareManager(onSuccess: () => void) {
handleDownloadShareFiles,
handleBulkDownloadWithZip,
setClearSelectionCallback,
handleEditFolder: async (folderId: string, newName: string, description?: string) => {
try {
await updateFolder(folderId, { name: newName, description });
toast.success(t("shareManager.updateSuccess"));
onSuccess();
} catch {
toast.error(t("shareManager.updateError"));
}
},
};
}

View File

@@ -9,6 +9,8 @@ import type {
GetPresignedUrlParams,
GetPresignedUrlResult,
ListFilesResult,
MoveFileBody,
MoveFileResult,
RegisterFileBody,
RegisterFileResult,
UpdateFileBody,
@@ -17,9 +19,9 @@ import type {
/**
* Generates a pre-signed URL for direct upload to S3-compatible storage
* @summary Get Presigned URL
* @summary Get Presigned URL for File
*/
export const getPresignedUrl = <TData = GetPresignedUrlResult>(
export const getFilePresignedUrl = <TData = GetPresignedUrlResult>(
params: GetPresignedUrlParams,
options?: AxiosRequestConfig
): Promise<TData> => {
@@ -55,8 +57,19 @@ export const registerFile = <TData = RegisterFileResult>(
* Lists user files
* @summary List Files
*/
export const listFiles = <TData = ListFilesResult>(options?: AxiosRequestConfig): Promise<TData> => {
return apiInstance.get(`/api/files`, options);
export const listFiles = <TData = ListFilesResult>(
params: { folderId?: string; recursive?: boolean } = {},
options?: AxiosRequestConfig
): Promise<TData> => {
const queryParams = {
...params,
recursive: params.recursive !== undefined ? params.recursive.toString() : undefined,
};
return apiInstance.get(`/api/files`, {
...options,
params: { ...queryParams, ...options?.params },
});
};
/**
@@ -89,3 +102,15 @@ export const updateFile = <TData = UpdateFileResult>(
): Promise<TData> => {
return apiInstance.patch(`/api/files/${id}`, updateFileBody, options);
};
/**
* Moves a file to a different folder
* @summary Move File
*/
export const moveFile = <TData = MoveFileResult>(
id: string,
moveFileBody: MoveFileBody,
options?: AxiosRequestConfig
): Promise<TData> => {
return apiInstance.put(`/api/files/${id}/move`, moveFileBody, options);
};

View File

@@ -8,6 +8,7 @@ export interface FileItem {
size: string;
objectName: string;
userId: string;
folderId: string | null;
createdAt: string;
updatedAt: string;
}
@@ -18,6 +19,7 @@ export interface FileOperationRequest {
extension: string;
size: number;
objectName: string;
folderId?: string;
}
export interface FileOperationResponse {
@@ -53,6 +55,10 @@ export interface UpdateFileBody {
description?: string | null;
}
export interface MoveFileBody {
folderId: string | null;
}
export interface GetPresignedUrlParams {
filename: string;
extension: string;
@@ -60,6 +66,7 @@ export interface GetPresignedUrlParams {
export type RegisterFile201 = FileOperationResponse;
export type UpdateFile200 = FileOperationResponse;
export type MoveFile200 = FileOperationResponse;
export type DeleteFile200 = MessageOnlyResponse;
export type CheckFile201 = MessageOnlyResponse;
export type GetPresignedUrl200 = PresignedUrlResponse;
@@ -72,3 +79,4 @@ export type ListFilesResult = AxiosResponse<ListFiles200>;
export type GetDownloadUrlResult = AxiosResponse<GetDownloadUrl200>;
export type DeleteFileResult = AxiosResponse<DeleteFile200>;
export type UpdateFileResult = AxiosResponse<UpdateFile200>;
export type MoveFileResult = AxiosResponse<MoveFile200>;

View File

@@ -0,0 +1,77 @@
import type { AxiosRequestConfig } from "axios";
import apiInstance from "@/config/api";
import type {
CheckFolderBody,
CheckFolderResult,
DeleteFolderResult,
ListFoldersResult,
MoveFolderBody,
MoveFolderResult,
RegisterFolderBody,
RegisterFolderResult,
UpdateFolderBody,
UpdateFolderResult,
} from "./types";
/**
* Checks if the folder meets constraints and validation rules
* @summary Check folder for constraints
*/
export const checkFolder = <TData = CheckFolderResult>(
checkFolderBody: CheckFolderBody,
options?: AxiosRequestConfig
): Promise<TData> => {
return apiInstance.post(`/api/folders/check`, checkFolderBody, options);
};
/**
* Registers folder metadata in the database
* @summary Register Folder Metadata
*/
export const registerFolder = <TData = RegisterFolderResult>(
registerFolderBody: RegisterFolderBody,
options?: AxiosRequestConfig
): Promise<TData> => {
return apiInstance.post(`/api/folders`, registerFolderBody, options);
};
/**
* Lists user folders with optional recursive structure
* @summary List Folders
*/
export const listFolders = <TData = ListFoldersResult>(options?: AxiosRequestConfig): Promise<TData> => {
return apiInstance.get(`/api/folders`, options);
};
/**
* Updates folder metadata
* @summary Update Folder
*/
export const updateFolder = <TData = UpdateFolderResult>(
id: string,
updateFolderBody: UpdateFolderBody,
options?: AxiosRequestConfig
): Promise<TData> => {
return apiInstance.patch(`/api/folders/${id}`, updateFolderBody, options);
};
/**
* Moves folder to different parent
* @summary Move Folder
*/
export const moveFolder = <TData = MoveFolderResult>(
id: string,
moveFolderBody: MoveFolderBody,
options?: AxiosRequestConfig
): Promise<TData> => {
return apiInstance.put(`/api/folders/${id}/move`, moveFolderBody, options);
};
/**
* Deletes a folder
* @summary Delete Folder
*/
export const deleteFolder = <TData = DeleteFolderResult>(id: string, options?: AxiosRequestConfig): Promise<TData> => {
return apiInstance.delete(`/api/folders/${id}`, options);
};

View File

@@ -0,0 +1,61 @@
import type { AxiosResponse } from "axios";
export interface FolderItem {
id: string;
name: string;
description: string | null;
parentId: string | null;
userId: string;
createdAt: string;
updatedAt: string;
totalSize?: string;
_count?: {
files: number;
children: number;
};
}
export interface FolderOperationRequest {
name: string;
description?: string;
objectName: string;
parentId?: string;
}
export interface UpdateFolderBody {
name?: string;
description?: string | null;
}
export interface MoveFolderBody {
parentId: string | null;
}
export interface FolderOperationResponse {
folder: FolderItem;
message: string;
}
export interface MessageOnlyResponse {
message: string;
}
export interface ListFolders200 {
folders: FolderItem[];
}
export type CheckFolderBody = FolderOperationRequest;
export type RegisterFolderBody = FolderOperationRequest;
export type CheckFolder201 = MessageOnlyResponse;
export type RegisterFolder201 = FolderOperationResponse;
export type UpdateFolder200 = FolderOperationResponse;
export type MoveFolder200 = FolderOperationResponse;
export type DeleteFolder200 = MessageOnlyResponse;
export type CheckFolderResult = AxiosResponse<CheckFolder201>;
export type RegisterFolderResult = AxiosResponse<RegisterFolder201>;
export type ListFoldersResult = AxiosResponse<ListFolders200>;
export type UpdateFolderResult = AxiosResponse<UpdateFolder200>;
export type MoveFolderResult = AxiosResponse<MoveFolder200>;
export type DeleteFolderResult = AxiosResponse<DeleteFolder200>;

View File

@@ -1,6 +1,7 @@
export * from "./auth";
export * from "./users";
export * from "./files";
export * from "./folders";
export * from "./shares";
export * from "./reverse-shares";
export * from "./config";

View File

@@ -179,3 +179,43 @@ export const notifyRecipients = <TData = NotifyRecipientsResult>(
): Promise<TData> => {
return apiInstance.post(`/api/shares/recipients/notify/${shareId}`, notifyRecipientsBody, options);
};
/**
* @summary Add folders to share
*/
export const addFolders = <TData = any>(
shareId: string,
addFoldersBody: { folders: string[] },
options?: AxiosRequestConfig
): Promise<TData> => {
return apiInstance.post(`/api/shares/folders/add/${shareId}`, addFoldersBody, options);
};
/**
* @summary Remove folders from share
*/
export const removeFolders = <TData = any>(
shareId: string,
removeFoldersBody: { folders: string[] },
options?: AxiosRequestConfig
): Promise<TData> => {
return apiInstance.delete(`/api/shares/folders/remove/${shareId}`, {
data: removeFoldersBody,
...options,
});
};
/**
* @summary Get folder contents within a share
*/
export const getShareFolderContents = <TData = any>(
shareId: string,
folderId: string,
password?: string,
options?: AxiosRequestConfig
): Promise<TData> => {
return apiInstance.get(`/api/shares/${shareId}/folders/${folderId}/contents`, {
...options,
params: { password, ...options?.params },
});
};

View File

@@ -16,10 +16,25 @@ export interface ShareFile {
size: string;
objectName: string;
userId: string;
folderId: string | null;
createdAt: string;
updatedAt: string;
}
export interface ShareFolder {
id: string;
name: string;
description: string | null;
parentId: string | null;
totalSize: string | null;
createdAt: string;
updatedAt: string;
_count?: {
files: number;
children: number;
};
}
export interface ShareRecipient {
id: string;
email: string;
@@ -43,6 +58,7 @@ export interface Share {
creatorId: string;
security: ShareSecurity;
files: ShareFile[];
folders: ShareFolder[];
recipients: ShareRecipient[];
alias: ShareAlias;
}
@@ -116,7 +132,8 @@ export interface CreateShareBody {
name?: string;
description?: string;
expiration?: string;
files: string[];
files?: string[];
folders?: string[];
password?: string;
maxViews?: number | null;
recipients?: string[];

View File

@@ -217,40 +217,360 @@ export async function downloadFileAsBlobWithQueue(
}
}
function collectFolderFiles(
folderId: string,
allFiles: any[],
allFolders: any[],
folderPath: string = ""
): Array<{ objectName: string; name: string; zipPath: string }> {
const result: Array<{ objectName: string; name: string; zipPath: string }> = [];
const directFiles = allFiles.filter((file: any) => file.folderId === folderId);
for (const file of directFiles) {
result.push({
objectName: file.objectName,
name: file.name,
zipPath: folderPath + file.name,
});
}
const subfolders = allFolders.filter((folder: any) => folder.parentId === folderId);
for (const subfolder of subfolders) {
const subfolderPath = folderPath + subfolder.name + "/";
const subFiles = collectFolderFiles(subfolder.id, allFiles, allFolders, subfolderPath);
result.push(...subFiles);
}
return result;
}
function collectEmptyFolders(folderId: string, allFiles: any[], allFolders: any[], folderPath: string = ""): string[] {
const emptyFolders: string[] = [];
const subfolders = allFolders.filter((folder: any) => folder.parentId === folderId);
for (const subfolder of subfolders) {
const subfolderPath = folderPath + subfolder.name + "/";
const subfolderFiles = collectFolderFiles(subfolder.id, allFiles, allFolders, "");
if (subfolderFiles.length === 0) {
emptyFolders.push(subfolderPath.slice(0, -1));
}
const nestedEmptyFolders = collectEmptyFolders(subfolder.id, allFiles, allFolders, subfolderPath);
emptyFolders.push(...nestedEmptyFolders);
}
return emptyFolders;
}
export async function downloadFolderWithQueue(
folderId: string,
folderName: string,
options: DownloadWithQueueOptions = {}
): Promise<void> {
const { silent = false, showToasts = true } = options;
const downloadId = `folder-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
try {
if (!silent) {
options.onStart?.(downloadId);
}
const { listFiles } = await import("@/http/endpoints/files");
const { listFolders } = await import("@/http/endpoints/folders");
const [allFilesResponse, allFoldersResponse] = await Promise.all([listFiles(), listFolders()]);
const allFiles = allFilesResponse.data.files || [];
const allFolders = allFoldersResponse.data.folders || [];
const folderFiles = collectFolderFiles(folderId, allFiles, allFolders, `${folderName}/`);
const emptyFolders = collectEmptyFolders(folderId, allFiles, allFolders, `${folderName}/`);
if (folderFiles.length === 0 && emptyFolders.length === 0) {
const message = "Folder is empty";
if (showToasts) {
toast.error(message);
}
throw new Error(message);
}
const JSZip = (await import("jszip")).default;
const zip = new JSZip();
for (const emptyFolderPath of emptyFolders) {
zip.folder(emptyFolderPath);
}
for (const file of folderFiles) {
try {
const blob = await downloadFileAsBlobWithQueue(file.objectName, file.name);
zip.file(file.zipPath, blob);
} catch (error) {
console.error(`Error downloading file ${file.name}:`, error);
}
}
const zipBlob = await zip.generateAsync({ type: "blob" });
const url = URL.createObjectURL(zipBlob);
const a = document.createElement("a");
a.href = url;
a.download = `${folderName}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
if (!silent) {
options.onComplete?.(downloadId);
if (showToasts) {
toast.success(`${folderName} downloaded successfully`);
}
}
} catch (error: any) {
if (!silent) {
options.onFail?.(downloadId, error?.message || "Download failed");
if (showToasts) {
toast.error(`Failed to download ${folderName}`);
}
}
throw error;
}
}
export async function downloadShareFolderWithQueue(
folderId: string,
folderName: string,
shareFiles: any[],
shareFolders: any[],
options: DownloadWithQueueOptions = {}
): Promise<void> {
const { silent = false, showToasts = true } = options;
const downloadId = `share-folder-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
try {
if (!silent) {
options.onStart?.(downloadId);
}
const folderFiles = collectFolderFiles(folderId, shareFiles, shareFolders, `${folderName}/`);
const emptyFolders = collectEmptyFolders(folderId, shareFiles, shareFolders, `${folderName}/`);
if (folderFiles.length === 0 && emptyFolders.length === 0) {
const message = "Folder is empty";
if (showToasts) {
toast.error(message);
}
throw new Error(message);
}
const JSZip = (await import("jszip")).default;
const zip = new JSZip();
for (const emptyFolderPath of emptyFolders) {
zip.folder(emptyFolderPath);
}
for (const file of folderFiles) {
try {
const blob = await downloadFileAsBlobWithQueue(file.objectName, file.name);
zip.file(file.zipPath, blob);
} catch (error) {
console.error(`Error downloading file ${file.name}:`, error);
}
}
const zipBlob = await zip.generateAsync({ type: "blob" });
const url = URL.createObjectURL(zipBlob);
const a = document.createElement("a");
a.href = url;
a.download = `${folderName}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
if (!silent) {
options.onComplete?.(downloadId);
if (showToasts) {
toast.success(`${folderName} downloaded successfully`);
}
}
} catch (error: any) {
if (!silent) {
options.onFail?.(downloadId, error?.message || "Download failed");
if (showToasts) {
toast.error(`Failed to download ${folderName}`);
}
}
throw error;
}
}
export async function bulkDownloadWithQueue(
files: Array<{
items: Array<{
objectName?: string;
name: string;
id?: string;
isReverseShare?: boolean;
type?: "file" | "folder";
}>,
zipName: string,
onProgress?: (current: number, total: number) => void
onProgress?: (current: number, total: number) => void,
wrapInFolder?: boolean
): Promise<void> {
try {
const JSZip = (await import("jszip")).default;
const zip = new JSZip();
const downloadPromises = files.map(async (file, index) => {
const files = items.filter((item) => item.type !== "folder");
const folders = items.filter((item) => item.type === "folder");
// eslint-disable-next-line prefer-const
let allFilesToDownload: Array<{ objectName: string; name: string; zipPath: string }> = [];
// eslint-disable-next-line prefer-const
let allEmptyFolders: string[] = [];
if (folders.length > 0) {
const { listFiles } = await import("@/http/endpoints/files");
const { listFolders } = await import("@/http/endpoints/folders");
const [allFilesResponse, allFoldersResponse] = await Promise.all([listFiles(), listFolders()]);
const allFiles = allFilesResponse.data.files || [];
const allFolders = allFoldersResponse.data.folders || [];
const wrapperPath = wrapInFolder ? `${zipName.replace(".zip", "")}/` : "";
for (const folder of folders) {
const folderPath = wrapperPath + `${folder.name}/`;
const folderFiles = collectFolderFiles(folder.id!, allFiles, allFolders, folderPath);
const emptyFolders = collectEmptyFolders(folder.id!, allFiles, allFolders, folderPath);
allFilesToDownload.push(...folderFiles);
allEmptyFolders.push(...emptyFolders);
if (folderFiles.length === 0 && emptyFolders.length === 0) {
allEmptyFolders.push(folderPath.slice(0, -1));
}
}
const filesInFolders = new Set(allFilesToDownload.map((f) => f.objectName));
for (const file of files) {
if (!file.objectName || !filesInFolders.has(file.objectName)) {
allFilesToDownload.push({
objectName: file.objectName || file.name,
name: file.name,
zipPath: wrapperPath + file.name,
});
}
}
} else {
const wrapperPath = wrapInFolder ? `${zipName.replace(".zip", "")}/` : "";
for (const file of files) {
allFilesToDownload.push({
objectName: file.objectName || file.name,
name: file.name,
zipPath: wrapperPath + file.name,
});
}
}
for (const emptyFolderPath of allEmptyFolders) {
zip.folder(emptyFolderPath);
}
for (let i = 0; i < allFilesToDownload.length; i++) {
const file = allFilesToDownload[i];
try {
const blob = await downloadFileAsBlobWithQueue(
file.objectName || file.name,
file.name,
file.isReverseShare,
file.id
);
zip.file(file.name, blob);
onProgress?.(index + 1, files.length);
const blob = await downloadFileAsBlobWithQueue(file.objectName, file.name);
zip.file(file.zipPath, blob);
onProgress?.(i + 1, allFilesToDownload.length);
} catch (error) {
console.error(`Error downloading file ${file.name}:`, error);
throw error;
}
});
await Promise.all(downloadPromises);
}
const zipBlob = await zip.generateAsync({ type: "blob" });
const url = URL.createObjectURL(zipBlob);
const a = document.createElement("a");
a.href = url;
a.download = zipName.endsWith(".zip") ? zipName : `${zipName}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error("Error creating ZIP:", error);
throw error;
}
}
export async function bulkDownloadShareWithQueue(
items: Array<{
objectName?: string;
name: string;
id?: string;
type?: "file" | "folder";
}>,
shareFiles: any[],
shareFolders: any[],
zipName: string,
onProgress?: (current: number, total: number) => void,
wrapInFolder?: boolean
): Promise<void> {
try {
const JSZip = (await import("jszip")).default;
const zip = new JSZip();
const files = items.filter((item) => item.type !== "folder");
const folders = items.filter((item) => item.type === "folder");
// eslint-disable-next-line prefer-const
let allFilesToDownload: Array<{ objectName: string; name: string; zipPath: string }> = [];
// eslint-disable-next-line prefer-const
let allEmptyFolders: string[] = [];
const wrapperPath = wrapInFolder ? `${zipName.replace(".zip", "")}/` : "";
for (const folder of folders) {
const folderPath = wrapperPath + `${folder.name}/`;
const folderFiles = collectFolderFiles(folder.id!, shareFiles, shareFolders, folderPath);
const emptyFolders = collectEmptyFolders(folder.id!, shareFiles, shareFolders, folderPath);
allFilesToDownload.push(...folderFiles);
allEmptyFolders.push(...emptyFolders);
if (folderFiles.length === 0 && emptyFolders.length === 0) {
allEmptyFolders.push(folderPath.slice(0, -1));
}
}
const filesInFolders = new Set(allFilesToDownload.map((f) => f.objectName));
for (const file of files) {
if (!file.objectName || !filesInFolders.has(file.objectName)) {
allFilesToDownload.push({
objectName: file.objectName!,
name: file.name,
zipPath: wrapperPath + file.name,
});
}
}
for (const emptyFolderPath of allEmptyFolders) {
zip.folder(emptyFolderPath);
}
for (let i = 0; i < allFilesToDownload.length; i++) {
const file = allFilesToDownload[i];
try {
const blob = await downloadFileAsBlobWithQueue(file.objectName, file.name);
zip.file(file.zipPath, blob);
onProgress?.(i + 1, allFilesToDownload.length);
} catch (error) {
console.error(`Error downloading file ${file.name}:`, error);
}
}
const zipBlob = await zip.generateAsync({ type: "blob" });
const url = URL.createObjectURL(zipBlob);
const a = document.createElement("a");
a.href = url;