diff --git a/README.md b/README.md index 9daccda..7d5dc7b 100644 --- a/README.md +++ b/README.md @@ -1,142 +1,143 @@ -# ๐ŸŒด Palmr. - Open-Source File Transfer - -

- Palmr Banner -

- -**Palmr.** is a **flexible** and **open-source** alternative to file transfer services like **WeTransfer**, **SendGB**, **Send Anywhere**, and **Files.fm**. - - -๐Ÿ”— **For detailed documentation visit:** [Palmr. - Documentation](https://palmr.kyantech.com.br) - -## ๐Ÿ“Œ Why Choose Palmr.? - -- **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. -- **Simple deployment** โ€“ SQLite database and filesystem storage for easy setup. -- **Scalable storage** โ€“ Optional S3-compatible object storage for enterprise needs. - -## ๐Ÿš€ Technologies Used - -### **Palmr.** is built with a focus on **performance**, **scalability**, and **security**. - -
- -
- - -### **Backend & API** -- **Fastify (Node.js)** โ€“ High-performance API framework with built-in schema validation. -- **SQLite** โ€“ Lightweight, reliable database with zero-configuration setup. -- **Filesystem Storage** โ€“ Direct file storage with optional S3-compatible object storage. - -### **Frontend** -- **NextJS 15 + TypeScript + Shadcn/ui** โ€“ Modern and fast web interface. - - -## ๐Ÿ› ๏ธ How It Works - -1. **Web Interface** โ†’ Built with Next, React and TypeScript for a seamless user experience. -2. **Backend API** โ†’ Fastify handles requests and manages file operations. -3. **Database** โ†’ SQLite stores metadata and transactional data with zero configuration. -4. **Storage** โ†’ Filesystem storage ensures reliable file storage with optional S3-compatible object storage for scalability. - -## ๐Ÿ“ธ Screenshots - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Login Page -
Login Page -
- Home Page -
Home Page -
- Dashboard -
Dashboard -
- Profile Page -
Profile Page -
- Files List View -
Files List View -
- Files Card View -
Files Card View -
- Shares Management -
Shares Management -
- Receive Files -
Receive Files -
- Reverse Share -
Reverse Share -
- Settings Panel -
Settings Panel -
- User Management -
User Management -
- Forgot Password -
Forgot Password -
- Forgot Password -
Reverse Share (WeTransfer Style) -
- - -## ๐Ÿ‘จโ€๐Ÿ’ป Core Maintainers - -| [**Daniel Luiz Alves**](https://github.com/danielalves96) | -|------------------| -| Daniel Luiz Alves | - -
- -## ๐Ÿค Supporters - -[Daniel Luiz Alves](https://www.repoflow.io/) - -## โญ Star History - - - - - - Star History Chart - - - -## ๐Ÿ› ๏ธ Contributing - -For contribution guidelines, please refer to the [CONTRIBUTING.md](CONTRIBUTING.md) file. - +# ๐ŸŒด Palmr. - Open-Source File Transfer + +

+ Palmr Banner +

+ +**Palmr.** is a **flexible** and **open-source** alternative to file transfer services like **WeTransfer**, **SendGB**, **Send Anywhere**, and **Files.fm**. + + +๐Ÿ”— **For detailed documentation visit:** [Palmr. - Documentation](https://palmr.kyantech.com.br) + +## ๐Ÿ“Œ Why Choose Palmr.? + +- **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. + +## ๐Ÿš€ Technologies Used + +### **Palmr.** is built with a focus on **performance**, **scalability**, and **security**. + +
+ +
+ + +### **Backend & API** +- **Fastify (Node.js)** โ€“ High-performance API framework with built-in schema validation. +- **SQLite** โ€“ Lightweight, reliable database with zero-configuration setup. +- **Filesystem Storage** โ€“ Direct file storage with optional S3-compatible object storage. + +### **Frontend** +- **NextJS 15 + TypeScript + Shadcn/ui** โ€“ Modern and fast web interface. + + +## ๐Ÿ› ๏ธ How It Works + +1. **Web Interface** โ†’ Built with Next, React and TypeScript for a seamless user experience. +2. **Backend API** โ†’ Fastify handles requests and manages file operations. +3. **Database** โ†’ SQLite stores metadata and transactional data with zero configuration. +4. **Storage** โ†’ Filesystem storage ensures reliable file storage with optional S3-compatible object storage for scalability. + +## ๐Ÿ“ธ Screenshots + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Login Page +
Login Page +
+ Home Page +
Home Page +
+ Dashboard +
Dashboard +
+ Profile Page +
Profile Page +
+ Files List View +
Files List View +
+ Files Card View +
Files Card View +
+ Shares Management +
Shares Management +
+ Receive Files +
Receive Files +
+ Reverse Share +
Reverse Share +
+ Settings Panel +
Settings Panel +
+ User Management +
User Management +
+ Forgot Password +
Forgot Password +
+ Forgot Password +
Reverse Share (WeTransfer Style) +
+ + +## ๐Ÿ‘จโ€๐Ÿ’ป Core Maintainers + +| [**Daniel Luiz Alves**](https://github.com/danielalves96) | +|------------------| +| Daniel Luiz Alves | + +
+ +## ๐Ÿค Supporters + +[Daniel Luiz Alves](https://www.repoflow.io/) + +## โญ Star History + + + + + + Star History Chart + + + +## ๐Ÿ› ๏ธ Contributing + +For contribution guidelines, please refer to the [CONTRIBUTING.md](CONTRIBUTING.md) file. + diff --git a/apps/docs/content/docs/3.2-beta/api.mdx b/apps/docs/content/docs/3.2-beta/api.mdx index 78710fa..ab1f6f3 100644 --- a/apps/docs/content/docs/3.2-beta/api.mdx +++ b/apps/docs/content/docs/3.2-beta/api.mdx @@ -1,230 +1,236 @@ ---- -title: API Endpoints -icon: Plug ---- - -Palmr. provides a comprehensive, well-documented, and fully typed REST API designed for maximum developer productivity and seamless integration. Whether you're building custom applications, automating workflows, or integrating with third-party services, our API offers the flexibility and reliability you need. - -> **Overview:** The API is built with Fastify + Zod + TypeScript, ensuring type safety, schema validation, and excellent performance for all operations. - -## Prerequisites - -### Exposing the API port - -To access the API endpoints, you need to expose port **3333** in your Docker configuration. This port is where the Palmr. API server runs. - -**Using Docker Compose:** - -```yaml -services: - palmr: - image: kyantech/palmr:latest - ports: - - "5487:5487" # Web interface - - "3333:3333" # API port (required for API access) - # ... other configuration -``` - -**Using Docker run command:** - -```bash -docker run -d \ - --name palmr \ - -p 5487:5487 \ - -p 3333:3333 \ - -v palmr_data:/app/server \ - --restart unless-stopped \ - kyantech/palmr:latest -``` - -> **Note:** Port 3333 exposure is optional if you only need the web interface. However, it's required for direct API access, custom integrations, and accessing the interactive documentation. - -## Accessing the API documentation - -The API documentation is available through interactive interfaces that allow you to explore, test, and validate all available endpoints directly in your browser. - -### Documentation endpoints - -**Local development:** - -- Scalar documentation: `http://localhost:3333/docs` -- Swagger documentation: `http://localhost:3333/swagger` - -**Production environment:** - -- Scalar documentation: `{your_domain}:3333/docs` -- Swagger documentation: `{your_domain}:3333/swagger` - -> **Important:** We don't provide an online version of the API documentation because endpoints and functionality may vary between different versions of Palmr. Always access the documentation from your specific deployment to ensure accuracy. - -## Interactive documentation with Scalar - -Our primary API documentation is powered by **Scalar**, a modern documentation platform that provides an exceptional developer experience. - -![Palmr API Documentation](/assets/v2/api-docs/scalar.png) - -### Why Scalar? - -- **Interactive testing** - Test endpoints directly in the documentation -- **Real-time validation** - Immediate feedback on request/response formats -- **Modern interface** - Clean, intuitive design optimized for developer productivity -- **Type-safe integration** - Full TypeScript support with automatic type inference -- **Schema visualization** - Clear representation of request and response structures - -### Key features - -- **Comprehensive endpoint coverage** - All API endpoints documented with examples -- **Authentication testing** - Built-in support for JWT token authentication -- **Request builders** - Interactive forms for constructing API requests -- **Response visualization** - Formatted display of API responses with syntax highlighting -- **Code generation** - Generate client code in multiple programming languages - -## Alternative Swagger documentation - -For developers who prefer Swagger or need compatibility with existing tools, we also provide a Swagger-based documentation interface. - -![Palmr API Documentation](/assets/v2/api-docs/swagger.png) - -### When to use Swagger - -- **Legacy tool compatibility** - Integration with existing Swagger-based workflows -- **Team preferences** - When your team is more familiar with Swagger interface -- **OpenAPI specification** - Direct access to OpenAPI spec for code generation -- **Third-party integrations** - Tools that specifically require Swagger format - -Both documentation formats provide identical endpoint coverage and functionality, ensuring you can choose the interface that best fits your development workflow. - -## API capabilities - -The Palmr. API provides comprehensive access to all platform features: - -### File operations - -- **Upload files** - Single and batch file uploads with progress tracking -- **Download files** - Secure file retrieval with access control -- **File management** - Rename, delete, and organize files -- **Metadata access** - Retrieve file information and properties - -### Share management - -- **Create shares** - Generate public links for file sharing -- **Configure access** - Set passwords, expiration dates, and view limits -- **Track usage** - Monitor share views and download statistics -- **Manage recipients** - Add and remove share recipients - -### User operations - -- **Authentication** - Login, logout, and session management -- **Profile management** - Update user information and preferences -- **User administration** - Create and manage user accounts (admin only) - -### System integration - -- **Health checks** - Monitor system status and availability -- **Configuration** - Access and modify system settings -- **Storage operations** - Manage filesystem and S3 storage options - -## Authentication - -The API uses **HTTP-only cookies** for secure authentication. After logging in through the web interface or API, authentication is automatically handled via secure cookies: - -```bash -# Login to establish authenticated session -POST /auth/login -{ - "email": "user@example.com", - "password": "your-password" -} - -# Subsequent requests automatically include authentication cookies -# No Authorization header needed - cookies are sent automatically -GET /api/files -``` - -### How authentication works - -1. **Login** - Use the `/auth/login` endpoint with your credentials -2. **Cookie storage** - The server sets HTTP-only cookies containing JWT tokens -3. **Automatic authentication** - All subsequent API requests include these cookies automatically -4. **Session management** - Cookies handle session persistence and expiration - -### Security features - -- **HTTP-only cookies** - Tokens stored securely in HTTP-only cookies, preventing XSS attacks -- **Secure transmission** - Cookies marked as secure and same-site for enhanced protection -- **Token expiration** - Automatic session timeout for security -- **Role-based access** - Different permissions for users and administrators -- **Request validation** - All inputs validated using Zod schemas - -## Getting started with the API - -### 1. Expose the API port - -Ensure port 3333 is exposed in your Docker configuration to access the API endpoints. - -### 2. Access the documentation - -Visit your Palmr. instance at `:3333/docs` to explore the interactive API documentation. - -### 3. Authenticate - -Use the login endpoint to establish an authenticated session. Authentication cookies will be automatically set and included in subsequent requests. - -### 4. Test endpoints - -Use the interactive documentation to test API endpoints and understand request/response formats. - -### 5. Build your integration - -Implement your custom application using the API endpoints that match your requirements. - -## Integration examples - -The API enables powerful integrations and automation: - -**Workflow automation:** - -- Automatically upload files from CI/CD pipelines -- Create shares for build artifacts and reports -- Integrate with project management tools - -**Custom applications:** - -- Build mobile apps with native file management -- Create specialized interfaces for specific use cases -- Develop backup and sync solutions - -**Business integrations:** - -- Connect with existing document management systems -- Automate file sharing workflows -- Integrate with CRM and ERP systems - -## Best practices - -### Performance optimization - -- **Use appropriate HTTP methods** - GET for retrieval, POST for creation, etc. -- **Implement pagination** - Handle large datasets efficiently -- **Cache responses** - Store frequently accessed data locally -- **Batch operations** - Group multiple operations when possible - -### Error handling - -- **Check status codes** - Handle different HTTP response codes appropriately -- **Parse error messages** - Use detailed error information for debugging -- **Implement retries** - Handle temporary failures gracefully -- **Log API interactions** - Maintain audit trails for troubleshooting - -### Security considerations - -- **Use HTTPS** - Always encrypt API communications to protect authentication cookies -- **Secure cookies** - Authentication handled automatically via HTTP-only cookies -- **Validate inputs** - Sanitize data before sending to API -- **Monitor usage** - Track API calls for unusual activity - -## Useful links - -- [Scalar Official Website](https://scalar.com/) - Learn more about our primary documentation platform -- [Swagger Official Website](https://swagger.io/) - Information about the alternative documentation format -- [JWT.io](https://jwt.io/) - Understanding JSON Web Tokens for authentication +--- +title: API Endpoints +icon: Plug +--- + +Palmr. provides a comprehensive, well-documented, and fully typed REST API designed for maximum developer productivity and seamless integration. Whether you're building custom applications, automating workflows, or integrating with third-party services, our API offers the flexibility and reliability you need. + +> **Overview:** The API is built with Fastify + Zod + TypeScript, ensuring type safety, schema validation, and excellent performance for all operations. + +## Prerequisites + +### Exposing the API port + +To access the API endpoints, you need to expose port **3333** in your Docker configuration. This port is where the Palmr. API server runs. + +**Using Docker Compose:** + +```yaml +services: + palmr: + image: kyantech/palmr:latest + ports: + - "5487:5487" # Web interface + - "3333:3333" # API port (required for API access) + # ... other configuration +``` + +**Using Docker run command:** + +```bash +docker run -d \ + --name palmr \ + -p 5487:5487 \ + -p 3333:3333 \ + -v palmr_data:/app/server \ + --restart unless-stopped \ + kyantech/palmr:latest +``` + +> **Note:** Port 3333 exposure is optional if you only need the web interface. However, it's required for direct API access, custom integrations, and accessing the interactive documentation. + +## Accessing the API documentation + +The API documentation is available through interactive interfaces that allow you to explore, test, and validate all available endpoints directly in your browser. + +### Documentation endpoints + +**Local development:** + +- Scalar documentation: `http://localhost:3333/docs` +- Swagger documentation: `http://localhost:3333/swagger` + +**Production environment:** + +- Scalar documentation: `{your_domain}:3333/docs` +- Swagger documentation: `{your_domain}:3333/swagger` + +> **Important:** We don't provide an online version of the API documentation because endpoints and functionality may vary between different versions of Palmr. Always access the documentation from your specific deployment to ensure accuracy. + +## Interactive documentation with Scalar + +Our primary API documentation is powered by **Scalar**, a modern documentation platform that provides an exceptional developer experience. + +![Palmr API Documentation](/assets/v2/api-docs/scalar.png) + +### Why Scalar? + +- **Interactive testing** - Test endpoints directly in the documentation +- **Real-time validation** - Immediate feedback on request/response formats +- **Modern interface** - Clean, intuitive design optimized for developer productivity +- **Type-safe integration** - Full TypeScript support with automatic type inference +- **Schema visualization** - Clear representation of request and response structures + +### Key features + +- **Comprehensive endpoint coverage** - All API endpoints documented with examples +- **Authentication testing** - Built-in support for JWT token authentication +- **Request builders** - Interactive forms for constructing API requests +- **Response visualization** - Formatted display of API responses with syntax highlighting +- **Code generation** - Generate client code in multiple programming languages + +## Alternative Swagger documentation + +For developers who prefer Swagger or need compatibility with existing tools, we also provide a Swagger-based documentation interface. + +![Palmr API Documentation](/assets/v2/api-docs/swagger.png) + +### When to use Swagger + +- **Legacy tool compatibility** - Integration with existing Swagger-based workflows +- **Team preferences** - When your team is more familiar with Swagger interface +- **OpenAPI specification** - Direct access to OpenAPI spec for code generation +- **Third-party integrations** - Tools that specifically require Swagger format + +Both documentation formats provide identical endpoint coverage and functionality, ensuring you can choose the interface that best fits your development workflow. + +## API capabilities + +The Palmr. API provides comprehensive access to all platform features: + +### File operations + +- **Upload files** - Single and batch file uploads with progress tracking +- **Download files** - Secure file retrieval with access control +- **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 +- **Configure access** - Set passwords, expiration dates, and view limits +- **Track usage** - Monitor share views and download statistics +- **Manage recipients** - Add and remove share recipients + +### User operations + +- **Authentication** - Login, logout, and session management +- **Profile management** - Update user information and preferences +- **User administration** - Create and manage user accounts (admin only) + +### System integration + +- **Health checks** - Monitor system status and availability +- **Configuration** - Access and modify system settings +- **Storage operations** - Manage filesystem and S3 storage options + +## Authentication + +The API uses **HTTP-only cookies** for secure authentication. After logging in through the web interface or API, authentication is automatically handled via secure cookies: + +```bash +# Login to establish authenticated session +POST /auth/login +{ + "email": "user@example.com", + "password": "your-password" +} + +# Subsequent requests automatically include authentication cookies +# No Authorization header needed - cookies are sent automatically +GET /api/files +``` + +### How authentication works + +1. **Login** - Use the `/auth/login` endpoint with your credentials +2. **Cookie storage** - The server sets HTTP-only cookies containing JWT tokens +3. **Automatic authentication** - All subsequent API requests include these cookies automatically +4. **Session management** - Cookies handle session persistence and expiration + +### Security features + +- **HTTP-only cookies** - Tokens stored securely in HTTP-only cookies, preventing XSS attacks +- **Secure transmission** - Cookies marked as secure and same-site for enhanced protection +- **Token expiration** - Automatic session timeout for security +- **Role-based access** - Different permissions for users and administrators +- **Request validation** - All inputs validated using Zod schemas + +## Getting started with the API + +### 1. Expose the API port + +Ensure port 3333 is exposed in your Docker configuration to access the API endpoints. + +### 2. Access the documentation + +Visit your Palmr. instance at `:3333/docs` to explore the interactive API documentation. + +### 3. Authenticate + +Use the login endpoint to establish an authenticated session. Authentication cookies will be automatically set and included in subsequent requests. + +### 4. Test endpoints + +Use the interactive documentation to test API endpoints and understand request/response formats. + +### 5. Build your integration + +Implement your custom application using the API endpoints that match your requirements. + +## Integration examples + +The API enables powerful integrations and automation: + +**Workflow automation:** + +- Automatically upload files from CI/CD pipelines +- Create shares for build artifacts and reports +- Integrate with project management tools + +**Custom applications:** + +- Build mobile apps with native file management +- Create specialized interfaces for specific use cases +- Develop backup and sync solutions + +**Business integrations:** + +- Connect with existing document management systems +- Automate file sharing workflows +- Integrate with CRM and ERP systems + +## Best practices + +### Performance optimization + +- **Use appropriate HTTP methods** - GET for retrieval, POST for creation, etc. +- **Implement pagination** - Handle large datasets efficiently +- **Cache responses** - Store frequently accessed data locally +- **Batch operations** - Group multiple operations when possible + +### Error handling + +- **Check status codes** - Handle different HTTP response codes appropriately +- **Parse error messages** - Use detailed error information for debugging +- **Implement retries** - Handle temporary failures gracefully +- **Log API interactions** - Maintain audit trails for troubleshooting + +### Security considerations + +- **Use HTTPS** - Always encrypt API communications to protect authentication cookies +- **Secure cookies** - Authentication handled automatically via HTTP-only cookies +- **Validate inputs** - Sanitize data before sending to API +- **Monitor usage** - Track API calls for unusual activity + +## Useful links + +- [Scalar Official Website](https://scalar.com/) - Learn more about our primary documentation platform +- [Swagger Official Website](https://swagger.io/) - Information about the alternative documentation format +- [JWT.io](https://jwt.io/) - Understanding JSON Web Tokens for authentication diff --git a/apps/docs/content/docs/3.2-beta/github-architecture.mdx b/apps/docs/content/docs/3.2-beta/github-architecture.mdx index f7929d2..eb6faf6 100644 --- a/apps/docs/content/docs/3.2-beta/github-architecture.mdx +++ b/apps/docs/content/docs/3.2-beta/github-architecture.mdx @@ -1,142 +1,144 @@ ---- -title: GitHub Architecture -icon: Github ---- - -import { File, Files, Folder } from "fumadocs-ui/components/files"; - -This guide provides a comprehensive overview of Palmr.'s GitHub repository structure, explaining how the different components are organized in the codebase. Understanding this architecture will help you navigate the repository, contribute effectively, and understand how the project is structured. - -> **Overview:** Palmr. uses a monorepo architecture with clear separation between frontend, backend, and documentation components. - -## Project structure - - - - - - - - - - - - - - - - -## Core components - -### Frontend application (apps/web) - -**Technology stack:** - -- Next.js 15 (App Router) -- React 18 -- TypeScript -- TailwindCSS -- shadcn/ui components -- next-intl for internationalization - -Palmr.'s frontend is built with **Next.js 15**, using the App Router and Server Components for performance, scalability, and flexibility. The structure is modular and feature-based, keeping things easy to evolve as the product grows. UI logic runs on **React 18**, and **TypeScript** adds type safety that prevents a lot of silent bugs. Styles are handled with **TailwindCSS**, letting us build clean, responsive interfaces quickly. For components, we rely on **shadcn/ui**, a headless component library built with Radix UI and Tailwind, it's fast, accessible, and fully customizable. - -Internationalization is handled by **next-intl**, which integrates perfectly with Next.js routing. It supports locale-aware routes, per-page translation loading, and plural rules, all without any extra client-side bloat. It's flexible, lightweight, and great for apps with multilingual audiences. - -The frontend is organized with: - -- A **components-based architecture** for modular UI -- **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 -- A **reusable modal system** used for file actions, confirmations, and more -- **Dynamic, locale-aware routing** using next-intl - -### Backend service (apps/server) - -**Technology stack:** - -- Fastify -- SQLite -- Filesystem storage (with S3-compatible object storage support) -- Prisma ORM - -The backend is built on **Fastify**, a blazing-fast web framework for Node.js. It's lightweight, modular, and optimized for high performance. Every route is validated using JSON schema, which helps keep the API consistent and secure. Auth flows are built using JWTs stored in HTTP-only cookies, and everything from file uploads to token-based sharing goes through this layer. - -Data is stored in **SQLite**, which handles user info, file metadata, session tokens, and more. For file storage, the system uses **filesystem storage** by default, keeping files organized in the local file system. The architecture also supports **S3-compatible object storage** as an alternative storage option for scalability. We use **Prisma** as our ORM to simplify database access, it gives us type-safe queries and easy-to-read code. - -Key features include: - -- **Authentication/authorization** with JWT + cookie sessions -- **File management logic** including uploads, deletes, and renames -- **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 -- Prisma models that keep the database logic predictable and type-safe - -### Documentation (apps/docs) - -**Technology stack:** - -- Fumadocs -- MDX (Markdown + JSX) -- React-based components - -The docs are built using **Fumadocs**, a modern documentation system built on top of Next.js. It uses **MDX**, so you can mix Markdown with interactive React components, perfect for developer tools and open-source projects. Pages are fast, versionable, and easy to customize. The goal is to keep the documentation as close to the codebase as possible, using shared components where needed and reusing UI patterns directly from the app. - -It supports sidebar navigation, keyboard shortcuts, dark mode, and even interactive demos or UI previews. The file tree you see above, for example, is powered by a real React component from the docs. - -- Built with **Fumadocs**, powered by Next.js -- Supports **MDX** and full React component embedding -- Ideal for technical docs and live code samples -- Styled using the same Tailwind setup from the main app - -### Infrastructure - -Palmr. is fully containerized using **Docker**, which means every service: frontend, backend, database, storage, runs in its own isolated environment. With `docker-compose`, the whole stack spins up locally with a single command. It's also easy to deploy to services like Fly.io, Render, or your own VPS. - -Volumes are used to persist data locally, and containers are networked together so that all services can talk to each other securely. - -- **Docker-first architecture** with all services containerized -- Configurable through `.env` and compose overrides -- Local volumes and named networks -- Easy to deploy and scale on any container-compatible infra - -## Key features - -### 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. - -- Upload/download with instant feedback -- File previews, type validation, and size limits -- Token-based sharing system -- Disk usage tracking by user - -### User system - -Authentication is done through secure JWTs, stored in HTTP-only cookies for safety. Signup and login flows are simple and fast, and user info is kept in sync across the frontend and backend. - -- Cookie-based session management -- Role support and future admin access -- Profile updates and password reset flows -- Logged-in user state handled via custom hooks - -### Storage system - -The system uses **filesystem storage** for all file operations by default. It's simple, fast, and reliable for most use cases. The architecture also supports **S3-compatible object storage** as an alternative for users who need cloud storage or additional scalability. The backend tracks usage, handles cleanup of orphaned files, and ensures that every file on disk has a matching database record. - -- Filesystem-based storage with organized directory structure -- Optional S3-compatible object storage support -- Upload validation and automatic cleanup -- Usage tracking and quotas (per user or global) -- Secure access to stored files with proper permissions - -### Internationalization - -Palmr. supports multiple languages using **next-intl**, which is deeply integrated into the routing system. It loads only the necessary translations per route, supports nested namespaces, and makes it easy to keep things organized even at scale. - -- Per-locale localstorage (`en-US`, `pt-BR`, etc.) -- Translation loading by namespace/page -- Full pluralization and formatting support -- Easy translation management via JSON files +--- +title: GitHub Architecture +icon: Github +--- + +import { File, Files, Folder } from "fumadocs-ui/components/files"; + +This guide provides a comprehensive overview of Palmr.'s GitHub repository structure, explaining how the different components are organized in the codebase. Understanding this architecture will help you navigate the repository, contribute effectively, and understand how the project is structured. + +> **Overview:** Palmr. uses a monorepo architecture with clear separation between frontend, backend, and documentation components. + +## Project structure + + + + + + + + + + + + + + + + +## Core components + +### Frontend application (apps/web) + +**Technology stack:** + +- Next.js 15 (App Router) +- React 18 +- TypeScript +- TailwindCSS +- shadcn/ui components +- next-intl for internationalization + +Palmr.'s frontend is built with **Next.js 15**, using the App Router and Server Components for performance, scalability, and flexibility. The structure is modular and feature-based, keeping things easy to evolve as the product grows. UI logic runs on **React 18**, and **TypeScript** adds type safety that prevents a lot of silent bugs. Styles are handled with **TailwindCSS**, letting us build clean, responsive interfaces quickly. For components, we rely on **shadcn/ui**, a headless component library built with Radix UI and Tailwind, it's fast, accessible, and fully customizable. + +Internationalization is handled by **next-intl**, which integrates perfectly with Next.js routing. It supports locale-aware routes, per-page translation loading, and plural rules, all without any extra client-side bloat. It's flexible, lightweight, and great for apps with multilingual audiences. + +The frontend is organized with: + +- A **components-based architecture** for modular UI +- **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 + +### Backend service (apps/server) + +**Technology stack:** + +- Fastify +- SQLite +- Filesystem storage (with S3-compatible object storage support) +- Prisma ORM + +The backend is built on **Fastify**, a blazing-fast web framework for Node.js. It's lightweight, modular, and optimized for high performance. Every route is validated using JSON schema, which helps keep the API consistent and secure. Auth flows are built using JWTs stored in HTTP-only cookies, and everything from file uploads to token-based sharing goes through this layer. + +Data is stored in **SQLite**, which handles user info, file metadata, session tokens, and more. For file storage, the system uses **filesystem storage** by default, keeping files organized in the local file system. The architecture also supports **S3-compatible object storage** as an alternative storage option for scalability. We use **Prisma** as our ORM to simplify database access, it gives us type-safe queries and easy-to-read code. + +Key features include: + +- **Authentication/authorization** with JWT + cookie sessions +- **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 +- Prisma models that keep the database logic predictable and type-safe + +### Documentation (apps/docs) + +**Technology stack:** + +- Fumadocs +- MDX (Markdown + JSX) +- React-based components + +The docs are built using **Fumadocs**, a modern documentation system built on top of Next.js. It uses **MDX**, so you can mix Markdown with interactive React components, perfect for developer tools and open-source projects. Pages are fast, versionable, and easy to customize. The goal is to keep the documentation as close to the codebase as possible, using shared components where needed and reusing UI patterns directly from the app. + +It supports sidebar navigation, keyboard shortcuts, dark mode, and even interactive demos or UI previews. The file tree you see above, for example, is powered by a real React component from the docs. + +- Built with **Fumadocs**, powered by Next.js +- Supports **MDX** and full React component embedding +- Ideal for technical docs and live code samples +- Styled using the same Tailwind setup from the main app + +### Infrastructure + +Palmr. is fully containerized using **Docker**, which means every service: frontend, backend, database, storage, runs in its own isolated environment. With `docker-compose`, the whole stack spins up locally with a single command. It's also easy to deploy to services like Fly.io, Render, or your own VPS. + +Volumes are used to persist data locally, and containers are networked together so that all services can talk to each other securely. + +- **Docker-first architecture** with all services containerized +- Configurable through `.env` and compose overrides +- Local volumes and named networks +- Easy to deploy and scale on any container-compatible infra + +## Key features + +### File management + +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 + +### User system + +Authentication is done through secure JWTs, stored in HTTP-only cookies for safety. Signup and login flows are simple and fast, and user info is kept in sync across the frontend and backend. + +- Cookie-based session management +- Role support and future admin access +- Profile updates and password reset flows +- Logged-in user state handled via custom hooks + +### Storage system + +The system uses **filesystem storage** for all file operations by default. It's simple, fast, and reliable for most use cases. The architecture also supports **S3-compatible object storage** as an alternative for users who need cloud storage or additional scalability. The backend tracks usage, handles cleanup of orphaned files, and ensures that every file on disk has a matching database record. + +- Filesystem-based storage with organized directory structure +- Optional S3-compatible object storage support +- Upload validation and automatic cleanup +- Usage tracking and quotas (per user or global) +- Secure access to stored files with proper permissions + +### Internationalization + +Palmr. supports multiple languages using **next-intl**, which is deeply integrated into the routing system. It loads only the necessary translations per route, supports nested namespaces, and makes it easy to keep things organized even at scale. + +- Per-locale localstorage (`en-US`, `pt-BR`, etc.) +- Translation loading by namespace/page +- Full pluralization and formatting support +- Easy translation management via JSON files diff --git a/apps/docs/content/docs/3.2-beta/index.mdx b/apps/docs/content/docs/3.2-beta/index.mdx index e3f9565..3b8722f 100644 --- a/apps/docs/content/docs/3.2-beta/index.mdx +++ b/apps/docs/content/docs/3.2-beta/index.mdx @@ -1,50 +1,50 @@ ---- -title: Welcome to Palmr. -icon: TreePalm ---- - -import { Ban, Palette, Shield, Star, Users, Zap } from "lucide-react"; - -![Palmr Banner](/assets/v2.1/general/banner.png) - -**Palmr.** is your go-to **open-source alternative** for file sharing, standing tall against services like **WeTransfer**, **SendGB**, **Send Anywhere**, and **Files.fm**. What sets Palmr. apart? You get to **host it on your own infrastructure** be it a **dedicated server** or **VPS** putting you in the driverโ€™s seat for data security and file control. No more third-party dependencies or worrying about pesky limits and steep fees! - -## Why Palmr. Rocks? - -### No limits, seriously - -Forget about arbitrary caps on file sizes or numbers. With Palmr., the only limit is your **serverโ€™s storage space**. Got the capacity? Then transfer files of any size or quantity without a hitch. No premium plans to unlock, no annoying ads to dodge, and definitely no hidden fees sneaking up on you. Your file sharing freedom is tied to your infrastructure, not artificial restrictions. - -### Open source & totally free - -Palmr. is 100% **open source** and free no licenses, subscriptions, or surprise costs. This transparency means youโ€™ve got full control over how you use it. Hereโ€™s what that looks like: - -- Deploy anywhere **VPS**, **dedicated server**, **Docker**, or any cloud platform you fancy. -- Peek under the hood by reviewing the **codebase** to ensure itโ€™s secure and meets your standards. -- Pitch in with **improvements** or custom features to make Palmr. even better. -- Tweak it for any use case, from personal sharing to business needs or niche applications. - -### Your data, your rules - -Host Palmr. on your own setup and keep **full control over your data**. By managing storage and transfers yourself, you cut out third-party risks. Your files stay in your environment no external services touching or storing them ensuring top-notch **privacy** and **confidentiality**. Set up security measures that fit your needs, and rest easy knowing no one else is in the loop. Palmr. hands you the reins for ultimate peace of mind, perfect for organizations needing strict data control or regulatory compliance. - -### Make it yours - -Palmr. is a canvas for customization, letting you shape every detail to match your **brand** and **user experience**: - -- Slap on your **logo** for consistent branding across the platform. -- Pick a **custom app name** that screams your identity. -- Hook up an **SMTP server** for seamless email notifications about transfers or updates. -- Rewrite **interface text** to vibe with your audience and keep your brandโ€™s voice. - -### Manage users like a pro - -Take charge of your file-sharing world with Palmr.โ€™s robust **user and admin management** system: - -- Set up multiple **admins** to share the load and keep things running smoothly. -- Add as many **users** as you need, from small crews to huge teams. -- Keep tabs on **storage usage** with handy analytics for smarter resource planning. - -### Lightning fast & feather light - -Palmr. is built for speed with a sleek, scalable design that handles big file transfers and busy user loads without breaking a sweat. It keeps transfer speeds high and adapts to growing demands effortlessly. Thanks to smart resource use and polished code, you get a snappy experience that scales with your needs while staying rock-solid and stable. +--- +title: Welcome to Palmr. +icon: TreePalm +--- + +import { Ban, Palette, Shield, Star, Users, Zap } from "lucide-react"; + +![Palmr Banner](/assets/v2.1/general/banner.png) + +**Palmr.** is your go-to **open-source alternative** for file sharing, standing tall against services like **WeTransfer**, **SendGB**, **Send Anywhere**, and **Files.fm**. What sets Palmr. apart? You get to **host it on your own infrastructure** be it a **dedicated server** or **VPS** putting you in the driverโ€™s seat for data security and file control. No more third-party dependencies or worrying about pesky limits and steep fees! + +## Why Palmr. Rocks? + +### No limits, seriously + +Forget about arbitrary caps on file sizes or numbers. With Palmr., the only limit is your **serverโ€™s storage space**. Got the capacity? Then transfer files of any size or quantity without a hitch. No premium plans to unlock, no annoying ads to dodge, and definitely no hidden fees sneaking up on you. Your file sharing freedom is tied to your infrastructure, not artificial restrictions. + +### Open source & totally free + +Palmr. is 100% **open source** and free no licenses, subscriptions, or surprise costs. This transparency means youโ€™ve got full control over how you use it. Hereโ€™s what that looks like: + +- Deploy anywhere **VPS**, **dedicated server**, **Docker**, or any cloud platform you fancy. +- Peek under the hood by reviewing the **codebase** to ensure itโ€™s secure and meets your standards. +- Pitch in with **improvements** or custom features to make Palmr. even better. +- Tweak it for any use case, from personal sharing to business needs or niche applications. + +### Your data, your rules + +Host Palmr. on your own setup and keep **full control over your data**. By managing storage and transfers yourself, you cut out third-party risks. Your files stay in your environment no external services touching or storing them ensuring top-notch **privacy** and **confidentiality**. Set up security measures that fit your needs, and rest easy knowing no one else is in the loop. Palmr. hands you the reins for ultimate peace of mind, perfect for organizations needing strict data control or regulatory compliance. + +### Make it yours + +Palmr. is a canvas for customization, letting you shape every detail to match your **brand** and **user experience**: + +- Slap on your **logo** for consistent branding across the platform. +- Pick a **custom app name** that screams your identity. +- Hook up an **SMTP server** for seamless email notifications about transfers or updates. +- Rewrite **interface text** to vibe with your audience and keep your brandโ€™s voice. + +### Manage users like a pro + +Take charge of your file-sharing world with Palmr.โ€™s robust **user and admin management** system: + +- Set up multiple **admins** to share the load and keep things running smoothly. +- Add as many **users** as you need, from small crews to huge teams. +- Keep tabs on **storage usage** with handy analytics for smarter resource planning. + +### Lightning fast & feather light + +Palmr. is built for speed with a sleek, scalable design that handles big file transfers and busy user loads without breaking a sweat. It keeps transfer speeds high and adapts to growing demands effortlessly. Thanks to smart resource use and polished code, you get a snappy experience that scales with your needs while staying rock-solid and stable. diff --git a/apps/docs/content/docs/3.2-beta/screenshots.mdx b/apps/docs/content/docs/3.2-beta/screenshots.mdx index c97003e..28951b3 100644 --- a/apps/docs/content/docs/3.2-beta/screenshots.mdx +++ b/apps/docs/content/docs/3.2-beta/screenshots.mdx @@ -1,130 +1,130 @@ ---- -title: Screenshots -icon: Image ---- - -import { ZoomableImage } from "@/components/ui/zoomable-image"; - -Here you can find a collection of screenshots showcasing various features and interfaces of the Palmr. web application. These images provide a visual overview of the user experience, highlighting key functionalities such as file sharing, user management, and settings configuration. Explore the screenshots below to get a better understanding of how Palmr works and what to expect from the platform. - -> **Note:** All screenshots shown are taken in dark mode, but Palmr. also offers a light mode theme for users who prefer brighter interfaces. - -## Authentication & Access - -### Home page - -The main landing page where users can access the platform and learn about Palmr.'s features. - - - -### Login page - -Secure authentication interface where users enter their credentials to access their Palmr account. - - - -### Forgot password - -Password recovery interface that allows users to reset their passwords when they can't access their accounts. - - - -## Main Application Interface - -### Dashboard - -The central hub after login, providing an overview of recent activity, quick actions, and system status. - - - -## File Management - -### Files list view - -Comprehensive file browser displaying all uploaded files in a detailed list format with metadata, actions, and sorting options. - - - -### Files card view - -Alternative file browser layout showing files as visual cards, perfect for quick browsing and visual file identification. - - - -### Receive files - -File upload interface where users can drag and drop or select files to upload to their Palmr storage. - - - -## Sharing & Collaboration - -### Shares page - -Management interface for all shared files and folders, showing share status, permissions, and access controls. - - - -## User & System Management - -### User management - -Administrative interface for managing user accounts, permissions, roles, and system access controls. - - - -### Profile settings - -Personal account management where users can update their profile information, preferences, and account settings. - - - -### System settings - -Comprehensive system configuration interface for administrators to manage platform settings, integrations, and system behavior. - - - -## Reverse share page themes - -### WeTransfer theme - -Special sharing interface with WeTransfer-inspired design, providing a familiar experience for file sharing with custom theming. - - - -### Default reverse theme - -Alternative dark theme interface showing Palmr's theming capabilities and customization options for different user preferences. - - +--- +title: Screenshots +icon: Image +--- + +import { ZoomableImage } from "@/components/ui/zoomable-image"; + +Here you can find a collection of screenshots showcasing various features and interfaces of the Palmr. web application. These images provide a visual overview of the user experience, highlighting key functionalities such as file sharing, user management, and settings configuration. Explore the screenshots below to get a better understanding of how Palmr works and what to expect from the platform. + +> **Note:** All screenshots shown are taken in dark mode, but Palmr. also offers a light mode theme for users who prefer brighter interfaces. + +## Authentication & Access + +### Home page + +The main landing page where users can access the platform and learn about Palmr.'s features. + + + +### Login page + +Secure authentication interface where users enter their credentials to access their Palmr account. + + + +### Forgot password + +Password recovery interface that allows users to reset their passwords when they can't access their accounts. + + + +## Main Application Interface + +### Dashboard + +The central hub after login, providing an overview of recent activity, quick actions, and system status. + + + +## File Management + +### Files list view + +Comprehensive file browser displaying all uploaded files in a detailed list format with metadata, actions, sorting options, and folder navigation. + + + +### Files card view + +Alternative file browser layout showing files as visual cards, perfect for quick browsing, visual file identification, and folder navigation. + + + +### Receive files + +File upload interface where users can drag and drop or select files to upload to their Palmr storage. + + + +## Sharing & Collaboration + +### Shares page + +Management interface for all shared files and folders, showing share status, permissions, and access controls for both individual files and folders. + + + +## User & System Management + +### User management + +Administrative interface for managing user accounts, permissions, roles, and system access controls. + + + +### Profile settings + +Personal account management where users can update their profile information, preferences, and account settings. + + + +### System settings + +Comprehensive system configuration interface for administrators to manage platform settings, integrations, and system behavior. + + + +## Reverse share page themes + +### WeTransfer theme + +Special sharing interface with WeTransfer-inspired design, providing a familiar experience for file sharing with custom theming. + + + +### Default reverse theme + +Alternative dark theme interface showing Palmr's theming capabilities and customization options for different user preferences. + + diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma index 0d26370..01eccd0 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -1,287 +1,319 @@ -generator client { - provider = "prisma-client-js" -} - -datasource db { - provider = "sqlite" - url = env("DATABASE_URL") -} - -model User { - id String @id @default(cuid()) - firstName String - lastName String - username String @unique - email String @unique - password String? - image String? - isAdmin Boolean @default(false) - isActive Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - twoFactorEnabled Boolean @default(false) - twoFactorSecret String? - twoFactorBackupCodes String? - twoFactorVerified Boolean @default(false) - - files File[] - shares Share[] - reverseShares ReverseShare[] - - loginAttempts LoginAttempt? - - passwordResets PasswordReset[] - authProviders UserAuthProvider[] - trustedDevices TrustedDevice[] - - @@map("users") -} - -model File { - id String @id @default(cuid()) - name String - description String? - extension String - size BigInt - objectName String - - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - shares Share[] @relation("ShareFiles") - - @@map("files") -} - -model Share { - id String @id @default(cuid()) - name String? - views Int @default(0) - expiration DateTime? - description String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - creatorId String? - creator User? @relation(fields: [creatorId], references: [id], onDelete: SetNull) - - securityId String @unique - security ShareSecurity @relation(fields: [securityId], references: [id]) - - files File[] @relation("ShareFiles") - recipients ShareRecipient[] - - alias ShareAlias? - - @@map("shares") -} - -model ShareSecurity { - id String @id @default(cuid()) - password String? - maxViews Int? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - share Share? - - @@map("share_security") -} - -model ShareRecipient { - id String @id @default(cuid()) - email String - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - shareId String - share Share @relation(fields: [shareId], references: [id], onDelete: Cascade) - - @@map("share_recipients") -} - -model AppConfig { - id String @id @default(cuid()) - key String @unique - value String - type String - group String - isSystem Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@map("app_configs") -} - -model LoginAttempt { - id String @id @default(cuid()) - userId String @unique - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - attempts Int @default(1) - lastAttempt DateTime @default(now()) - - @@map("login_attempts") -} - -model PasswordReset { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - token String @unique - expiresAt DateTime - used Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@map("password_resets") -} - -model ShareAlias { - id String @id @default(cuid()) - alias String @unique - shareId String @unique - share Share @relation(fields: [shareId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@map("share_aliases") -} - -model AuthProvider { - id String @id @default(cuid()) - name String @unique - displayName String - type String - icon String? - enabled Boolean @default(false) - - issuerUrl String? - clientId String? - clientSecret String? - redirectUri String? - scope String? @default("openid profile email") - - authorizationEndpoint String? - tokenEndpoint String? - userInfoEndpoint String? - - metadata String? - - autoRegister Boolean @default(true) - adminEmailDomains String? - - sortOrder Int @default(0) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - userAuthProviders UserAuthProvider[] - - @@map("auth_providers") -} - -model UserAuthProvider { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - providerId String - authProvider AuthProvider @relation(fields: [providerId], references: [id], onDelete: Cascade) - - provider String? - - externalId String - metadata String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@unique([userId, providerId]) - @@unique([providerId, externalId]) - @@map("user_auth_providers") -} - -model ReverseShare { - id String @id @default(cuid()) - name String? - description String? - expiration DateTime? - maxFiles Int? - maxFileSize BigInt? - allowedFileTypes String? - password String? - pageLayout PageLayout @default(DEFAULT) - isActive Boolean @default(true) - nameFieldRequired FieldRequirement @default(OPTIONAL) - emailFieldRequired FieldRequirement @default(OPTIONAL) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - creatorId String - creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade) - - files ReverseShareFile[] - alias ReverseShareAlias? - - @@map("reverse_shares") -} - -model ReverseShareFile { - id String @id @default(cuid()) - name String - description String? - extension String - size BigInt - objectName String - uploaderEmail String? - uploaderName String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - reverseShareId String - reverseShare ReverseShare @relation(fields: [reverseShareId], references: [id], onDelete: Cascade) - - @@map("reverse_share_files") -} - -model ReverseShareAlias { - id String @id @default(cuid()) - alias String @unique - reverseShareId String @unique - reverseShare ReverseShare @relation(fields: [reverseShareId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@map("reverse_share_aliases") -} - -enum FieldRequirement { - HIDDEN - OPTIONAL - REQUIRED -} - -enum PageLayout { - DEFAULT - WETRANSFER -} - -model TrustedDevice { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - deviceHash String @unique - deviceName String? - userAgent String? - ipAddress String? - lastUsedAt DateTime @default(now()) - expiresAt DateTime - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@map("trusted_devices") -} +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +model User { + id String @id @default(cuid()) + firstName String + lastName String + username String @unique + email String @unique + password String? + image String? + isAdmin Boolean @default(false) + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + twoFactorEnabled Boolean @default(false) + twoFactorSecret String? + twoFactorBackupCodes String? + twoFactorVerified Boolean @default(false) + + files File[] + folders Folder[] + shares Share[] + reverseShares ReverseShare[] + + loginAttempts LoginAttempt? + + passwordResets PasswordReset[] + authProviders UserAuthProvider[] + trustedDevices TrustedDevice[] + + @@map("users") +} + +model File { + id String @id @default(cuid()) + name String + description String? + extension String + size BigInt + objectName String + + 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") +} + +model Share { + id String @id @default(cuid()) + name String? + views Int @default(0) + expiration DateTime? + description String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + creatorId String? + creator User? @relation(fields: [creatorId], references: [id], onDelete: SetNull) + + securityId String @unique + security ShareSecurity @relation(fields: [securityId], references: [id]) + + files File[] @relation("ShareFiles") + folders Folder[] @relation("ShareFolders") + recipients ShareRecipient[] + + alias ShareAlias? + + @@map("shares") +} + +model ShareSecurity { + id String @id @default(cuid()) + password String? + maxViews Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + share Share? + + @@map("share_security") +} + +model ShareRecipient { + id String @id @default(cuid()) + email String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + shareId String + share Share @relation(fields: [shareId], references: [id], onDelete: Cascade) + + @@map("share_recipients") +} + +model AppConfig { + id String @id @default(cuid()) + key String @unique + value String + type String + group String + isSystem Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("app_configs") +} + +model LoginAttempt { + id String @id @default(cuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + attempts Int @default(1) + lastAttempt DateTime @default(now()) + + @@map("login_attempts") +} + +model PasswordReset { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + token String @unique + expiresAt DateTime + used Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("password_resets") +} + +model ShareAlias { + id String @id @default(cuid()) + alias String @unique + shareId String @unique + share Share @relation(fields: [shareId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("share_aliases") +} + +model AuthProvider { + id String @id @default(cuid()) + name String @unique + displayName String + type String + icon String? + enabled Boolean @default(false) + + issuerUrl String? + clientId String? + clientSecret String? + redirectUri String? + scope String? @default("openid profile email") + + authorizationEndpoint String? + tokenEndpoint String? + userInfoEndpoint String? + + metadata String? + + autoRegister Boolean @default(true) + adminEmailDomains String? + + sortOrder Int @default(0) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + userAuthProviders UserAuthProvider[] + + @@map("auth_providers") +} + +model UserAuthProvider { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + providerId String + authProvider AuthProvider @relation(fields: [providerId], references: [id], onDelete: Cascade) + + provider String? + + externalId String + metadata String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([userId, providerId]) + @@unique([providerId, externalId]) + @@map("user_auth_providers") +} + +model ReverseShare { + id String @id @default(cuid()) + name String? + description String? + expiration DateTime? + maxFiles Int? + maxFileSize BigInt? + allowedFileTypes String? + password String? + pageLayout PageLayout @default(DEFAULT) + isActive Boolean @default(true) + nameFieldRequired FieldRequirement @default(OPTIONAL) + emailFieldRequired FieldRequirement @default(OPTIONAL) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + creatorId String + creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade) + + files ReverseShareFile[] + alias ReverseShareAlias? + + @@map("reverse_shares") +} + +model ReverseShareFile { + id String @id @default(cuid()) + name String + description String? + extension String + size BigInt + objectName String + uploaderEmail String? + uploaderName String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + reverseShareId String + reverseShare ReverseShare @relation(fields: [reverseShareId], references: [id], onDelete: Cascade) + + @@map("reverse_share_files") +} + +model ReverseShareAlias { + id String @id @default(cuid()) + alias String @unique + reverseShareId String @unique + reverseShare ReverseShare @relation(fields: [reverseShareId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("reverse_share_aliases") +} + +enum FieldRequirement { + HIDDEN + OPTIONAL + REQUIRED +} + +enum PageLayout { + DEFAULT + WETRANSFER +} + +model TrustedDevice { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + deviceHash String @unique + deviceName String? + userAgent String? + ipAddress String? + lastUsedAt DateTime @default(now()) + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@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") +} diff --git a/apps/server/src/config/swagger.config.ts b/apps/server/src/config/swagger.config.ts index 3df2621..cfef60e 100644 --- a/apps/server/src/config/swagger.config.ts +++ b/apps/server/src/config/swagger.config.ts @@ -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" }, diff --git a/apps/server/src/modules/file/controller.ts b/apps/server/src/modules/file/controller.ts index f5d9ce0..29a1d9f 100644 --- a/apps/server/src/modules/file/controller.ts +++ b/apps/server/src/modules/file/controller.ts @@ -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 { + 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; + } } diff --git a/apps/server/src/modules/file/dto.ts b/apps/server/src/modules/file/dto.ts index 29e3434..987178f 100644 --- a/apps/server/src/modules/file/dto.ts +++ b/apps/server/src/modules/file/dto.ts @@ -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; @@ -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; +export type MoveFileInput = z.infer; +export type ListFilesInput = z.infer; diff --git a/apps/server/src/modules/file/routes.ts b/apps/server/src/modules/file/routes.ts index eaf504a..810498e 100644 --- a/apps/server/src/modules/file/routes.ts +++ b/apps/server/src/modules/file/routes.ts @@ -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) - ); } diff --git a/apps/server/src/modules/folder/controller.ts b/apps/server/src/modules/folder/controller.ts new file mode 100644 index 0000000..8ebcea8 --- /dev/null +++ b/apps/server/src/modules/folder/controller.ts @@ -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 { + 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; + } +} diff --git a/apps/server/src/modules/folder/dto.ts b/apps/server/src/modules/folder/dto.ts new file mode 100644 index 0000000..9df8976 --- /dev/null +++ b/apps/server/src/modules/folder/dto.ts @@ -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; +export type UpdateFolderInput = z.infer; +export type MoveFolderInput = z.infer; +export type CheckFolderInput = z.infer; +export type ListFoldersInput = z.infer; +export type FolderResponse = z.infer; diff --git a/apps/server/src/modules/folder/routes.ts b/apps/server/src/modules/folder/routes.ts new file mode 100644 index 0000000..a33d77f --- /dev/null +++ b/apps/server/src/modules/folder/routes.ts @@ -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) + ); +} diff --git a/apps/server/src/modules/folder/service.ts b/apps/server/src/modules/folder/service.ts new file mode 100644 index 0000000..3104033 --- /dev/null +++ b/apps/server/src/modules/folder/service.ts @@ -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 { + 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 { + try { + return await this.storageProvider.getPresignedGetUrl(objectName, expires, folderName); + } catch (err) { + console.error("Erro no presignedGetObject:", err); + throw err; + } + } + + async deleteObject(objectName: string): Promise { + 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 { + 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 { + 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; + } +} diff --git a/apps/server/src/modules/share/controller.ts b/apps/server/src/modules/share/controller.ts index a387e70..c2e072d 100644 --- a/apps/server/src/modules/share/controller.ts +++ b/apps/server/src/modules/share/controller.ts @@ -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") { diff --git a/apps/server/src/modules/share/dto.ts b/apps/server/src/modules/share/dto.ts index 3dae483..4f1bcfe 100644 --- a/apps/server/src/modules/share/dto.ts +++ b/apps/server/src/modules/share/dto.ts @@ -1,19 +1,31 @@ import { z } from "zod"; -export const CreateShareSchema = z.object({ - name: z.string().optional().describe("The share name"), - description: z.string().optional().describe("The share description"), - expiration: z - .string() - .datetime({ - 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"), - 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"), -}); +export const CreateShareSchema = z + .object({ + name: z.string().optional().describe("The share name"), + description: z.string().optional().describe("The share description"), + expiration: z + .string() + .datetime({ + message: "Data de expiraรงรฃo deve estar no formato ISO 8601 (ex: 2025-02-06T13:20:49Z)", + }) + .optional(), + 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")), diff --git a/apps/server/src/modules/share/repository.ts b/apps/server/src/modules/share/repository.ts index b4be41a..849154e 100644 --- a/apps/server/src/modules/share/repository.ts +++ b/apps/server/src/modules/share/repository.ts @@ -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): Promise; updateShareSecurity(id: string, data: Partial): Promise; deleteShare(id: string): Promise; incrementViews(id: string): Promise; addFilesToShare(shareId: string, fileIds: string[]): Promise; removeFilesFromShare(shareId: string, fileIds: string[]): Promise; + addFoldersToShare(shareId: string, folderIds: string[]): Promise; + removeFoldersFromShare(shareId: string, folderIds: string[]): Promise; findFilesByIds(fileIds: string[]): Promise; + findFoldersByIds(folderIds: string[]): Promise; addRecipients(shareId: string, emails: string[]): Promise; removeRecipients(shareId: string, emails: string[]): Promise; - findSharesByUserId(userId: string): Promise; + findSharesByUserId( + userId: string + ): Promise<(Share & { security: ShareSecurity; files: any[]; folders: any[]; recipients: any[]; alias: any })[]>; } export class PrismaShareRepository implements IShareRepository { async createShare( data: Omit & { securityId: string; creatorId: string } ): Promise { - 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 { + await prisma.share.update({ + where: { id: shareId }, + data: { + folders: { + connect: folderIds.map((id) => ({ id })), + }, + }, + }); + } + async removeFilesFromShare(shareId: string, fileIds: string[]): Promise { await prisma.share.update({ where: { id: shareId }, @@ -132,6 +194,17 @@ export class PrismaShareRepository implements IShareRepository { }); } + async removeFoldersFromShare(shareId: string, folderIds: string[]): Promise { + await prisma.share.update({ + where: { id: shareId }, + data: { + folders: { + disconnect: folderIds.map((id) => ({ id })), + }, + }, + }); + } + async findFilesByIds(fileIds: string[]): Promise { return prisma.file.findMany({ where: { @@ -142,6 +215,16 @@ export class PrismaShareRepository implements IShareRepository { }); } + async findFoldersByIds(folderIds: string[]): Promise { + return prisma.folder.findMany({ + where: { + id: { + in: folderIds, + }, + }, + }); + } + async addRecipients(shareId: string, emails: string[]): Promise { 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, }, diff --git a/apps/server/src/modules/share/routes.ts b/apps/server/src/modules/share/routes.ts index 9d1254d..e3243a6 100644 --- a/apps/server/src/modules/share/routes.ts +++ b/apps/server/src/modules/share/routes.ts @@ -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( diff --git a/apps/server/src/modules/share/service.ts b/apps/server/src/modules/share/service.ts index 7ab0de2..11d7c30 100644 --- a/apps/server/src/modules/share/service.ts +++ b/apps/server/src/modules/share/service.ts @@ -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, 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,19 +256,33 @@ export class ShareService { throw new Error("Unauthorized to update this share"); } - const existingFiles = await this.shareRepository.findFilesByIds(fileIds); - const notFoundFiles = fileIds.filter((id) => !existingFiles.some((file) => file.id === id)); + if (fileIds.length > 0) { + const existingFiles = await this.shareRepository.findFilesByIds(fileIds); + const notFoundFiles = fileIds.filter((id) => !existingFiles.some((file) => file.id === id)); - if (notFoundFiles.length > 0) { - throw new Error(`Files not found: ${notFoundFiles.join(", ")}`); + if (notFoundFiles.length > 0) { + throw new Error(`Files not found: ${notFoundFiles.join(", ")}`); + } + + await this.shareRepository.addFilesToShare(shareId, fileIds); + } + + 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); } - await this.shareRepository.addFilesToShare(shareId, fileIds); const updated = await this.shareRepository.findShareById(shareId); - return ShareResponseSchema.parse(this.formatShareResponse(updated)); + return ShareResponseSchema.parse(await this.formatShareResponse(updated)); } - async removeFilesFromShare(shareId: string, userId: string, fileIds: string[]) { + 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"); } - await this.shareRepository.removeFilesFromShare(shareId, fileIds); + 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); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 86121bf..07558f3 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -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); diff --git a/apps/web/messages/ar-SA.json b/apps/web/messages/ar-SA.json index 7a6dc9b..d80adaf 100644 --- a/apps/web/messages/ar-SA.json +++ b/apps/web/messages/ar-SA.json @@ -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": "ุงุณุชุฎุฏุงู… ุงู„ุชุฎุฒูŠู†", diff --git a/apps/web/messages/de-DE.json b/apps/web/messages/de-DE.json index a2bb77b..9d03ba8 100644 --- a/apps/web/messages/de-DE.json +++ b/apps/web/messages/de-DE.json @@ -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", diff --git a/apps/web/messages/en-US.json b/apps/web/messages/en-US.json index 93d5c6f..c8a61ed 100644 --- a/apps/web/messages/en-US.json +++ b/apps/web/messages/en-US.json @@ -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", diff --git a/apps/web/messages/es-ES.json b/apps/web/messages/es-ES.json index 34605bb..b9ef907 100644 --- a/apps/web/messages/es-ES.json +++ b/apps/web/messages/es-ES.json @@ -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", diff --git a/apps/web/messages/fr-FR.json b/apps/web/messages/fr-FR.json index 598e4d6..a887e0d 100644 --- a/apps/web/messages/fr-FR.json +++ b/apps/web/messages/fr-FR.json @@ -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", diff --git a/apps/web/messages/hi-IN.json b/apps/web/messages/hi-IN.json index a291256..8394580 100644 --- a/apps/web/messages/hi-IN.json +++ b/apps/web/messages/hi-IN.json @@ -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": "เคธเฅเคŸเฅ‹เคฐเฅ‡เคœ เค‰เคชเคฏเฅ‹เค—", diff --git a/apps/web/messages/it-IT.json b/apps/web/messages/it-IT.json index 505e56f..f603d2f 100644 --- a/apps/web/messages/it-IT.json +++ b/apps/web/messages/it-IT.json @@ -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", diff --git a/apps/web/messages/ja-JP.json b/apps/web/messages/ja-JP.json index 15f1a24..2652c7f 100644 --- a/apps/web/messages/ja-JP.json +++ b/apps/web/messages/ja-JP.json @@ -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": "ใ‚นใƒˆใƒฌใƒผใ‚ธไฝฟ็”จ้‡", diff --git a/apps/web/messages/ko-KR.json b/apps/web/messages/ko-KR.json index eafa310..067eba9 100644 --- a/apps/web/messages/ko-KR.json +++ b/apps/web/messages/ko-KR.json @@ -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": "์Šคํ† ๋ฆฌ์ง€ ์‚ฌ์šฉ๋Ÿ‰", diff --git a/apps/web/messages/nl-NL.json b/apps/web/messages/nl-NL.json index 32c5eb8..da02b35 100644 --- a/apps/web/messages/nl-NL.json +++ b/apps/web/messages/nl-NL.json @@ -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", diff --git a/apps/web/messages/pl-PL.json b/apps/web/messages/pl-PL.json index 8a51dc1..9af2ade 100644 --- a/apps/web/messages/pl-PL.json +++ b/apps/web/messages/pl-PL.json @@ -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", diff --git a/apps/web/messages/pt-BR.json b/apps/web/messages/pt-BR.json index 45a24c7..8d9c7c5 100644 --- a/apps/web/messages/pt-BR.json +++ b/apps/web/messages/pt-BR.json @@ -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", diff --git a/apps/web/messages/ru-RU.json b/apps/web/messages/ru-RU.json index 623f01b..5942be3 100644 --- a/apps/web/messages/ru-RU.json +++ b/apps/web/messages/ru-RU.json @@ -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": "ะ˜ัะฟะพะปัŒะทะพะฒะฐะฝะธะต ั…ั€ะฐะฝะธะปะธั‰ะฐ", diff --git a/apps/web/messages/tr-TR.json b/apps/web/messages/tr-TR.json index 5d64c08..ae10a41 100644 --- a/apps/web/messages/tr-TR.json +++ b/apps/web/messages/tr-TR.json @@ -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ฤฑ", diff --git a/apps/web/messages/zh-CN.json b/apps/web/messages/zh-CN.json index 35d4bff..d77db16 100644 --- a/apps/web/messages/zh-CN.json +++ b/apps/web/messages/zh-CN.json @@ -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": "ๅญ˜ๅ‚จไฝฟ็”จๆƒ…ๅ†ต", diff --git a/apps/web/src/app/(shares)/s/[alias]/components/files-table.tsx b/apps/web/src/app/(shares)/s/[alias]/components/files-table.tsx index 186071b..ce585f6 100644 --- a/apps/web/src/app/(shares)/s/[alias]/components/files-table.tsx +++ b/apps/web/src/app/(shares)/s/[alias]/components/files-table.tsx @@ -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; + onDownloadFolder?: (folderId: string, folderName: string) => Promise; + 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 (
@@ -57,44 +106,109 @@ export function ShareFilesTable({ files, onDownload }: ShareFilesTableProps) { - {files.map((file) => { - const { icon: FileIcon, color } = getFileIcon(file.name); - - return ( - - -
- - {file.name} -
-
- {formatFileSize(Number(file.size))} - {formatDateTime(file.createdAt)} - -
- - -
-
-
- ); - })} + {allItems.length === 0 ? ( + + +
+ +

+ {enableNavigation ? "No files or folders" : "No files or folders shared"} +

+

{enableNavigation ? "This location is empty" : "This share is empty"}

+
+
+
+ ) : ( + allItems.map((item) => { + if (item.type === "folder") { + return ( + + +
+ + {enableNavigation ? ( + + ) : ( + {item.name} + )} +
+
+ + {item.totalSize ? formatFileSize(Number(item.totalSize)) : "โ€”"} + + {formatDateTime(item.createdAt)} + +
+ {enableNavigation && ( + + )} + +
+
+
+ ); + } else { + const { icon: FileIcon, color } = getFileIcon(item.name); + return ( + + +
+ + {item.name} +
+
+ {formatFileSize(Number(item.size))} + {formatDateTime(item.createdAt)} + +
+ + +
+
+
+ ); + } + }) + )}
@@ -103,3 +217,5 @@ export function ShareFilesTable({ files, onDownload }: ShareFilesTableProps) {
); } + +export const ShareContentTable = ShareFilesTable; diff --git a/apps/web/src/app/(shares)/s/[alias]/components/password-modal.tsx b/apps/web/src/app/(shares)/s/[alias]/components/password-modal.tsx index 12344f8..7a9fe49 100644 --- a/apps/web/src/app/(shares)/s/[alias]/components/password-modal.tsx +++ b/apps/web/src/app/(shares)/s/[alias]/components/password-modal.tsx @@ -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 {}} modal> -

{t("share.password.title")}

+ {t("share.password.title")}

{t("share.password.protected")}

diff --git a/apps/web/src/app/(shares)/s/[alias]/components/share-details.tsx b/apps/web/src/app/(shares)/s/[alias]/components/share-details.tsx index 9467185..dc41b6e 100644 --- a/apps/web/src/app/(shares)/s/[alias]/components/share-details.tsx +++ b/apps/web/src/app/(shares)/s/[alias]/components/share-details.tsx @@ -1,54 +1,193 @@ -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 { + onBulkDownload?: () => Promise; + onSelectedItemsBulkDownload?: (files: File[], folders: Folder[]) => Promise; + 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 ( - - -
-
-
-
- -

{share.name || t("share.details.untitled")}

+ <> + + +
+
+
+
+ +

{share.name || t("share.details.untitled")}

+
+ {shareHasItems && hasMultipleFiles && ( + + )}
- {hasMultipleFiles && ( - - )} -
- {share.description &&

{share.description}

} -
- - {t("share.details.created", { - date: format(new Date(share.createdAt), "MM/dd/yyyy HH:mm"), - })} - - {share.expiration && ( + {share.description &&

{share.description}

} +
- {t("share.details.expires", { - date: format(new Date(share.expiration), "MM/dd/yyyy HH:mm"), + {t("share.details.created", { + date: format(new Date(share.createdAt), "MM/dd/yyyy HH:mm"), })} - )} + {share.expiration && ( + + {t("share.details.expires", { + date: format(new Date(share.expiration), "MM/dd/yyyy HH:mm"), + })} + + )} +
-
- -
- - + ( +
+
+ +
+

{t("fileSelector.noFilesInShare")}

+

{t("files.empty.description")}

+
+ )} + breadcrumbs={ + + + + navigateToFolder()} + > + + {t("folderActions.rootFolder")} + + + + {path.map((folder, index) => ( +
+ + + {index === path.length - 1 ? ( + {folder.name} + ) : ( + navigateToFolder(folder.id)}> + {folder.name} + + )} + +
+ ))} +
+
+ } + onNavigateToFolder={navigateToFolder} + onDownloadFolder={handleFolderDownload} + onPreview={(file) => { + setSelectedFile({ name: file.name, objectName: file.objectName }); + setIsPreviewOpen(true); + }} + /> +
+ + + + {selectedFile && ( + { + setIsPreviewOpen(false); + setSelectedFile(null); + }} + file={selectedFile} + /> + )} + ); } diff --git a/apps/web/src/app/(shares)/s/[alias]/hooks/use-public-share.ts b/apps/web/src/app/(shares)/s/[alias]/hooks/use-public-share.ts index 5f17b01..2528036 100644 --- a/apps/web/src/app/(shares)/s/[alias]/hooks/use-public-share.ts +++ b/apps/web/src/app/(shares)/s/[alias]/hooks/use-public-share.ts @@ -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(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({ + folders: [], + files: [], + path: [], + isLoading: true, + error: null, + }); + const urlFolderSlug = searchParams.get("folder") || null; + const [currentFolderId, setCurrentFolderId] = useState(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`; + // 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, + 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( - share.files.map((file) => ({ - objectName: file.objectName, - name: file.name, - isReverseShare: false, - })), - zipName + 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(); + 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(() => { - loadShare(); - }, [alias, loadShare]); + if (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), }; } diff --git a/apps/web/src/app/(shares)/s/[alias]/page.tsx b/apps/web/src/app/(shares)/s/[alias]/page.tsx index 26ad66c..a591f3b 100644 --- a/apps/web/src/app/(shares)/s/[alias]/page.tsx +++ b/apps/web/src/app/(shares)/s/[alias]/page.tsx @@ -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() {
{!isPasswordModalOpen && !share && } - {share && } + {share && ( + + )}
diff --git a/apps/web/src/app/(shares)/s/[alias]/types/index.tsx b/apps/web/src/app/(shares)/s/[alias]/types/index.tsx index 0817907..a929597 100644 --- a/apps/web/src/app/(shares)/s/[alias]/types/index.tsx +++ b/apps/web/src/app/(shares)/s/[alias]/types/index.tsx @@ -8,11 +8,24 @@ export interface ShareFile { createdAt: string; } -export interface ShareFilesTableProps { - files: ShareFile[]; - onDownload: (objectName: string, fileName: string) => Promise; +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; + onDownloadFolder?: (folderId: string, folderName: string) => Promise; + 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; onBulkDownload?: () => Promise; } diff --git a/apps/web/src/app/(shares)/shares/components/shares-modals.tsx b/apps/web/src/app/(shares)/shares/components/shares-modals.tsx index e136a8c..bbe9490 100644 --- a/apps/web/src/app/(shares)/shares/components/shares-modals.tsx +++ b/apps/web/src/app/(shares)/shares/components/shares-modals.tsx @@ -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 ( <> - + { + const [filesResponse, foldersResponse] = await Promise.all([listFiles(), listFolders()]); + return { + files: filesResponse.data.files || [], + folders: foldersResponse.data.folders || [], + }; + }} + /> - fileManager.setFilesToShare(null)} onSuccess={() => { diff --git a/apps/web/src/app/api/(proxy)/files/[id]/move/route.ts b/apps/web/src/app/api/(proxy)/files/[id]/move/route.ts new file mode 100644 index 0000000..850128c --- /dev/null +++ b/apps/web/src/app/api/(proxy)/files/[id]/move/route.ts @@ -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; +} diff --git a/apps/web/src/app/api/(proxy)/files/route.ts b/apps/web/src/app/api/(proxy)/files/route.ts index a63b2d9..e32f506 100644 --- a/apps/web/src/app/api/(proxy)/files/route.ts +++ b/apps/web/src/app/api/(proxy)/files/route.ts @@ -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", diff --git a/apps/web/src/app/api/(proxy)/folders/[id]/contents/route.ts b/apps/web/src/app/api/(proxy)/folders/[id]/contents/route.ts new file mode 100644 index 0000000..1c235f8 --- /dev/null +++ b/apps/web/src/app/api/(proxy)/folders/[id]/contents/route.ts @@ -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; +} diff --git a/apps/web/src/app/api/(proxy)/folders/[id]/files/route.ts b/apps/web/src/app/api/(proxy)/folders/[id]/files/route.ts new file mode 100644 index 0000000..d539e85 --- /dev/null +++ b/apps/web/src/app/api/(proxy)/folders/[id]/files/route.ts @@ -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; +} diff --git a/apps/web/src/app/api/(proxy)/folders/[id]/move/route.ts b/apps/web/src/app/api/(proxy)/folders/[id]/move/route.ts new file mode 100644 index 0000000..0219897 --- /dev/null +++ b/apps/web/src/app/api/(proxy)/folders/[id]/move/route.ts @@ -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; +} diff --git a/apps/web/src/app/api/(proxy)/folders/[id]/route.ts b/apps/web/src/app/api/(proxy)/folders/[id]/route.ts new file mode 100644 index 0000000..aaadf5e --- /dev/null +++ b/apps/web/src/app/api/(proxy)/folders/[id]/route.ts @@ -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; +} diff --git a/apps/web/src/app/api/(proxy)/folders/route.ts b/apps/web/src/app/api/(proxy)/folders/route.ts new file mode 100644 index 0000000..e76b0fb --- /dev/null +++ b/apps/web/src/app/api/(proxy)/folders/route.ts @@ -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; +} diff --git a/apps/web/src/app/api/(proxy)/shares/[shareId]/folders/[folderId]/contents/route.ts b/apps/web/src/app/api/(proxy)/shares/[shareId]/folders/[folderId]/contents/route.ts new file mode 100644 index 0000000..05d5759 --- /dev/null +++ b/apps/web/src/app/api/(proxy)/shares/[shareId]/folders/[folderId]/contents/route.ts @@ -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; +} diff --git a/apps/web/src/app/api/(proxy)/shares/[shareId]/folders/[folderId]/download/route.ts b/apps/web/src/app/api/(proxy)/shares/[shareId]/folders/[folderId]/download/route.ts new file mode 100644 index 0000000..f444038 --- /dev/null +++ b/apps/web/src/app/api/(proxy)/shares/[shareId]/folders/[folderId]/download/route.ts @@ -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; +} diff --git a/apps/web/src/app/api/(proxy)/shares/files/add/[shareId]/route.ts b/apps/web/src/app/api/(proxy)/shares/files/add/[shareId]/route.ts index 30a6346..230cb76 100644 --- a/apps/web/src/app/api/(proxy)/shares/files/add/[shareId]/route.ts +++ b/apps/web/src/app/api/(proxy)/shares/files/add/[shareId]/route.ts @@ -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", }); diff --git a/apps/web/src/app/api/(proxy)/shares/files/remove/[shareId]/route.ts b/apps/web/src/app/api/(proxy)/shares/files/remove/[shareId]/route.ts index 31dfe41..d45863b 100644 --- a/apps/web/src/app/api/(proxy)/shares/files/remove/[shareId]/route.ts +++ b/apps/web/src/app/api/(proxy)/shares/files/remove/[shareId]/route.ts @@ -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", }); diff --git a/apps/web/src/app/api/(proxy)/shares/folders/add/[shareId]/route.ts b/apps/web/src/app/api/(proxy)/shares/folders/add/[shareId]/route.ts new file mode 100644 index 0000000..2dc98f5 --- /dev/null +++ b/apps/web/src/app/api/(proxy)/shares/folders/add/[shareId]/route.ts @@ -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; +} diff --git a/apps/web/src/app/api/(proxy)/shares/folders/remove/[shareId]/route.ts b/apps/web/src/app/api/(proxy)/shares/folders/remove/[shareId]/route.ts new file mode 100644 index 0000000..82bbd1f --- /dev/null +++ b/apps/web/src/app/api/(proxy)/shares/folders/remove/[shareId]/route.ts @@ -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; +} diff --git a/apps/web/src/app/dashboard/components/dashboard-files-view.tsx b/apps/web/src/app/dashboard/components/dashboard-files-view.tsx index 5577a30..153e785 100644 --- a/apps/web/src/app/dashboard/components/dashboard-files-view.tsx +++ b/apps/web/src/app/dashboard/components/dashboard-files-view.tsx @@ -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 ( onBulkDelete(files) : undefined} + onBulkShare={onBulkShare ? (files) => onBulkShare(files) : undefined} + onBulkDownload={onBulkDownload ? (files) => onBulkDownload(files) : undefined} setClearSelectionCallback={setClearSelectionCallback} /> ); diff --git a/apps/web/src/app/dashboard/modals/dashboard-modals.tsx b/apps/web/src/app/dashboard/modals/dashboard-modals.tsx index 7c30424..836a27e 100644 --- a/apps/web/src/app/dashboard/modals/dashboard-modals.tsx +++ b/apps/web/src/app/dashboard/modals/dashboard-modals.tsx @@ -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)} /> - 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, + })) || []), + ]} /> - fileManager.setFilesToShare(null)} + folders={fileManager.foldersToShare} + isOpen={!!(fileManager.filesToShare || fileManager.foldersToShare)} + onClose={() => { + fileManager.setFilesToShare(null); + fileManager.setFoldersToShare(null); + }} onSuccess={() => { fileManager.handleShareBulkSuccess(); onSuccess(); }} /> - + { + modals.onCloseCreateModal(); + onSuccess(); + }} + getAllFilesAndFolders={async () => { + const [filesResponse, foldersResponse] = await Promise.all([listFiles(), listFolders()]); + return { + files: filesResponse.data.files || [], + folders: foldersResponse.data.folders || [], + }; + }} + /> diff --git a/apps/web/src/app/files/components/empty-state.tsx b/apps/web/src/app/files/components/empty-state.tsx deleted file mode 100644 index 73f2dbe..0000000 --- a/apps/web/src/app/files/components/empty-state.tsx +++ /dev/null @@ -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 ( -
- -

{t("emptyState.noFiles")}

- -
- ); -} diff --git a/apps/web/src/app/files/components/file-list.tsx b/apps/web/src/app/files/components/file-list.tsx index c3a5ef4..3827ac5 100644 --- a/apps/web/src/app/files/components/file-list.tsx +++ b/apps/web/src/app/files/components/file-list.tsx @@ -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 ( @@ -13,8 +25,10 @@ export function FileList({ files, filteredFiles, fileManager, searchQuery, onSea
@@ -46,7 +60,9 @@ export function FileList({ files, filteredFiles, fileManager, searchQuery, onSea }} /> ) : ( - +
+

{t("files.empty.title")}

+
)}
diff --git a/apps/web/src/app/files/components/files-view-manager.tsx b/apps/web/src/app/files/components/files-view-manager.tsx index 357ae14..60d9311 100644 --- a/apps/web/src/app/files/components/files-view-manager.tsx +++ b/apps/web/src/app/files/components/files-view-manager.tsx @@ -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; + 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(() => { @@ -66,53 +115,109 @@ 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 (
+ {/* Breadcrumbs, Search and View Controls */}
- onSearch(e.target.value)} - className="max-w-sm" - /> +
{breadcrumbs}
-
- - +
+
+ + onSearch(e.target.value)} + className="max-w-sm pl-10" + /> +
+ +
+ + +
- {viewMode === "table" ? : } + {isLoading ? ( +
+
+

Loading...

+
+ ) : showEmptyState ? ( + EmptyStateComponent ? ( + + ) : ( +
+

{t("files.empty.title")}

+
+ ) + ) : ( +
+ {viewMode === "table" ? : } + + {/* No results message */} + {searchQuery && !hasContent && ( +
+

{t("searchBar.noResults", { query: searchQuery })}

+
+ )} +
+ )}
); } diff --git a/apps/web/src/app/files/components/header.tsx b/apps/web/src/app/files/components/header.tsx index 10b9b05..0f9af01 100644 --- a/apps/web/src/app/files/components/header.tsx +++ b/apps/web/src/app/files/components/header.tsx @@ -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 (

{t("files.title")}

- +
+ {onCreateFolder && ( + + )} + +
); } diff --git a/apps/web/src/app/files/components/search-bar.tsx b/apps/web/src/app/files/components/search-bar.tsx index ebb6fe0..fd01bd1 100644 --- a/apps/web/src/app/files/components/search-bar.tsx +++ b/apps/web/src/app/files/components/search-bar.tsx @@ -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 (
@@ -20,7 +30,7 @@ export function SearchBar({ searchQuery, onSearch, totalFiles, filteredCount }:
{searchQuery && ( - {t("searchBar.results", { filtered: filteredCount, total: totalFiles })} + {t("searchBar.results", { filtered: filteredItems, total: totalItems })} )}
diff --git a/apps/web/src/app/files/hooks/use-file-browser.ts b/apps/web/src/app/files/hooks/use-file-browser.ts new file mode 100644 index 0000000..ab1d3b5 --- /dev/null +++ b/apps/web/src/app/files/hooks/use-file-browser.ts @@ -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([]); + const [folders, setFolders] = useState([]); + const [allFiles, setAllFiles] = useState([]); + const [allFolders, setAllFolders] = useState([]); + const [currentPath, setCurrentPath] = useState([]); + 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(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(); + + 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; diff --git a/apps/web/src/app/files/hooks/use-files.ts b/apps/web/src/app/files/hooks/use-files.ts deleted file mode 100644 index 0e0aa95..0000000 --- a/apps/web/src/app/files/hooks/use-files.ts +++ /dev/null @@ -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([]); - 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, - }; -} diff --git a/apps/web/src/app/files/modals/files-modals.tsx b/apps/web/src/app/files/modals/files-modals.tsx index 4bf6953..40a7063 100644 --- a/apps/web/src/app/files/modals/files-modals.tsx +++ b/apps/web/src/app/files/modals/files-modals.tsx @@ -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 ( <> - + + + {/* Folder Modals */} + 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} + /> fileManager.setPreviewFile(null)} /> - 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, + })) || []), + ]} /> 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" + } /> - fileManager.setFilesToShare(null)} + folders={fileManager.foldersToShare} + isOpen={!!(fileManager.filesToShare || fileManager.foldersToShare)} + onClose={() => { + fileManager.setFilesToShare(null); + fileManager.setFoldersToShare(null); + }} onSuccess={() => { fileManager.handleShareBulkSuccess(); onSuccess(); diff --git a/apps/web/src/app/files/page.tsx b/apps/web/src/app/files/page.tsx index 30ed92c..6750df5 100644 --- a/apps/web/src/app/files/page.tsx +++ b/apps/web/src/app/files/page.tsx @@ -1,26 +1,116 @@ "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 ; - } + 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 ( @@ -35,19 +125,79 @@ export default function FilesPage() {
-
+
fileManager.setCreateFolderModalOpen(true)} + /> + + + + + + {t("folderActions.rootFolder")} + + + + {currentPath.map((folder, index) => ( +
+ + + {index === currentPath.length - 1 ? ( + {folder.name} + ) : ( + navigateToFolder(folder.id)}> + {folder.name} + + )} + +
+ ))} +
+ + } + 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={() => ( +
+ +

{t("files.empty.title")}

+

{t("files.empty.description")}

+
+ )} />
- + 0 ? currentPath[currentPath.length - 1].id : null} + /> + + 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} + />
diff --git a/apps/web/src/app/files/types/index.ts b/apps/web/src/app/files/types/index.ts index a697aa0..7dbed76 100644 --- a/apps/web/src/app/files/types/index.ts +++ b/apps/web/src/app/files/types/index.ts @@ -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 { diff --git a/apps/web/src/components/files/files-view.tsx b/apps/web/src/components/files/files-view.tsx index 448aaea..3849307 100644 --- a/apps/web/src/components/files/files-view.tsx +++ b/apps/web/src/components/files/files-view.tsx @@ -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("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 (
@@ -95,7 +121,7 @@ export function FilesView({
{t("files.totalFiles", { count: files.length })}
- {viewMode === "table" ? : } + {viewMode === "table" ? : }
); } diff --git a/apps/web/src/components/general/file-selector.tsx b/apps/web/src/components/general/file-selector.tsx index 4f354e7..ff63a34 100644 --- a/apps/web/src/components/general/file-selector.tsx +++ b/apps/web/src/components/general/file-selector.tsx @@ -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; + selectedFolders?: string[]; + onSave: (files: string[], folders: string[]) => Promise; onEditFile?: (fileId: string, newName: string, description?: string) => Promise; + onEditFolder?: (folderId: string, newName: string, description?: string) => Promise; } -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([]); const [shareFiles, setShareFiles] = useState([]); + const [availableFolders, setAvailableFolders] = useState([]); + const [shareFolders, setShareFolders] = useState([]); const [isLoading, setIsLoading] = useState(false); const [searchFilter, setSearchFilter] = useState(""); const [shareSearchFilter, setShareSearchFilter] = useState(""); const [previewFile, setPreviewFile] = useState(null); const [fileToEdit, setFileToEdit] = useState(null); + const [folderToEdit, setFolderToEdit] = useState(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 ( +
+ +
+
+ {folder.name} +
+ {folder.description && ( +
+ {folder.description} +
+ )} +
+ {folder.totalSize ? formatFileSize(folder.totalSize) : "โ€”"} โ€ข {folder._count?.files || 0} files +
+
+
+
+ {onEditFolder && ( + + )} +
+ +
+ +
+
+
+ ); + }; + return ( <>
-

{t("fileSelector.shareFiles", { count: shareFiles.length })}

-

{t("fileSelector.shareFilesDescription")}

+

Share Contents ({shareFiles.length + shareFolders.length} items)

+

Files and folders that will be shared

- {shareFiles.length} {t("fileSelector.fileCount", { count: shareFiles.length })} + {shareFiles.length} files โ€ข {shareFolders.length} folders
- {shareFiles.length > 0 && ( + {(shareFiles.length > 0 || shareFolders.length > 0) && (
setShareSearchFilter(e.target.value)} className="pl-10" @@ -192,20 +327,23 @@ export function FileSelector({ shareId, selectedFiles, onSave, onEditFile }: Fil
)} - {shareFiles.length > 0 ? ( + {shareFiles.length > 0 || shareFolders.length > 0 ? (
- {filteredShareFiles.map((file) => ( - + {filteredShareFolders.map((folder) => ( + ))} - {filteredShareFiles.length === 0 && shareSearchFilter && ( + {filteredShareFiles.map((file) => ( + + ))} + {filteredShareFiles.length === 0 && filteredShareFolders.length === 0 && shareSearchFilter && (
-

{t("fileSelector.noFilesFoundWith", { query: shareSearchFilter })}

+

No items found with "{shareSearchFilter}"

)}
) : (
-
๐Ÿ“
+

{t("fileSelector.noFilesInShare")}

{t("fileSelector.addFilesFromList")}

@@ -216,9 +354,9 @@ export function FileSelector({ shareId, selectedFiles, onSave, onEditFile }: Fil

- {t("fileSelector.availableFiles", { count: filteredAvailableFiles.length })} + Available Items ({filteredAvailableFiles.length + filteredAvailableFolders.length})

-

{t("fileSelector.availableFilesDescription")}

+

Files and folders you can add to the share

@@ -232,21 +370,24 @@ export function FileSelector({ shareId, selectedFiles, onSave, onEditFile }: Fil />
- {filteredAvailableFiles.length > 0 ? ( + {filteredAvailableFiles.length > 0 || filteredAvailableFolders.length > 0 ? (
+ {filteredAvailableFolders.map((folder) => ( + + ))} {filteredAvailableFiles.map((file) => ( - + ))}
) : searchFilter ? (
-
๐Ÿ”
+

{t("fileSelector.noFilesFound")}

{t("fileSelector.tryDifferentSearch")}

) : (
-
๐Ÿ“„
+

{t("fileSelector.allFilesInShare")}

{t("fileSelector.uploadNewFiles")}

@@ -254,9 +395,7 @@ export function FileSelector({ shareId, selectedFiles, onSave, onEditFile }: Fil
-
- {t("fileSelector.filesSelected", { count: shareFiles.length })} -
+
{shareFiles.length + shareFolders.length} items selected
diff --git a/apps/web/src/components/modals/create-share-modal.tsx b/apps/web/src/components/modals/create-share-modal.tsx index 173dbda..0bca9cf 100644 --- a/apps/web/src/components/modals/create-share-modal.tsx +++ b/apps/web/src/components/modals/create-share-modal.tsx @@ -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([]); + const [files, setFiles] = useState([]); + const [folders, setFolders] = useState([]); + 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 ( - - + + - + {t("createShare.title")} -
-
- - setFormData({ ...formData, name: e.target.value })} - onPaste={(e) => { - e.preventDefault(); - const pastedText = e.clipboardData.getData("text"); - setFormData({ ...formData, name: pastedText }); - }} - placeholder={t("createShare.namePlaceholder")} - /> -
-
- - setFormData({ ...formData, description: e.target.value })} - placeholder={t("createShare.descriptionPlaceholder")} - /> -
+
+ + + {t("createShare.tabs.shareDetails")} + + {t("createShare.tabs.selectFiles")} + {selectedCount > 0 && ( + + {selectedCount} + + )} + + -
- - setFormData({ ...formData, expiresAt: e.target.value })} - onBlur={(e) => { - const value = e.target.value; - if (value && value.length === 10) { - setFormData({ ...formData, expiresAt: value + "T23:59" }); - } - }} - /> -
+ +
+ + updateFormData("name", e.target.value)} + placeholder={t("createShare.namePlaceholder")} + required + /> +
-
- - setFormData({ ...formData, maxViews: e.target.value })} - /> -
+
+ +