mirror of
https://github.com/kyantech/Palmr.git
synced 2025-10-23 06:11:58 +00:00
Compare commits
48 Commits
v3.2.0-bet
...
v3.2.5-bet
Author | SHA1 | Date | |
---|---|---|---|
|
f63105c5eb | ||
|
cb4ed3f581 | ||
|
148676513d | ||
|
42a5b7a796 | ||
|
59fccd9a93 | ||
|
5fe6434027 | ||
|
91a5a24c8b | ||
|
ff83364870 | ||
|
df31b325f6 | ||
|
cce9847242 | ||
|
39dc94b7f8 | ||
|
ab5ea156a3 | ||
|
4ff1eb28d9 | ||
|
17080e4465 | ||
|
c798c1bb1d | ||
|
0d7f9ca2b3 | ||
|
f78ecab2ed | ||
|
fcc877738f | ||
|
92722692f9 | ||
|
94e021d8c6 | ||
|
95ac0f195b | ||
|
d6c9b0d7d2 | ||
|
59f9e19ffb | ||
|
6086d2a0ac | ||
|
f3aeaf66df | ||
|
6b979a22fb | ||
|
e4bae380c9 | ||
|
3117904009 | ||
|
331624e2f2 | ||
|
b078e94189 | ||
|
ba512ebe95 | ||
|
bd4212b44c | ||
|
b699bffb5b | ||
|
a755c5324f | ||
|
9072e7e866 | ||
|
d3d1057ba8 | ||
|
24eda85fdc | ||
|
d49d15ac9b | ||
|
e7b2062764 | ||
|
f21f972825 | ||
|
4f4e4a079e | ||
|
6fbb9aa9da | ||
|
0e610d002c | ||
|
abd8366e94 | ||
|
5d8c243125 | ||
|
d23af700da | ||
|
494161eb47 | ||
|
6a9728be4b |
259
.github/copilot-instructions.md
vendored
Normal file
259
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,259 @@
|
||||
# GitHub Copilot Instructions for Palmr
|
||||
|
||||
This file contains instructions for GitHub Copilot to help contributors work effectively with the Palmr codebase.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Palmr is a flexible and open-source alternative to file transfer services like WeTransfer and SendGB. It's built with:
|
||||
|
||||
- **Backend**: Fastify (Node.js) with TypeScript, SQLite database, and filesystem/S3 storage
|
||||
- **Frontend**: Next.js 15 + React + TypeScript + Shadcn/ui
|
||||
- **Documentation**: Next.js + Fumadocs + MDX
|
||||
- **Package Manager**: pnpm (v10.6.0)
|
||||
- **Monorepo Structure**: Three main apps (web, server, docs) in the `apps/` directory
|
||||
|
||||
## Architecture and Structure
|
||||
|
||||
### Monorepo Layout
|
||||
|
||||
```
|
||||
apps/
|
||||
├── docs/ # Documentation site (Next.js + Fumadocs)
|
||||
├── server/ # Backend API (Fastify + TypeScript)
|
||||
└── web/ # Frontend application (Next.js 15)
|
||||
```
|
||||
|
||||
### Key Technologies
|
||||
|
||||
- **TypeScript**: Primary language for all applications
|
||||
- **Database**: Prisma ORM with SQLite (optional S3-compatible storage)
|
||||
- **Authentication**: Multiple OAuth providers (Google, GitHub, Discord, etc.)
|
||||
- **Internationalization**: Multi-language support with translation scripts
|
||||
- **Validation**: Husky pre-push hooks for linting and type checking
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Base Branch
|
||||
|
||||
Always create new branches from and submit PRs to the `next` branch, not `main`.
|
||||
|
||||
### Commit Convention
|
||||
|
||||
Use Conventional Commits format for all commits:
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
Types:
|
||||
- feat: New feature
|
||||
- fix: Bug fix
|
||||
- docs: Documentation changes
|
||||
- test: Adding or updating tests
|
||||
- refactor: Code refactoring
|
||||
- style: Code formatting
|
||||
- chore: Maintenance tasks
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
- `feat(web): add user authentication system`
|
||||
- `fix(api): resolve null pointer exception in user service`
|
||||
- `docs: update installation instructions in README`
|
||||
- `test(server): add unit tests for user validation`
|
||||
|
||||
### Code Quality Standards
|
||||
|
||||
1. **Linting**: All apps use ESLint. Run `pnpm lint` before committing
|
||||
2. **Formatting**: Use Prettier for code formatting. Run `pnpm format`
|
||||
3. **Type Checking**: Run `pnpm type-check` to validate TypeScript
|
||||
4. **Validation**: Run `pnpm validate` to run both linting and type checking
|
||||
5. **Pre-push Hook**: Automatically validates all apps before pushing
|
||||
|
||||
### Testing Changes
|
||||
|
||||
- Test incrementally during development
|
||||
- Run validation locally before pushing: `pnpm validate` in each app directory
|
||||
- Keep changes focused on a single issue or feature
|
||||
- Review your work before committing
|
||||
|
||||
## Application-Specific Guidelines
|
||||
|
||||
### Web App (`apps/web/`)
|
||||
|
||||
- Framework: Next.js 15 with App Router
|
||||
- Port: 3000 (development)
|
||||
- Scripts:
|
||||
- `pnpm dev`: Start development server
|
||||
- `pnpm build`: Build for production
|
||||
- `pnpm validate`: Run linting and type checking
|
||||
- Translations: Use Python scripts in `scripts/` directory
|
||||
- `pnpm translations:check`: Check translation status
|
||||
- `pnpm translations:sync`: Synchronize translations
|
||||
|
||||
### Server App (`apps/server/`)
|
||||
|
||||
- Framework: Fastify with TypeScript
|
||||
- Port: 3333 (default)
|
||||
- Scripts:
|
||||
- `pnpm dev`: Start development server with watch mode
|
||||
- `pnpm build`: Build TypeScript to JavaScript
|
||||
- `pnpm validate`: Run linting and type checking
|
||||
- `pnpm db:seed`: Seed database
|
||||
- Database: Prisma ORM with SQLite
|
||||
|
||||
### Docs App (`apps/docs/`)
|
||||
|
||||
- Framework: Next.js with Fumadocs
|
||||
- Port: 3001 (development)
|
||||
- Content: MDX files in `content/docs/`
|
||||
- Scripts:
|
||||
- `pnpm dev`: Start development server
|
||||
- `pnpm build`: Build documentation site
|
||||
- `pnpm validate`: Run linting and type checking
|
||||
|
||||
## Code Style and Best Practices
|
||||
|
||||
### General Guidelines
|
||||
|
||||
1. **Follow Style Guidelines**: Ensure code adheres to ESLint and Prettier configurations
|
||||
2. **TypeScript First**: Always use TypeScript, avoid `any` types when possible
|
||||
3. **Component Organization**: Keep components focused and single-purpose
|
||||
4. **Error Handling**: Implement proper error handling and logging
|
||||
5. **Comments**: Add comments only when necessary to explain complex logic
|
||||
6. **Imports**: Use absolute imports where configured, keep imports organized
|
||||
|
||||
### API Development (Server)
|
||||
|
||||
- Use Fastify's schema validation for all routes
|
||||
- Follow REST principles for endpoint design
|
||||
- Implement proper authentication and authorization
|
||||
- Handle errors gracefully with appropriate status codes
|
||||
- Document API endpoints in the docs app
|
||||
|
||||
### Frontend Development (Web)
|
||||
|
||||
- Use React Server Components where appropriate
|
||||
- Implement proper loading and error states
|
||||
- Follow accessibility best practices (WCAG guidelines)
|
||||
- Optimize performance (lazy loading, code splitting)
|
||||
- Use Shadcn/ui components for consistent UI
|
||||
|
||||
### Documentation
|
||||
|
||||
- Write clear, concise documentation
|
||||
- Include code examples where helpful
|
||||
- Update documentation when changing functionality
|
||||
- Use MDX features for interactive documentation
|
||||
- Follow the existing documentation structure
|
||||
|
||||
## Translation and Internationalization
|
||||
|
||||
- All user-facing strings should be translatable
|
||||
- Use the Next.js internationalization system
|
||||
- Translation files are in `apps/web/messages/`
|
||||
- Reference file: `en-US.json`
|
||||
- Run `pnpm translations:check` to verify translations
|
||||
- Mark untranslated strings with `[TO_TRANSLATE]` prefix
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Authentication Providers
|
||||
|
||||
- Provider configurations in `apps/server/src/modules/auth-providers/providers.config.ts`
|
||||
- Support for OAuth2 and OIDC protocols
|
||||
- Field mappings for user data normalization
|
||||
- Special handling for providers like GitHub that require additional API calls
|
||||
|
||||
### File Storage
|
||||
|
||||
- Default: Filesystem storage
|
||||
- Optional: S3-compatible object storage
|
||||
- File metadata stored in SQLite database
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- Configure via `.env` files (not committed to repository)
|
||||
- Required variables documented in README or docs
|
||||
- Use environment-specific configurations
|
||||
|
||||
## Contributing Guidelines
|
||||
|
||||
### Pull Request Process
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a branch from `next`: `git checkout -b feature/your-feature upstream/next`
|
||||
3. Make focused changes addressing a single issue/feature
|
||||
4. Write or update tests as needed
|
||||
5. Update documentation to reflect changes
|
||||
6. Ensure all validations pass: `pnpm validate` in each app
|
||||
7. Commit using Conventional Commits
|
||||
8. Push to your fork
|
||||
9. Create Pull Request targeting the `next` branch
|
||||
|
||||
### Code Review
|
||||
|
||||
- Be responsive to feedback
|
||||
- Keep discussions constructive and professional
|
||||
- Make requested changes promptly
|
||||
- Ask questions if requirements are unclear
|
||||
|
||||
### What to Avoid
|
||||
|
||||
- Don't mix unrelated changes in a single PR
|
||||
- Don't skip linting or type checking
|
||||
- Don't commit directly to `main` or `next` branches
|
||||
- Don't add unnecessary dependencies
|
||||
- Don't ignore existing code style and patterns
|
||||
- Don't remove or modify tests without good reason
|
||||
|
||||
## Helpful Commands
|
||||
|
||||
### Root Level
|
||||
|
||||
```bash
|
||||
pnpm install # Install all dependencies
|
||||
git config core.hooksPath .husky # Configure Git hooks
|
||||
```
|
||||
|
||||
### Per App (web/server/docs)
|
||||
|
||||
```bash
|
||||
pnpm dev # Start development server
|
||||
pnpm build # Build for production
|
||||
pnpm lint # Run ESLint
|
||||
pnpm lint:fix # Fix ESLint issues automatically
|
||||
pnpm format # Format code with Prettier
|
||||
pnpm format:check # Check code formatting
|
||||
pnpm type-check # Run TypeScript type checking
|
||||
pnpm validate # Run lint + type-check
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker-compose up # Start all services
|
||||
docker-compose down # Stop all services
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- **Documentation**: [https://palmr.kyantech.com.br](https://palmr.kyantech.com.br)
|
||||
- **Contributing Guide**: [CONTRIBUTING.md](../CONTRIBUTING.md)
|
||||
- **Issue Tracker**: GitHub Issues
|
||||
- **License**: Apache-2.0
|
||||
|
||||
## Getting Help
|
||||
|
||||
- Review existing documentation in `apps/docs/content/docs/`
|
||||
- Check contribution guide in `CONTRIBUTING.md`
|
||||
- Review existing code for patterns and examples
|
||||
- Ask questions in PR discussions or issues
|
||||
- Read error messages and logs carefully
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Beta Status**: This project is in beta; expect changes and improvements
|
||||
- **Focus on Quality**: Prioritize code quality and maintainability over speed
|
||||
- **Test Locally**: Always test your changes locally before submitting
|
||||
- **Documentation Matters**: Keep documentation synchronized with code
|
||||
- **Community First**: Be respectful, patient, and constructive with all contributors
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -33,4 +33,5 @@ apps/server/dist/*
|
||||
.steering
|
||||
data/
|
||||
|
||||
node_modules/
|
||||
node_modules/
|
||||
screenshots/
|
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine AS base
|
||||
FROM node:24-alpine AS base
|
||||
|
||||
# Install system dependencies
|
||||
RUN apk add --no-cache \
|
||||
|
212
LICENSE
212
LICENSE
@@ -1,40 +1,190 @@
|
||||
Kyantech-Permissive License (Based on BSD 2-Clause)
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
Copyright (c) 2025, Daniel Luiz Alves (danielalves96) - Kyantech Solutions
|
||||
All rights reserved.
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted for any purpose — private, commercial,
|
||||
educational, governmental — **fully free and unrestricted**, provided
|
||||
that the following conditions are met:
|
||||
1. Definitions.
|
||||
|
||||
1. Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions, and the following disclaimer.
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions, and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
3. **If this software (or derivative works) is used in any public-facing
|
||||
interface** — such as websites, apps, dashboards, admin panels, or
|
||||
similar — a **simple credit** must appear in the footer or similar
|
||||
location. The credit text should read:
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
> “Powered by Kyantech Solutions · https://kyantech.com.br”
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
This credit must be reasonably visible but **must not interfere** with
|
||||
your UI, branding, or user experience. You may style it to match your
|
||||
own design and choose its size, placement, or color.
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
---
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2025 Daniel Luiz Alves (danielalves96) - Kyantech Solutions, Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
296
README.md
296
README.md
@@ -1,142 +1,154 @@
|
||||
# 🌴 Palmr. - Open-Source File Transfer
|
||||
|
||||
<p align="center">
|
||||
<img src="https://res.cloudinary.com/technical-intelligence/image/upload/v1749825361/Group_47_1_bcx8gw.png" alt="Palmr Banner" style="width: 100%;"/>
|
||||
</p>
|
||||
|
||||
**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**.
|
||||
|
||||
<div align="center">
|
||||
<img src="https://res.cloudinary.com/technical-intelligence/image/upload/v1745548231/Palmr./Captura_de_Tela_2025-04-24_a%CC%80s_23.24.26_kr4hsl.png" style="width: 100%; border-radius: 15px;" />
|
||||
</div>
|
||||
|
||||
|
||||
### **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
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="https://res.cloudinary.com/technical-intelligence/image/upload/v1749824929/Login_veq6e7.png" alt="Login Page" style="width: 100%; border-radius: 8px;" />
|
||||
<br /><strong>Login Page</strong>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="https://res.cloudinary.com/technical-intelligence/image/upload/v1749824929/Home_lzvfzu.png" alt="Home Page" style="width: 100%; border-radius: 8px;" />
|
||||
<br /><strong>Home Page</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="https://res.cloudinary.com/technical-intelligence/image/upload/v1749824928/Dashboard_uycmxb.png" alt="Dashboard" style="width: 100%; border-radius: 8px;" />
|
||||
<br /><strong>Dashboard</strong>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="https://res.cloudinary.com/technical-intelligence/image/upload/v1749824929/Profile_wvnlzw.png" alt="Profile Page" style="width: 100%; border-radius: 8px;" />
|
||||
<br /><strong>Profile Page</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="https://res.cloudinary.com/technical-intelligence/image/upload/v1749824928/Files_List_ztwr1e.png" alt="Files List View" style="width: 100%; border-radius: 8px;" />
|
||||
<br /><strong>Files List View</strong>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="https://res.cloudinary.com/technical-intelligence/image/upload/v1749824928/Files_Cards_pwsh5e.png" alt="Files Card View" style="width: 100%; border-radius: 8px;" />
|
||||
<br /><strong>Files Card View</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="https://res.cloudinary.com/technical-intelligence/image/upload/v1749824927/Shares_cgplgw.png" alt="Shares Management" style="width: 100%; border-radius: 8px;" />
|
||||
<br /><strong>Shares Management</strong>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="https://res.cloudinary.com/technical-intelligence/image/upload/v1749824928/Reive_Files_uhkeyc.png" alt="Receive Files" style="width: 100%; border-radius: 8px;" />
|
||||
<br /><strong>Receive Files</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="https://res.cloudinary.com/technical-intelligence/image/upload/v1749824927/Default_Reverse_xedmhw.png" alt="Reverse Share" style="width: 100%; border-radius: 8px;" />
|
||||
<br /><strong>Reverse Share</strong>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="https://res.cloudinary.com/technical-intelligence/image/upload/v1749824928/Settings_oampxr.png" alt="Settings Panel" style="width: 100%; border-radius: 8px;" />
|
||||
<br /><strong>Settings Panel</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="https://res.cloudinary.com/technical-intelligence/image/upload/v1749824928/User_Management_xjbfhn.png" alt="User Management" style="width: 100%; border-radius: 8px;" />
|
||||
<br /><strong>User Management</strong>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="https://res.cloudinary.com/technical-intelligence/image/upload/v1749824928/Forgot_Password_jcz9ad.png" alt="Forgot Password" style="width: 100%; border-radius: 8px;" />
|
||||
<br /><strong>Forgot Password</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="https://res.cloudinary.com/technical-intelligence/image/upload/v1749824928/WeTransfer_Reverse_u0g7eb.png" alt="Forgot Password" style="width: 100%; border-radius: 8px;" />
|
||||
<br /><strong>Reverse Share (WeTransfer Style)</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
## 👨💻 Core Maintainers
|
||||
|
||||
| [**Daniel Luiz Alves**](https://github.com/danielalves96) |
|
||||
|------------------|
|
||||
| <img src="https://github.com/danielalves96.png" width="150px" alt="Daniel Luiz Alves" /> |
|
||||
|
||||
</br>
|
||||
|
||||
## 🤝 Supporters
|
||||
|
||||
[<img src="https://i.ibb.co/nMN40STL/Repoflow.png" width="200px" alt="Daniel Luiz Alves" />](https://www.repoflow.io/)
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
<a href="https://www.star-history.com/#kyantech/Palmr&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=kyantech/Palmr&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=kyantech/Palmr&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=kyantech/Palmr&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## 🛠️ Contributing
|
||||
|
||||
For contribution guidelines, please refer to the [CONTRIBUTING.md](CONTRIBUTING.md) file.
|
||||
|
||||
# 🌴 Palmr. - Open-Source File Transfer
|
||||
|
||||
<p align="center">
|
||||
<img src="https://res.cloudinary.com/technical-intelligence/image/upload/v1749825361/Group_47_1_bcx8gw.png" alt="Palmr Banner" style="width: 100%;"/>
|
||||
</p>
|
||||
|
||||
**Palmr.** is a **flexible** and **open-source** alternative to file transfer services like **WeTransfer**, **SendGB**, **Send Anywhere**, and **Files.fm**.
|
||||
|
||||
<div align="center">
|
||||
<div style="background: linear-gradient(135deg, #ff4757, #ff3838); padding: 20px; border-radius: 12px; margin: 20px 0; box-shadow: 0 4px 15px rgba(255, 71, 87, 0.3); border: 2px solid #ff3838;">
|
||||
<h3 style="color: white; margin: 0 0 10px 0; font-size: 18px; font-weight: bold;">
|
||||
⚠️ BETA VERSION
|
||||
</h3>
|
||||
<p style="color: white; margin: 0; font-size: 14px; opacity: 0.95;">
|
||||
<strong>This project is currently in beta phase.</strong><br>
|
||||
Not recommended for production environments.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
🔗 **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**.
|
||||
|
||||
<div align="center">
|
||||
<img src="https://res.cloudinary.com/technical-intelligence/image/upload/v1745548231/Palmr./Captura_de_Tela_2025-04-24_a%CC%80s_23.24.26_kr4hsl.png" style="width: 100%; border-radius: 15px;" />
|
||||
</div>
|
||||
|
||||
|
||||
### **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
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="https://res.cloudinary.com/technical-intelligence/image/upload/v1749824929/Login_veq6e7.png" alt="Login Page" style="width: 100%; border-radius: 8px;" />
|
||||
<br /><strong>Login Page</strong>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="https://res.cloudinary.com/technical-intelligence/image/upload/v1749824929/Home_lzvfzu.png" alt="Home Page" style="width: 100%; border-radius: 8px;" />
|
||||
<br /><strong>Home Page</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="https://res.cloudinary.com/technical-intelligence/image/upload/v1749824928/Dashboard_uycmxb.png" alt="Dashboard" style="width: 100%; border-radius: 8px;" />
|
||||
<br /><strong>Dashboard</strong>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="https://res.cloudinary.com/technical-intelligence/image/upload/v1749824929/Profile_wvnlzw.png" alt="Profile Page" style="width: 100%; border-radius: 8px;" />
|
||||
<br /><strong>Profile Page</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="https://res.cloudinary.com/technical-intelligence/image/upload/v1749824928/Files_List_ztwr1e.png" alt="Files List View" style="width: 100%; border-radius: 8px;" />
|
||||
<br /><strong>Files List View</strong>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="https://res.cloudinary.com/technical-intelligence/image/upload/v1749824928/Files_Cards_pwsh5e.png" alt="Files Card View" style="width: 100%; border-radius: 8px;" />
|
||||
<br /><strong>Files Card View</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="https://res.cloudinary.com/technical-intelligence/image/upload/v1749824927/Shares_cgplgw.png" alt="Shares Management" style="width: 100%; border-radius: 8px;" />
|
||||
<br /><strong>Shares Management</strong>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="https://res.cloudinary.com/technical-intelligence/image/upload/v1749824928/Reive_Files_uhkeyc.png" alt="Receive Files" style="width: 100%; border-radius: 8px;" />
|
||||
<br /><strong>Receive Files</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="https://res.cloudinary.com/technical-intelligence/image/upload/v1749824927/Default_Reverse_xedmhw.png" alt="Reverse Share" style="width: 100%; border-radius: 8px;" />
|
||||
<br /><strong>Reverse Share</strong>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="https://res.cloudinary.com/technical-intelligence/image/upload/v1749824928/Settings_oampxr.png" alt="Settings Panel" style="width: 100%; border-radius: 8px;" />
|
||||
<br /><strong>Settings Panel</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="https://res.cloudinary.com/technical-intelligence/image/upload/v1749824928/User_Management_xjbfhn.png" alt="User Management" style="width: 100%; border-radius: 8px;" />
|
||||
<br /><strong>User Management</strong>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="https://res.cloudinary.com/technical-intelligence/image/upload/v1749824928/Forgot_Password_jcz9ad.png" alt="Forgot Password" style="width: 100%; border-radius: 8px;" />
|
||||
<br /><strong>Forgot Password</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="https://res.cloudinary.com/technical-intelligence/image/upload/v1749824928/WeTransfer_Reverse_u0g7eb.png" alt="Forgot Password" style="width: 100%; border-radius: 8px;" />
|
||||
<br /><strong>Reverse Share (WeTransfer Style)</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
## 👨💻 Core Maintainers
|
||||
|
||||
| [**Daniel Luiz Alves**](https://github.com/danielalves96) |
|
||||
|------------------|
|
||||
| <img src="https://github.com/danielalves96.png" width="150px" alt="Daniel Luiz Alves" /> |
|
||||
|
||||
</br>
|
||||
|
||||
## 🤝 Supporters
|
||||
|
||||
[<img src="https://i.ibb.co/nMN40STL/Repoflow.png" width="200px" alt="Daniel Luiz Alves" />](https://www.repoflow.io/)
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
<a href="https://www.star-history.com/#kyantech/Palmr&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=kyantech/Palmr&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=kyantech/Palmr&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=kyantech/Palmr&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## 🛠️ Contributing
|
||||
|
||||
For contribution guidelines, please refer to the [CONTRIBUTING.md](CONTRIBUTING.md) file.
|
||||
|
||||
|
@@ -105,6 +105,12 @@ The Palmr. API provides comprehensive access to all platform features:
|
||||
- **File management** - Rename, delete, and organize files
|
||||
- **Metadata access** - Retrieve file information and properties
|
||||
|
||||
### Folder operations
|
||||
|
||||
- **Create folders** - Build folder structures for organization
|
||||
- **Folder management** - Rename, move, delete folders
|
||||
- **Folder sharing** - Share folders with same controls as files
|
||||
|
||||
### Share management
|
||||
|
||||
- **Create shares** - Generate public links for file sharing
|
||||
|
374
apps/docs/content/docs/3.2-beta/cleanup-orphan-files.mdx
Normal file
374
apps/docs/content/docs/3.2-beta/cleanup-orphan-files.mdx
Normal file
@@ -0,0 +1,374 @@
|
||||
---
|
||||
title: Cleanup Orphan Files
|
||||
icon: Trash2
|
||||
---
|
||||
|
||||
This guide provides detailed instructions on how to identify and remove orphan file records from your Palmr database. Orphan files are database entries that reference files that no longer exist in the storage system, typically resulting from failed uploads or interrupted transfers.
|
||||
|
||||
## When and why to use this tool
|
||||
|
||||
The orphan file cleanup script is designed to maintain database integrity by removing stale file records. Consider using this tool if:
|
||||
|
||||
- Users are experiencing "File not found" errors when attempting to download files that appear in the UI
|
||||
- You've identified failed uploads that left incomplete database records
|
||||
- You're performing routine database maintenance
|
||||
- You've migrated storage systems and need to verify file consistency
|
||||
- You need to free up quota space occupied by phantom file records
|
||||
|
||||
> **Note:** This script only removes **database records** for files that don't exist in storage. It does not delete physical files. Files that exist in storage will remain untouched.
|
||||
|
||||
## How the cleanup works
|
||||
|
||||
Palmr provides a maintenance script that scans all file records in the database and verifies their existence in the storage system (either filesystem or S3). The script operates in two modes:
|
||||
|
||||
- **Dry-run mode (default):** Identifies orphan files and displays what would be deleted without making any changes
|
||||
- **Confirmation mode:** Actually removes the orphan database records after explicit confirmation
|
||||
|
||||
The script maintains safety by:
|
||||
- Checking file existence before marking as orphan
|
||||
- Providing detailed statistics and file listings
|
||||
- Requiring explicit `--confirm` flag to delete records
|
||||
- Working with both filesystem and S3 storage providers
|
||||
- Preserving all files that exist in storage
|
||||
|
||||
## Understanding orphan files
|
||||
|
||||
### What are orphan files?
|
||||
|
||||
Orphan files occur when:
|
||||
|
||||
1. **Failed chunked uploads:** A large file upload starts, creates a database record, but the upload fails before completion
|
||||
2. **Interrupted transfers:** Network issues or server restarts interrupt file transfers mid-process
|
||||
3. **Manual deletions:** Files are manually deleted from storage without removing the database record
|
||||
4. **Storage migrations:** Files are moved or lost during storage system changes
|
||||
|
||||
### Why they cause problems
|
||||
|
||||
When orphan records exist in the database:
|
||||
- Users see files in the UI that cannot be downloaded
|
||||
- Download attempts result in "ENOENT: no such file or directory" errors
|
||||
- Storage quota calculations become inaccurate
|
||||
- The system returns 500 errors instead of proper 404 responses (in older versions)
|
||||
|
||||
### Renamed files with suffixes
|
||||
|
||||
Files with duplicate names are automatically renamed with suffixes (e.g., `file (1).png`, `file (2).png`). Sometimes the upload fails after the database record is created but before the physical file is saved, creating an orphan record with a suffix.
|
||||
|
||||
**Example:**
|
||||
```
|
||||
Database record: photo (1).png → objectName: user123/1758805195682-Rjn9at692HdR.png
|
||||
Physical file: Does not exist ❌
|
||||
```
|
||||
|
||||
## Step-by-step instructions
|
||||
|
||||
### 1. Access the server environment
|
||||
|
||||
**For Docker installations:**
|
||||
|
||||
```bash
|
||||
docker exec -it <container_name> /bin/sh
|
||||
cd /app/palmr-app
|
||||
```
|
||||
|
||||
**For bare-metal installations:**
|
||||
|
||||
```bash
|
||||
cd /path/to/palmr/apps/server
|
||||
```
|
||||
|
||||
### 2. Run the cleanup script in dry-run mode
|
||||
|
||||
First, run the script without the `--confirm` flag to see what would be deleted:
|
||||
|
||||
```bash
|
||||
pnpm cleanup:orphan-files
|
||||
```
|
||||
|
||||
This will:
|
||||
- Scan all file records in the database
|
||||
- Check if each file exists in storage
|
||||
- Display a summary of orphan files
|
||||
- Show what would be deleted (without actually deleting)
|
||||
|
||||
### 3. Review the output
|
||||
|
||||
The script will provide detailed information about orphan files:
|
||||
|
||||
```text
|
||||
Starting orphan file cleanup...
|
||||
Storage mode: Filesystem
|
||||
Found 7 files in database
|
||||
❌ Orphan: photo(1).png (cmddjchw80000gmiimqnxga2g/1758805195682-Rjn9at692HdR.png)
|
||||
❌ Orphan: document.pdf (cmddjchw80000gmiimqnxga2g/1758803757558-JQxlvF816UVo.pdf)
|
||||
|
||||
📊 Summary:
|
||||
Total files in DB: 7
|
||||
✅ Files with storage: 5
|
||||
❌ Orphan files: 2
|
||||
|
||||
🗑️ Orphan files to be deleted:
|
||||
- photo(1).png (0.76 MB) - cmddjchw80000gmiimqnxga2g/1758805195682-Rjn9at692HdR.png
|
||||
- document.pdf (2.45 MB) - cmddjchw80000gmiimqnxga2g/1758803757558-JQxlvF816UVo.pdf
|
||||
|
||||
⚠️ Dry run mode. To actually delete orphan records, run with --confirm flag:
|
||||
pnpm cleanup:orphan-files:confirm
|
||||
```
|
||||
|
||||
### 4. Confirm and execute the cleanup
|
||||
|
||||
If you're satisfied with the results and want to proceed with the deletion:
|
||||
|
||||
```bash
|
||||
pnpm cleanup:orphan-files:confirm
|
||||
```
|
||||
|
||||
This will remove the orphan database records and display a confirmation:
|
||||
|
||||
```text
|
||||
🗑️ Deleting orphan file records...
|
||||
✓ Deleted: photo(1).png
|
||||
✓ Deleted: document.pdf
|
||||
|
||||
✅ Cleanup complete!
|
||||
Deleted 2 orphan file records
|
||||
```
|
||||
|
||||
## Example session
|
||||
|
||||
Below is a complete example of running the cleanup script:
|
||||
|
||||
```bash
|
||||
$ pnpm cleanup:orphan-files
|
||||
|
||||
> palmr-api@3.2.3-beta cleanup:orphan-files
|
||||
> tsx src/scripts/cleanup-orphan-files.ts
|
||||
|
||||
Starting orphan file cleanup...
|
||||
Storage mode: Filesystem
|
||||
Found 15 files in database
|
||||
❌ Orphan: video.mp4 (user123/1758803869037-1WhtnrQioeFQ.mp4)
|
||||
❌ Orphan: image(1).png (user123/1758805195682-Rjn9at692HdR.png)
|
||||
❌ Orphan: image(2).png (user123/1758803757558-JQxlvF816UVo.png)
|
||||
|
||||
📊 Summary:
|
||||
Total files in DB: 15
|
||||
✅ Files with storage: 12
|
||||
❌ Orphan files: 3
|
||||
|
||||
🗑️ Orphan files to be deleted:
|
||||
- video.mp4 (97.09 MB) - user123/1758803869037-1WhtnrQioeFQ.mp4
|
||||
- image(1).png (0.01 MB) - user123/1758805195682-Rjn9at692HdR.png
|
||||
- image(2).png (0.76 MB) - user123/1758803757558-JQxlvF816UVo.png
|
||||
|
||||
⚠️ Dry run mode. To actually delete orphan records, run with --confirm flag:
|
||||
pnpm cleanup:orphan-files:confirm
|
||||
|
||||
$ pnpm cleanup:orphan-files:confirm
|
||||
|
||||
> palmr-api@3.2.3-beta cleanup:orphan-files:confirm
|
||||
> tsx src/scripts/cleanup-orphan-files.ts --confirm
|
||||
|
||||
Starting orphan file cleanup...
|
||||
Storage mode: Filesystem
|
||||
Found 15 files in database
|
||||
❌ Orphan: video.mp4 (user123/1758803869037-1WhtnrQioeFQ.mp4)
|
||||
❌ Orphan: image(1).png (user123/1758805195682-Rjn9at692HdR.png)
|
||||
❌ Orphan: image(2).png (user123/1758803757558-JQxlvF816UVo.png)
|
||||
|
||||
📊 Summary:
|
||||
Total files in DB: 15
|
||||
✅ Files with storage: 12
|
||||
❌ Orphan files: 3
|
||||
|
||||
🗑️ Orphan files to be deleted:
|
||||
- video.mp4 (97.09 MB) - user123/1758803869037-1WhtnrQioeFQ.mp4
|
||||
- image(1).png (0.01 MB) - user123/1758805195682-Rjn9at692HdR.png
|
||||
- image(2).png (0.76 MB) - user123/1758803757558-JQxlvF816UVo.png
|
||||
|
||||
🗑️ Deleting orphan file records...
|
||||
✓ Deleted: video.mp4
|
||||
✓ Deleted: image(1).png
|
||||
✓ Deleted: image(2).png
|
||||
|
||||
✅ Cleanup complete!
|
||||
Deleted 3 orphan file records
|
||||
|
||||
Script completed successfully
|
||||
```
|
||||
|
||||
## Troubleshooting common issues
|
||||
|
||||
### No orphan files found
|
||||
|
||||
```text
|
||||
📊 Summary:
|
||||
Total files in DB: 10
|
||||
✅ Files with storage: 10
|
||||
❌ Orphan files: 0
|
||||
|
||||
✨ No orphan files found!
|
||||
```
|
||||
|
||||
**This is good!** It means your database is in sync with your storage system.
|
||||
|
||||
### Script cannot connect to database
|
||||
|
||||
If you see database connection errors:
|
||||
|
||||
1. Verify the database file exists:
|
||||
```bash
|
||||
ls -la prisma/palmr.db
|
||||
```
|
||||
|
||||
2. Check database permissions:
|
||||
```bash
|
||||
chmod 644 prisma/palmr.db
|
||||
```
|
||||
|
||||
3. Ensure you're in the correct directory:
|
||||
```bash
|
||||
pwd # Should show .../palmr/apps/server
|
||||
```
|
||||
|
||||
### Storage provider errors
|
||||
|
||||
For **S3 storage:**
|
||||
- Verify your S3 credentials are configured correctly
|
||||
- Check that the bucket is accessible
|
||||
- Ensure network connectivity to S3
|
||||
|
||||
For **Filesystem storage:**
|
||||
- Verify the uploads directory exists and is readable
|
||||
- Check file system permissions
|
||||
- Ensure sufficient disk space
|
||||
|
||||
### Script fails to delete records
|
||||
|
||||
If deletion fails for specific files:
|
||||
- Check database locks (close other connections)
|
||||
- Verify you have write permissions to the database
|
||||
- Review the error message for specific details
|
||||
|
||||
## Understanding the output
|
||||
|
||||
### File statistics
|
||||
|
||||
The script provides several key metrics:
|
||||
|
||||
- **Total files in DB:** All file records in your database
|
||||
- **Files with storage:** Records where the physical file exists
|
||||
- **Orphan files:** Records where the physical file is missing
|
||||
|
||||
### File information
|
||||
|
||||
For each orphan file, you'll see:
|
||||
|
||||
- **Name:** Display name in the UI
|
||||
- **Size:** File size as recorded in the database
|
||||
- **Object name:** Internal storage path
|
||||
|
||||
Example: `photo(1).png (0.76 MB) - user123/1758805195682-Rjn9at692HdR.png`
|
||||
|
||||
## Prevention and best practices
|
||||
|
||||
### Prevent orphan files from occurring
|
||||
|
||||
1. **Monitor upload failures:** Check server logs for upload errors
|
||||
2. **Stable network:** Ensure reliable network connectivity for large uploads
|
||||
3. **Adequate resources:** Provide sufficient disk space and memory
|
||||
4. **Regular maintenance:** Run this script periodically as part of maintenance
|
||||
|
||||
### When to run cleanup
|
||||
|
||||
Consider running the cleanup script:
|
||||
|
||||
- **Monthly:** As part of routine database maintenance
|
||||
- **After incidents:** Following server crashes or storage issues
|
||||
- **Before migrations:** Before moving to new storage systems
|
||||
- **When users report errors:** If download failures are reported
|
||||
|
||||
### Safe cleanup practices
|
||||
|
||||
1. **Always run dry-run first:** Review what will be deleted before confirming
|
||||
2. **Backup your database:** Create a backup before running with `--confirm`
|
||||
3. **Check during low usage:** Run during off-peak hours to minimize disruption
|
||||
4. **Document the cleanup:** Keep records of when and why cleanup was performed
|
||||
5. **Verify after cleanup:** Check that file counts match expectations
|
||||
|
||||
## Technical details
|
||||
|
||||
### How files are stored
|
||||
|
||||
When files are uploaded to Palmr:
|
||||
|
||||
1. Frontend generates a safe object name using random identifiers
|
||||
2. Backend creates the final `objectName` as: `${userId}/${timestamp}-${randomId}.${extension}`
|
||||
3. If a duplicate name exists, the **display name** gets a suffix, but `objectName` remains unique
|
||||
4. Physical file is stored using `objectName`, display name is stored separately in database
|
||||
|
||||
### Storage providers
|
||||
|
||||
The script works with both storage providers:
|
||||
|
||||
- **FilesystemStorageProvider:** Uses `fs.promises.access()` to check file existence
|
||||
- **S3StorageProvider:** Uses `HeadObjectCommand` to verify objects in S3 bucket
|
||||
|
||||
### Database schema
|
||||
|
||||
Files table structure:
|
||||
```typescript
|
||||
{
|
||||
name: string // Display name (can have suffixes like "file (1).png")
|
||||
objectName: string // Physical storage path (always unique)
|
||||
size: bigint // File size in bytes
|
||||
extension: string // File extension
|
||||
userId: string // Owner of the file
|
||||
folderId: string? // Parent folder (null for root)
|
||||
}
|
||||
```
|
||||
|
||||
## Related improvements
|
||||
|
||||
### Download validation (v3.2.3-beta+)
|
||||
|
||||
Starting from version 3.2.3-beta, Palmr includes enhanced download validation:
|
||||
|
||||
- Files are checked for existence **before** attempting download
|
||||
- Returns proper 404 error if file is missing (instead of 500)
|
||||
- Provides helpful error message to users
|
||||
|
||||
This prevents errors when trying to download orphan files that haven't been cleaned up yet.
|
||||
|
||||
## Security considerations
|
||||
|
||||
- **Read-only by default:** Dry-run mode is safe and doesn't modify data
|
||||
- **Explicit confirmation:** Requires `--confirm` flag to delete records
|
||||
- **No file deletion:** Only removes database records, never deletes physical files
|
||||
- **Audit trail:** All actions are logged to console
|
||||
- **Permission-based:** Only users with server access can run the script
|
||||
|
||||
> **Important:** This script does not delete physical files from storage. It only removes database records for files that don't exist. This is intentional to prevent accidental data loss.
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: Will this delete my files?**
|
||||
A: No. The script only removes database records for files that are already missing from storage. Physical files are never deleted.
|
||||
|
||||
**Q: Can I undo the cleanup?**
|
||||
A: No. Once orphan records are deleted, they cannot be recovered. Always run dry-run mode first and backup your database.
|
||||
|
||||
**Q: Why do orphan files have suffixes like (1), (2)?**
|
||||
A: When duplicate files are uploaded, Palmr renames them with suffixes. If the upload fails after creating the database record, an orphan with a suffix remains.
|
||||
|
||||
**Q: How often should I run this script?**
|
||||
A: Monthly maintenance is usually sufficient. Run more frequently if you experience many upload failures.
|
||||
|
||||
**Q: Does this work with S3 storage?**
|
||||
A: Yes! The script automatically detects your storage provider (filesystem or S3) and works with both.
|
||||
|
||||
**Q: What if I have thousands of orphan files?**
|
||||
A: The script handles large numbers efficiently. Consider running during off-peak hours for very large cleanups.
|
||||
|
||||
**Q: Can this fix "File not found" errors?**
|
||||
A: Yes, if the errors are caused by orphan database records. The script removes those records, preventing future errors.
|
@@ -49,6 +49,7 @@ The frontend is organized with:
|
||||
- **Custom hooks** to isolate logic and side effects
|
||||
- A **route protection system** using session cookies and middleware
|
||||
- A **file management interface** integrated with the backend
|
||||
- **Folder support** for organizing files hierarchically
|
||||
- A **reusable modal system** used for file actions, confirmations, and more
|
||||
- **Dynamic, locale-aware routing** using next-intl
|
||||
|
||||
@@ -68,7 +69,7 @@ Data is stored in **SQLite**, which handles user info, file metadata, session to
|
||||
Key features include:
|
||||
|
||||
- **Authentication/authorization** with JWT + cookie sessions
|
||||
- **File management logic** including uploads, deletes, and renames
|
||||
- **File management logic** including uploads, deletes, renames, and folders
|
||||
- **Storage operations** to handle file organization, usage tracking, and cleanup
|
||||
- A **share system** that generates tokenized public file links
|
||||
- Schema-based request validation for all endpoints
|
||||
@@ -106,9 +107,10 @@ Volumes are used to persist data locally, and containers are networked together
|
||||
|
||||
### File management
|
||||
|
||||
Files are at the heart of Palmr. Users can upload files via the frontend, and they're stored directly in the filesystem. The backend handles metadata (name, size, type, ownership), and also handles deletion, renaming, and public sharing. Every file operation is tracked, and all actions can be scoped per user.
|
||||
Files are at the heart of Palmr. Users can upload files via the frontend, and they're stored directly in the filesystem. Users can also create folders to organize files. The backend handles metadata (name, size, type, ownership), and also handles deletion, renaming, and public sharing. Every file operation is tracked, and all actions can be scoped per user.
|
||||
|
||||
- Upload/download with instant feedback
|
||||
- Create and organize files in folders
|
||||
- File previews, type validation, and size limits
|
||||
- Token-based sharing system
|
||||
- Disk usage tracking by user
|
||||
|
@@ -165,6 +165,27 @@ cp .env.example .env
|
||||
|
||||
This creates a `.env` file with the necessary configurations for the frontend.
|
||||
|
||||
##### Upload Configuration
|
||||
|
||||
Palmr. supports configurable chunked uploading for large files. You can customize the chunk size by setting the following environment variable in your `.env` file:
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_UPLOAD_CHUNK_SIZE_MB=100
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
|
||||
- If `NEXT_PUBLIC_UPLOAD_CHUNK_SIZE_MB` is set, Palmr. will use this value (in megabytes) as the chunk size for all file uploads that exceed this threshold.
|
||||
- If not set or left empty, Palmr. automatically calculates optimal chunk sizes based on file size:
|
||||
- Files ≤ 100MB: uploaded without chunking
|
||||
- Files > 100MB and ≤ 1GB: 75MB chunks
|
||||
- Files > 1GB: 150MB chunks
|
||||
|
||||
**When to configure:**
|
||||
|
||||
- **Default (not set):** Recommended for most use cases. Palmr. will intelligently determine the best chunk size.
|
||||
- **Custom value:** Set this if you have specific network conditions or want to optimize for your infrastructure (e.g., slower connections may benefit from smaller chunks like 50MB, while fast networks can handle larger chunks like 200MB, or the upload size per payload may be limited by a proxy like Cloudflare)
|
||||
|
||||
#### Install dependencies
|
||||
|
||||
Install all the frontend dependencies:
|
||||
@@ -201,6 +222,17 @@ You should see the full Palmr. application ready to go!
|
||||
|
||||
This guide sets up Palmr. using the local file system for storage. Want to use an S3-compatible object storage instead? You can configure that in the `.env` file. Check the Palmr. documentation for details on setting up S3 storage just update the environment variables, then build and run as shown here.
|
||||
|
||||
### Custom Installation Paths and Symlinks
|
||||
|
||||
If you're using a custom installation setup with symlinks (for example, `/opt/palmr_data/uploads -> /mnt/data/uploads`), you might encounter issues with disk space detection. Palmr. includes a `CUSTOM_PATH` environment variable to handle these scenarios:
|
||||
|
||||
```bash
|
||||
# In your .env file (apps/server/.env)
|
||||
CUSTOM_PATH=/opt/palmr_data
|
||||
```
|
||||
|
||||
This tells Palmr. to check your custom path first when determining available disk space, ensuring proper detection even when using symlinks or non-standard directory structures.
|
||||
|
||||
---
|
||||
|
||||
## Command cheat sheet
|
||||
|
@@ -16,6 +16,7 @@
|
||||
"reverse-proxy-configuration",
|
||||
"download-memory-management",
|
||||
"password-reset-without-smtp",
|
||||
"cleanup-orphan-files",
|
||||
"oidc-authentication",
|
||||
"troubleshooting",
|
||||
"---Developers---",
|
||||
@@ -31,4 +32,4 @@
|
||||
],
|
||||
"root": true,
|
||||
"title": "v3.2-beta"
|
||||
}
|
||||
}
|
||||
|
@@ -15,4 +15,4 @@
|
||||
"other"
|
||||
],
|
||||
"title": "OIDC Authentication"
|
||||
}
|
||||
}
|
||||
|
@@ -70,14 +70,13 @@ Choose your storage method based on your needs:
|
||||
# - SECURE_SITE=false # Set to true if you are using a reverse proxy
|
||||
# - DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US)
|
||||
# - PRESIGNED_URL_EXPIRATION=3600 # Duration in seconds for presigned URL expiration (optional, defaults to 3600 seconds / 1 hour)
|
||||
|
||||
# Download Memory Management (OPTIONAL - See Download Memory Management documentation)
|
||||
# - DOWNLOAD_MAX_CONCURRENT=5 # Maximum simultaneous downloads (auto-scales if not set)
|
||||
# - DOWNLOAD_MEMORY_THRESHOLD_MB=2048 # Memory threshold in MB before throttling (auto-scales if not set)
|
||||
# - DOWNLOAD_QUEUE_SIZE=25 # Maximum queue size for pending downloads (auto-scales if not set)
|
||||
# - DOWNLOAD_MIN_FILE_SIZE_GB=3.0 # Minimum file size in GB to activate memory management (default: 3.0)
|
||||
# - DOWNLOAD_AUTO_SCALE=true # Enable auto-scaling based on system memory (default: true)
|
||||
# - NODE_OPTIONS=--expose-gc # Enable garbage collection for large downloads (recommended for production)
|
||||
# - NEXT_PUBLIC_UPLOAD_CHUNK_SIZE_MB=100 # Chunk size in MB for large file uploads (OPTIONAL - auto-calculates if not set)
|
||||
volumes:
|
||||
- palmr_data:/app/server
|
||||
|
||||
@@ -126,8 +125,6 @@ Choose your storage method based on your needs:
|
||||
# - SECURE_SITE=false # Set to true if you are using a reverse proxy
|
||||
# - DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US)
|
||||
# - PRESIGNED_URL_EXPIRATION=3600 # Duration in seconds for presigned URL expiration (optional, defaults to 3600 seconds / 1 hour)
|
||||
|
||||
# Download Memory Management (OPTIONAL - See Download Memory Management documentation)
|
||||
# - DOWNLOAD_MAX_CONCURRENT=5 # Maximum simultaneous downloads (auto-scales if not set)
|
||||
# - DOWNLOAD_MEMORY_THRESHOLD_MB=2048 # Memory threshold in MB before throttling (auto-scales if not set)
|
||||
# - DOWNLOAD_QUEUE_SIZE=25 # Maximum queue size for pending downloads (auto-scales if not set)
|
||||
@@ -155,21 +152,33 @@ Choose your storage method based on your needs:
|
||||
|
||||
Customize Palmr's behavior with these environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ENABLE_S3` | `false` | Enable S3-compatible storage backends |
|
||||
| `S3_REJECT_UNAUTHORIZED` | `true` | Enable strict SSL certificate validation for S3 (set to `false` for self-signed certificates) |
|
||||
| `ENCRYPTION_KEY` | - | **Required when encryption is enabled**: 32+ character key for file encryption |
|
||||
| `DISABLE_FILESYSTEM_ENCRYPTION` | `true` | Disable file encryption for better performance (set to `false` to enable encryption) |
|
||||
| `SECURE_SITE` | `false` | Enable secure cookies for HTTPS/reverse proxy deployments |
|
||||
| `DEFAULT_LANGUAGE` | `en-US` | Default application language ([see available languages](/docs/3.2-beta/available-languages)) |
|
||||
| `PALMR_UID` | `1000` | User ID for container processes (helps with file permissions) |
|
||||
| `PALMR_GID` | `1000` | Group ID for container processes (helps with file permissions) |
|
||||
| `DOWNLOAD_MAX_CONCURRENT` | auto-scale | Maximum number of simultaneous downloads (see [Download Memory Management](/docs/3.2-beta/download-memory-management)) |
|
||||
| `DOWNLOAD_MEMORY_THRESHOLD_MB` | auto-scale | Memory threshold in MB before throttling |
|
||||
| `DOWNLOAD_QUEUE_SIZE` | auto-scale | Maximum queue size for pending downloads |
|
||||
| `DOWNLOAD_MIN_FILE_SIZE_GB` | `3.0` | Minimum file size in GB to activate memory management |
|
||||
| `DOWNLOAD_AUTO_SCALE` | `true` | Enable auto-scaling based on system memory |
|
||||
| Variable | Default | Description |
|
||||
| ---------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ENABLE_S3` | `false` | Enable S3-compatible storage backends |
|
||||
| `S3_ENDPOINT` | - | S3 server endpoint URL (required when using S3) |
|
||||
| `S3_PORT` | - | S3 server port (optional when using S3) |
|
||||
| `S3_USE_SSL` | - | Enable SSL for S3 connections (optional when using S3) |
|
||||
| `S3_ACCESS_KEY` | - | S3 access key for authentication (required when using S3) |
|
||||
| `S3_SECRET_KEY` | - | S3 secret key for authentication (required when using S3) |
|
||||
| `S3_REGION` | - | S3 region configuration (optional when using S3) |
|
||||
| `S3_BUCKET_NAME` | - | S3 bucket name for file storage (required when using S3) |
|
||||
| `S3_FORCE_PATH_STYLE` | `false` | Force path-style S3 URLs (optional when using S3) |
|
||||
| `S3_REJECT_UNAUTHORIZED` | `true` | Enable strict SSL certificate validation for S3 (set to `false` for self-signed certificates) |
|
||||
| `ENCRYPTION_KEY` | - | **Required when encryption is enabled**: 32+ character key for file encryption |
|
||||
| `DISABLE_FILESYSTEM_ENCRYPTION` | `true` | Disable file encryption for better performance (set to `false` to enable encryption) |
|
||||
| `PRESIGNED_URL_EXPIRATION` | `3600` | Duration in seconds for presigned URL expiration (applies to both filesystem and S3 storage) |
|
||||
| `CUSTOM_PATH` | - | Custom base path for disk space detection in manual installations with symlinks |
|
||||
| `SECURE_SITE` | `false` | Enable secure cookies for HTTPS/reverse proxy deployments |
|
||||
| `DEFAULT_LANGUAGE` | `en-US` | Default application language ([see available languages](/docs/3.2-beta/available-languages)) |
|
||||
| `PALMR_UID` | `1000` | User ID for container processes (helps with file permissions) |
|
||||
| `PALMR_GID` | `1000` | Group ID for container processes (helps with file permissions) |
|
||||
| `NODE_OPTIONS` | - | Node.js options (recommended: `--expose-gc` for garbage collection in production) |
|
||||
| `DOWNLOAD_MAX_CONCURRENT` | auto-scale | Maximum number of simultaneous downloads (see [Download Memory Management](/docs/3.2-beta/download-memory-management)) |
|
||||
| `DOWNLOAD_MEMORY_THRESHOLD_MB` | auto-scale | Memory threshold in MB before throttling |
|
||||
| `DOWNLOAD_QUEUE_SIZE` | auto-scale | Maximum queue size for pending downloads |
|
||||
| `DOWNLOAD_MIN_FILE_SIZE_GB` | `3.0` | Minimum file size in GB to activate memory management |
|
||||
| `NEXT_PUBLIC_UPLOAD_CHUNK_SIZE_MB` | auto-calculate | Chunk size in MB for large file uploads (see [Chunked Upload Configuration](/docs/3.2-beta/quick-start#chunked-upload-configuration)) |
|
||||
| `DOWNLOAD_AUTO_SCALE` | `true` | Enable auto-scaling based on system memory |
|
||||
|
||||
<Callout type="info">
|
||||
**Performance First**: Palmr runs without encryption by default for optimal speed and lower resource usage—perfect for
|
||||
@@ -307,6 +316,28 @@ environment:
|
||||
|
||||
**Note:** S3 storage handles encryption through your S3 provider's encryption features.
|
||||
|
||||
### Chunked Upload Configuration
|
||||
|
||||
Palmr supports configurable chunked uploading for large files. You can customize the chunk size by setting the following environment variable:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- NEXT_PUBLIC_UPLOAD_CHUNK_SIZE_MB=100 # Chunk size in MB
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
|
||||
- If `NEXT_PUBLIC_UPLOAD_CHUNK_SIZE_MB` is set, Palmr will use this value (in megabytes) as the chunk size for all file uploads that exceed this threshold.
|
||||
- If not set or left empty, Palmr automatically calculates optimal chunk sizes based on file size:
|
||||
- Files ≤ 100MB: uploaded without chunking
|
||||
- Files > 100MB and ≤ 1GB: 75MB chunks
|
||||
- Files > 1GB: 150MB chunks
|
||||
|
||||
**When to configure:**
|
||||
|
||||
- **Default (not set):** Recommended for most use cases. Palmr will intelligently determine the best chunk size.
|
||||
- **Custom value:** Set this if you have specific network conditions or want to optimize for your infrastructure (e.g., slower connections may benefit from smaller chunks like 50MB, while fast networks can handle larger chunks like 200MB, or the upload size per payload may be limited by a proxy like Cloudflare)
|
||||
|
||||
---
|
||||
|
||||
## Maintenance
|
||||
|
@@ -44,7 +44,7 @@ The central hub after login, providing an overview of recent activity, quick act
|
||||
|
||||
### Files list view
|
||||
|
||||
Comprehensive file browser displaying all uploaded files in a detailed list format with metadata, actions, and sorting options.
|
||||
Comprehensive file browser displaying all uploaded files in a detailed list format with metadata, actions, sorting options, and folder navigation.
|
||||
|
||||
<ZoomableImage
|
||||
src="/assets/v3/screenshots/files-list.png"
|
||||
@@ -53,7 +53,7 @@ Comprehensive file browser displaying all uploaded files in a detailed list form
|
||||
|
||||
### Files card view
|
||||
|
||||
Alternative file browser layout showing files as visual cards, perfect for quick browsing and visual file identification.
|
||||
Alternative file browser layout showing files as visual cards, perfect for quick browsing, visual file identification, and folder navigation.
|
||||
|
||||
<ZoomableImage
|
||||
src="/assets/v3/screenshots/files-card.png"
|
||||
@@ -73,7 +73,7 @@ File upload interface where users can drag and drop or select files to upload to
|
||||
|
||||
### Shares page
|
||||
|
||||
Management interface for all shared files and folders, showing share status, permissions, and access controls.
|
||||
Management interface for all shared files and folders, showing share status, permissions, and access controls for both individual files and folders.
|
||||
|
||||
<ZoomableImage
|
||||
src="/assets/v3/screenshots/shares.png"
|
||||
|
@@ -366,13 +366,11 @@ If none of these solutions work:
|
||||
```
|
||||
|
||||
2. **Check our documentation:**
|
||||
|
||||
- [UID/GID Configuration](/docs/3.0-beta/uid-gid-configuration)
|
||||
- [Quick Start Guide](/docs/3.0-beta/quick-start)
|
||||
- [API Reference](/docs/3.0-beta/api)
|
||||
|
||||
3. **Open an issue on GitHub:**
|
||||
|
||||
- Include your `docker-compose.yaml`
|
||||
- Include relevant log output
|
||||
- Describe your system (OS, Docker version, etc.)
|
||||
|
@@ -1,6 +1,3 @@
|
||||
{
|
||||
"pages": [
|
||||
"3.2-beta",
|
||||
"2.0.0-beta"
|
||||
]
|
||||
}
|
||||
"pages": ["3.2-beta", "2.0.0-beta"]
|
||||
}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "palmr-docs",
|
||||
"version": "3.2.0-beta",
|
||||
"version": "3.2.5-beta",
|
||||
"description": "Docs for Palmr",
|
||||
"private": true,
|
||||
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
|
||||
@@ -13,7 +13,7 @@
|
||||
"react",
|
||||
"typescript"
|
||||
],
|
||||
"license": "BSD-2-Clause",
|
||||
"license": "Apache-2.0",
|
||||
"packageManager": "pnpm@10.6.0",
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
|
4878
apps/docs/pnpm-lock.yaml
generated
4878
apps/docs/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -294,7 +294,7 @@ function FullWidthFooter() {
|
||||
<div className="flex items-center gap-1 text-sm max-w-7xl">
|
||||
<span>Powered by</span>
|
||||
<Link
|
||||
href="http://kyantech.com.br"
|
||||
href="https://github.com/kyantech"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
className="flex items-center hover:text-green-700 text-green-500 transition-colors font-light"
|
||||
|
@@ -6,7 +6,7 @@ export function Footer() {
|
||||
<div className="flex items-center gap-1 text-sm ">
|
||||
<span>Powered by</span>
|
||||
<Link
|
||||
href="http://kyantech.com.br"
|
||||
href="https://github.com/kyantech"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
className="flex items-center hover:text-green-700 text-green-500 transition-colors font-light"
|
||||
|
1
apps/server/.gitignore
vendored
1
apps/server/.gitignore
vendored
@@ -4,3 +4,4 @@ dist/*
|
||||
uploads/*
|
||||
temp-uploads/*
|
||||
prisma/*.db
|
||||
tsconfig.tsbuildinfo
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "palmr-api",
|
||||
"version": "3.2.0-beta",
|
||||
"version": "3.2.5-beta",
|
||||
"description": "API for Palmr",
|
||||
"private": true,
|
||||
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
|
||||
@@ -12,7 +12,7 @@
|
||||
"nodejs",
|
||||
"typescript"
|
||||
],
|
||||
"license": "BSD-2-Clause",
|
||||
"license": "Apache-2.0",
|
||||
"packageManager": "pnpm@10.6.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -25,7 +25,9 @@
|
||||
"format:check": "prettier . --check",
|
||||
"type-check": "npx tsc --noEmit",
|
||||
"validate": "pnpm lint && pnpm type-check",
|
||||
"db:seed": "ts-node prisma/seed.js"
|
||||
"db:seed": "ts-node prisma/seed.js",
|
||||
"cleanup:orphan-files": "tsx src/scripts/cleanup-orphan-files.ts",
|
||||
"cleanup:orphan-files:confirm": "tsx src/scripts/cleanup-orphan-files.ts --confirm"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "node prisma/seed.js"
|
||||
@@ -77,4 +79,4 @@
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
}
|
4498
apps/server/pnpm-lock.yaml
generated
4498
apps/server/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,287 +1,318 @@
|
||||
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")
|
||||
}
|
||||
|
@@ -17,6 +17,12 @@ const defaultConfigs = [
|
||||
type: "boolean",
|
||||
group: "general",
|
||||
},
|
||||
{
|
||||
key: "hideVersion",
|
||||
value: "false",
|
||||
type: "boolean",
|
||||
group: "general",
|
||||
},
|
||||
{
|
||||
key: "appDescription",
|
||||
value: "Secure and simple file sharing - Your personal cloud",
|
||||
|
@@ -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" },
|
||||
|
@@ -33,6 +33,7 @@ const envSchema = z.object({
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => (val ? parseFloat(val) : undefined)),
|
||||
CUSTOM_PATH: z.string().optional(),
|
||||
});
|
||||
|
||||
export const env = envSchema.parse(process.env);
|
||||
|
@@ -617,6 +617,11 @@ export class AuthProvidersService {
|
||||
return await this.linkProviderToExistingUser(existingUser, provider.id, String(externalId), userInfo);
|
||||
}
|
||||
|
||||
// Check if auto-registration is disabled
|
||||
if (provider.autoRegister === false) {
|
||||
throw new Error(`User registration via ${provider.displayName || provider.name} is disabled`);
|
||||
}
|
||||
|
||||
return await this.createNewUserWithProvider(userInfo, provider.id, String(externalId));
|
||||
}
|
||||
|
||||
|
@@ -1,9 +1,28 @@
|
||||
import * as fs from "fs";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
|
||||
import { env } from "../../env";
|
||||
import { prisma } from "../../shared/prisma";
|
||||
import {
|
||||
generateUniqueFileName,
|
||||
generateUniqueFileNameForRename,
|
||||
parseFileName,
|
||||
} from "../../utils/file-name-generator";
|
||||
import { getContentType } from "../../utils/mime-types";
|
||||
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,14 +91,28 @@ 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." });
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the filename and generate a unique name if there's a duplicate
|
||||
const { baseName, extension } = parseFileName(input.name);
|
||||
const uniqueName = await generateUniqueFileName(baseName, extension, userId, input.folderId);
|
||||
|
||||
const fileRecord = await prisma.file.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
name: uniqueName,
|
||||
description: input.description,
|
||||
extension: input.extension,
|
||||
size: BigInt(input.size),
|
||||
objectName: input.objectName,
|
||||
userId,
|
||||
folderId: input.folderId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -91,6 +124,7 @@ export class FileController {
|
||||
size: fileRecord.size.toString(),
|
||||
objectName: fileRecord.objectName,
|
||||
userId: fileRecord.userId,
|
||||
folderId: fileRecord.folderId,
|
||||
createdAt: fileRecord.createdAt,
|
||||
updatedAt: fileRecord.updatedAt,
|
||||
};
|
||||
@@ -146,9 +180,20 @@ export class FileController {
|
||||
});
|
||||
}
|
||||
|
||||
return reply.status(201).send({
|
||||
// Check for duplicate filename and provide the suggested unique name
|
||||
const { baseName, extension } = parseFileName(input.name);
|
||||
const uniqueName = await generateUniqueFileName(baseName, extension, userId, input.folderId);
|
||||
|
||||
// Include suggestedName in response if the name was changed
|
||||
const response: any = {
|
||||
message: "File checks succeeded.",
|
||||
});
|
||||
};
|
||||
|
||||
if (uniqueName !== input.name) {
|
||||
response.suggestedName = uniqueName;
|
||||
}
|
||||
|
||||
return reply.status(201).send(response);
|
||||
} catch (error: any) {
|
||||
console.error("Error in checkFile:", error);
|
||||
return reply.status(400).send({ error: error.message });
|
||||
@@ -157,10 +202,10 @@ export class FileController {
|
||||
|
||||
async getDownloadUrl(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const { objectName: encodedObjectName } = request.params as {
|
||||
const { objectName, password } = request.query as {
|
||||
objectName: string;
|
||||
password?: string;
|
||||
};
|
||||
const objectName = decodeURIComponent(encodedObjectName);
|
||||
|
||||
if (!objectName) {
|
||||
return reply.status(400).send({ error: "The 'objectName' parameter is required." });
|
||||
@@ -171,6 +216,52 @@ export class FileController {
|
||||
if (!fileRecord) {
|
||||
return reply.status(404).send({ error: "File not found." });
|
||||
}
|
||||
|
||||
let hasAccess = false;
|
||||
|
||||
// Don't log raw passwords. Log only whether a password was provided (for debugging access flow).
|
||||
console.log(`Requested file access for object="${objectName}" passwordProvided=${password ? true : false}`);
|
||||
|
||||
const shares = await prisma.share.findMany({
|
||||
where: {
|
||||
files: {
|
||||
some: {
|
||||
id: fileRecord.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
security: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const share of shares) {
|
||||
if (!share.security.password) {
|
||||
hasAccess = true;
|
||||
break;
|
||||
} else if (password) {
|
||||
const isPasswordValid = await bcrypt.compare(password, share.security.password);
|
||||
if (isPasswordValid) {
|
||||
hasAccess = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAccess) {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
const userId = (request as any).user?.userId;
|
||||
if (userId && fileRecord.userId === userId) {
|
||||
hasAccess = true;
|
||||
}
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
if (!hasAccess) {
|
||||
return reply.status(401).send({ error: "Unauthorized access to file." });
|
||||
}
|
||||
|
||||
const fileName = fileRecord.name;
|
||||
const expires = parseInt(env.PRESIGNED_URL_EXPIRATION);
|
||||
const url = await this.fileService.getPresignedGetUrl(objectName, expires, fileName);
|
||||
@@ -181,6 +272,118 @@ export class FileController {
|
||||
}
|
||||
}
|
||||
|
||||
async downloadFile(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const { objectName, password } = request.query as {
|
||||
objectName: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
if (!objectName) {
|
||||
return reply.status(400).send({ error: "The 'objectName' parameter is required." });
|
||||
}
|
||||
|
||||
const fileRecord = await prisma.file.findFirst({ where: { objectName } });
|
||||
|
||||
if (!fileRecord) {
|
||||
if (objectName.startsWith("reverse-shares/")) {
|
||||
const reverseShareFile = await prisma.reverseShareFile.findFirst({
|
||||
where: { objectName },
|
||||
include: {
|
||||
reverseShare: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!reverseShareFile) {
|
||||
return reply.status(404).send({ error: "File not found." });
|
||||
}
|
||||
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
const userId = (request as any).user?.userId;
|
||||
|
||||
if (!userId || reverseShareFile.reverseShare.creatorId !== userId) {
|
||||
return reply.status(401).send({ error: "Unauthorized access to file." });
|
||||
}
|
||||
} catch (err) {
|
||||
return reply.status(401).send({ error: "Unauthorized access to file." });
|
||||
}
|
||||
|
||||
const storageProvider = (this.fileService as any).storageProvider;
|
||||
const filePath = storageProvider.getFilePath(objectName);
|
||||
|
||||
const contentType = getContentType(reverseShareFile.name);
|
||||
const fileName = reverseShareFile.name;
|
||||
|
||||
reply.header("Content-Type", contentType);
|
||||
reply.header("Content-Disposition", `inline; filename="${encodeURIComponent(fileName)}"`);
|
||||
|
||||
const stream = fs.createReadStream(filePath);
|
||||
return reply.send(stream);
|
||||
}
|
||||
|
||||
return reply.status(404).send({ error: "File not found." });
|
||||
}
|
||||
|
||||
let hasAccess = false;
|
||||
|
||||
const shares = await prisma.share.findMany({
|
||||
where: {
|
||||
files: {
|
||||
some: {
|
||||
id: fileRecord.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
security: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const share of shares) {
|
||||
if (!share.security.password) {
|
||||
hasAccess = true;
|
||||
break;
|
||||
} else if (password) {
|
||||
const isPasswordValid = await bcrypt.compare(password, share.security.password);
|
||||
if (isPasswordValid) {
|
||||
hasAccess = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAccess) {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
const userId = (request as any).user?.userId;
|
||||
if (userId && fileRecord.userId === userId) {
|
||||
hasAccess = true;
|
||||
}
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
if (!hasAccess) {
|
||||
return reply.status(401).send({ error: "Unauthorized access to file." });
|
||||
}
|
||||
|
||||
const storageProvider = (this.fileService as any).storageProvider;
|
||||
const filePath = storageProvider.getFilePath(objectName);
|
||||
|
||||
const contentType = getContentType(fileRecord.name);
|
||||
const fileName = fileRecord.name;
|
||||
|
||||
reply.header("Content-Type", contentType);
|
||||
reply.header("Content-Disposition", `inline; filename="${encodeURIComponent(fileName)}"`);
|
||||
|
||||
const stream = fs.createReadStream(filePath);
|
||||
return reply.send(stream);
|
||||
} catch (error) {
|
||||
console.error("Error in downloadFile:", error);
|
||||
return reply.status(500).send({ error: "Internal server error." });
|
||||
}
|
||||
}
|
||||
|
||||
async listFiles(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
@@ -189,18 +392,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,
|
||||
}));
|
||||
@@ -265,6 +493,13 @@ export class FileController {
|
||||
return reply.status(403).send({ error: "Access denied." });
|
||||
}
|
||||
|
||||
// If renaming the file, check for duplicates and auto-rename if necessary
|
||||
if (updateData.name && updateData.name !== fileRecord.name) {
|
||||
const { baseName, extension } = parseFileName(updateData.name);
|
||||
const uniqueName = await generateUniqueFileNameForRename(baseName, extension, userId, fileRecord.folderId, id);
|
||||
updateData.name = uniqueName;
|
||||
}
|
||||
|
||||
const updatedFile = await prisma.file.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
@@ -278,6 +513,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 +527,131 @@ 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 });
|
||||
}
|
||||
}
|
||||
|
||||
async embedFile(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
|
||||
if (!id) {
|
||||
return reply.status(400).send({ error: "File ID is required." });
|
||||
}
|
||||
|
||||
const fileRecord = await prisma.file.findUnique({ where: { id } });
|
||||
|
||||
if (!fileRecord) {
|
||||
return reply.status(404).send({ error: "File not found." });
|
||||
}
|
||||
|
||||
const extension = fileRecord.extension.toLowerCase();
|
||||
const imageExts = ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico", "avif"];
|
||||
const videoExts = ["mp4", "webm", "ogg", "mov", "avi", "mkv", "flv", "wmv"];
|
||||
const audioExts = ["mp3", "wav", "ogg", "m4a", "flac", "aac", "wma"];
|
||||
|
||||
const isMedia = imageExts.includes(extension) || videoExts.includes(extension) || audioExts.includes(extension);
|
||||
|
||||
if (!isMedia) {
|
||||
return reply.status(403).send({
|
||||
error: "Embed is only allowed for images, videos, and audio files.",
|
||||
});
|
||||
}
|
||||
|
||||
const storageProvider = (this.fileService as any).storageProvider;
|
||||
const filePath = storageProvider.getFilePath(fileRecord.objectName);
|
||||
|
||||
const contentType = getContentType(fileRecord.name);
|
||||
const fileName = fileRecord.name;
|
||||
|
||||
reply.header("Content-Type", contentType);
|
||||
reply.header("Content-Disposition", `inline; filename="${encodeURIComponent(fileName)}"`);
|
||||
reply.header("Cache-Control", "public, max-age=31536000"); // Cache por 1 ano
|
||||
|
||||
const stream = fs.createReadStream(filePath);
|
||||
return reply.send(stream);
|
||||
} catch (error) {
|
||||
console.error("Error in embedFile:", error);
|
||||
return reply.status(500).send({ error: "Internal server error." });
|
||||
}
|
||||
}
|
||||
|
||||
private async getAllUserFilesRecursively(userId: string): Promise<any[]> {
|
||||
const rootFiles = await prisma.file.findMany({
|
||||
where: { userId, folderId: null },
|
||||
});
|
||||
|
||||
const rootFolders = await prisma.folder.findMany({
|
||||
where: { userId, parentId: null },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
let allFiles = [...rootFiles];
|
||||
|
||||
if (rootFolders.length > 0) {
|
||||
const { FolderService } = await import("../folder/service.js");
|
||||
const folderService = new FolderService();
|
||||
|
||||
for (const folder of rootFolders) {
|
||||
const folderFiles = await folderService.getAllFilesInFolder(folder.id, userId);
|
||||
allFiles = [...allFiles, ...folderFiles];
|
||||
}
|
||||
}
|
||||
|
||||
return allFiles;
|
||||
}
|
||||
}
|
||||
|
@@ -9,6 +9,7 @@ export const RegisterFileSchema = z.object({
|
||||
invalid_type_error: "O tamanho deve ser um número",
|
||||
}),
|
||||
objectName: z.string().min(1, "O objectName é obrigatório"),
|
||||
folderId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const CheckFileSchema = z.object({
|
||||
@@ -20,6 +21,7 @@ export const CheckFileSchema = z.object({
|
||||
invalid_type_error: "O tamanho deve ser um número",
|
||||
}),
|
||||
objectName: z.string().min(1, "O objectName é obrigatório"),
|
||||
folderId: z.string().optional(),
|
||||
});
|
||||
|
||||
export type RegisterFileInput = z.infer<typeof RegisterFileSchema>;
|
||||
@@ -30,4 +32,15 @@ export const UpdateFileSchema = z.object({
|
||||
description: z.string().optional().nullable().describe("The file description"),
|
||||
});
|
||||
|
||||
export const MoveFileSchema = z.object({
|
||||
folderId: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const ListFilesSchema = z.object({
|
||||
folderId: z.string().optional().describe("The folder ID"),
|
||||
recursive: z.string().optional().default("true").describe("Include files from subfolders"),
|
||||
});
|
||||
|
||||
export type UpdateFileInput = z.infer<typeof UpdateFileSchema>;
|
||||
export type MoveFileInput = z.infer<typeof MoveFileSchema>;
|
||||
export type ListFilesInput = z.infer<typeof ListFilesSchema>;
|
||||
|
@@ -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",
|
||||
@@ -104,15 +106,16 @@ export async function fileRoutes(app: FastifyInstance) {
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/files/:objectName/download",
|
||||
"/files/download-url",
|
||||
{
|
||||
schema: {
|
||||
tags: ["File"],
|
||||
operationId: "getDownloadUrl",
|
||||
summary: "Get Download URL",
|
||||
description: "Generates a pre-signed URL for downloading a private file",
|
||||
params: z.object({
|
||||
description: "Generates a pre-signed URL for downloading a file",
|
||||
querystring: z.object({
|
||||
objectName: z.string().min(1, "The objectName is required"),
|
||||
password: z.string().optional().describe("Share password if required"),
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
@@ -128,6 +131,46 @@ export async function fileRoutes(app: FastifyInstance) {
|
||||
fileController.getDownloadUrl.bind(fileController)
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/embed/:id",
|
||||
{
|
||||
schema: {
|
||||
tags: ["File"],
|
||||
operationId: "embedFile",
|
||||
summary: "Embed File (Public Access)",
|
||||
description:
|
||||
"Returns a media file (image/video/audio) for public embedding without authentication. Only works for media files.",
|
||||
params: z.object({
|
||||
id: z.string().min(1, "File ID is required").describe("The file ID"),
|
||||
}),
|
||||
response: {
|
||||
400: z.object({ error: z.string().describe("Error message") }),
|
||||
403: z.object({ error: z.string().describe("Error message - not a media file") }),
|
||||
404: z.object({ error: z.string().describe("Error message") }),
|
||||
500: z.object({ error: z.string().describe("Error message") }),
|
||||
},
|
||||
},
|
||||
},
|
||||
fileController.embedFile.bind(fileController)
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/files/download",
|
||||
{
|
||||
schema: {
|
||||
tags: ["File"],
|
||||
operationId: "downloadFile",
|
||||
summary: "Download File",
|
||||
description: "Downloads a file directly (returns file content)",
|
||||
querystring: z.object({
|
||||
objectName: z.string().min(1, "The objectName is required"),
|
||||
password: z.string().optional().describe("Share password if required"),
|
||||
}),
|
||||
},
|
||||
},
|
||||
fileController.downloadFile.bind(fileController)
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/files",
|
||||
{
|
||||
@@ -136,7 +179,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 +192,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 +206,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 +309,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)
|
||||
);
|
||||
}
|
||||
|
@@ -12,6 +12,20 @@ export class FilesystemController {
|
||||
private chunkManager = ChunkManager.getInstance();
|
||||
private memoryManager = DownloadMemoryManager.getInstance();
|
||||
|
||||
/**
|
||||
* Check if a character is valid in an HTTP token (RFC 2616)
|
||||
* Tokens can contain: alphanumeric and !#$%&'*+-.^_`|~
|
||||
* Must exclude separators: ()<>@,;:\"/[]?={} and space/tab
|
||||
*/
|
||||
private isTokenChar(char: string): boolean {
|
||||
const code = char.charCodeAt(0);
|
||||
// Basic ASCII range check
|
||||
if (code < 33 || code > 126) return false;
|
||||
// Exclude separator characters per RFC 2616
|
||||
const separators = '()<>@,;:\\"/[]?={} \t';
|
||||
return !separators.includes(char);
|
||||
}
|
||||
|
||||
private encodeFilenameForHeader(filename: string): string {
|
||||
if (!filename || filename.trim() === "") {
|
||||
return 'attachment; filename="download"';
|
||||
@@ -36,12 +50,10 @@ export class FilesystemController {
|
||||
return 'attachment; filename="download"';
|
||||
}
|
||||
|
||||
// Create ASCII-safe version with only valid token characters
|
||||
const asciiSafe = sanitized
|
||||
.split("")
|
||||
.filter((char) => {
|
||||
const code = char.charCodeAt(0);
|
||||
return code >= 32 && code <= 126;
|
||||
})
|
||||
.filter((char) => this.isTokenChar(char))
|
||||
.join("");
|
||||
|
||||
if (asciiSafe && asciiSafe.trim()) {
|
||||
@@ -72,7 +84,6 @@ export class FilesystemController {
|
||||
const result = await this.handleChunkedUpload(request, chunkMetadata, tokenData.objectName);
|
||||
|
||||
if (result.isComplete) {
|
||||
provider.consumeUploadToken(token);
|
||||
reply.status(200).send({
|
||||
message: "File uploaded successfully",
|
||||
objectName: result.finalPath,
|
||||
@@ -92,7 +103,6 @@ export class FilesystemController {
|
||||
}
|
||||
} else {
|
||||
await this.uploadFileStream(request, provider, tokenData.objectName);
|
||||
provider.consumeUploadToken(token);
|
||||
reply.status(200).send({ message: "File uploaded successfully" });
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -110,13 +120,22 @@ export class FilesystemController {
|
||||
const totalChunks = request.headers["x-total-chunks"] as string;
|
||||
const chunkSize = request.headers["x-chunk-size"] as string;
|
||||
const totalSize = request.headers["x-total-size"] as string;
|
||||
const fileName = request.headers["x-file-name"] as string;
|
||||
const encodedFileName = request.headers["x-file-name"] as string;
|
||||
const isLastChunk = request.headers["x-is-last-chunk"] as string;
|
||||
|
||||
if (!fileId || !chunkIndex || !totalChunks || !chunkSize || !totalSize || !fileName) {
|
||||
if (!fileId || !chunkIndex || !totalChunks || !chunkSize || !totalSize || !encodedFileName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Decode the base64-encoded filename to handle UTF-8 characters
|
||||
let fileName: string;
|
||||
try {
|
||||
fileName = decodeURIComponent(escape(Buffer.from(encodedFileName, "base64").toString("binary")));
|
||||
} catch (error) {
|
||||
// Fallback to the encoded value if decoding fails (for backward compatibility)
|
||||
fileName = encodedFileName;
|
||||
}
|
||||
|
||||
const metadata = {
|
||||
fileId,
|
||||
chunkIndex: parseInt(chunkIndex, 10),
|
||||
@@ -182,6 +201,17 @@ export class FilesystemController {
|
||||
}
|
||||
|
||||
const filePath = provider.getFilePath(tokenData.objectName);
|
||||
|
||||
const fileExists = await provider.fileExists(tokenData.objectName);
|
||||
if (!fileExists) {
|
||||
console.error(`[DOWNLOAD] File not found: ${tokenData.objectName}`);
|
||||
return reply.status(404).send({
|
||||
error: "File not found",
|
||||
message:
|
||||
"The requested file does not exist on the server. It may have been deleted or the upload was incomplete.",
|
||||
});
|
||||
}
|
||||
|
||||
const stats = await fs.promises.stat(filePath);
|
||||
const fileSize = stats.size;
|
||||
const fileName = tokenData.fileName || "download";
|
||||
@@ -239,8 +269,6 @@ export class FilesystemController {
|
||||
reply.header("Content-Length", fileSize);
|
||||
await this.downloadFileStream(reply, provider, tokenData.objectName, downloadId);
|
||||
}
|
||||
|
||||
provider.consumeDownloadToken(token);
|
||||
} finally {
|
||||
this.memoryManager.endDownload(downloadId);
|
||||
}
|
||||
|
396
apps/server/src/modules/folder/controller.ts
Normal file
396
apps/server/src/modules/folder/controller.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
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" });
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicates and auto-rename if necessary
|
||||
const { generateUniqueFolderName } = await import("../../utils/file-name-generator.js");
|
||||
const uniqueName = await generateUniqueFolderName(input.name, userId, input.parentId);
|
||||
|
||||
const folderRecord = await prisma.folder.create({
|
||||
data: {
|
||||
name: uniqueName,
|
||||
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 renaming the folder, check for duplicates and auto-rename if necessary
|
||||
if (updateData.name && updateData.name !== folderRecord.name) {
|
||||
const { generateUniqueFolderName } = await import("../../utils/file-name-generator.js");
|
||||
const uniqueName = await generateUniqueFolderName(updateData.name, userId, folderRecord.parentId, id);
|
||||
updateData.name = uniqueName;
|
||||
}
|
||||
|
||||
const updatedFolder = await prisma.folder.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
files: true,
|
||||
children: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const totalSize = await this.folderService.calculateFolderSize(updatedFolder.id, userId);
|
||||
|
||||
const folderResponse = {
|
||||
id: updatedFolder.id,
|
||||
name: updatedFolder.name,
|
||||
description: updatedFolder.description,
|
||||
objectName: updatedFolder.objectName,
|
||||
parentId: updatedFolder.parentId,
|
||||
userId: updatedFolder.userId,
|
||||
createdAt: updatedFolder.createdAt,
|
||||
updatedAt: updatedFolder.updatedAt,
|
||||
totalSize: totalSize.toString(),
|
||||
_count: updatedFolder._count,
|
||||
};
|
||||
|
||||
return reply.send({
|
||||
folder: folderResponse,
|
||||
message: "Folder updated successfully.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error in updateFolder:", error);
|
||||
return reply.status(400).send({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async moveFolder(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
const userId = (request as any).user?.userId;
|
||||
|
||||
if (!userId) {
|
||||
return reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
|
||||
}
|
||||
|
||||
const { id } = request.params as { id: string };
|
||||
const body = request.body as any;
|
||||
|
||||
const input = {
|
||||
parentId: body.parentId === undefined ? null : body.parentId,
|
||||
};
|
||||
|
||||
const validatedInput = MoveFolderSchema.parse(input);
|
||||
|
||||
const existingFolder = await prisma.folder.findFirst({
|
||||
where: { id, userId },
|
||||
});
|
||||
|
||||
if (!existingFolder) {
|
||||
return reply.status(404).send({ error: "Folder not found." });
|
||||
}
|
||||
|
||||
if (validatedInput.parentId) {
|
||||
const parentFolder = await prisma.folder.findFirst({
|
||||
where: { id: validatedInput.parentId, userId },
|
||||
});
|
||||
if (!parentFolder) {
|
||||
return reply.status(400).send({ error: "Parent folder not found or access denied" });
|
||||
}
|
||||
|
||||
if (await this.isDescendantOf(validatedInput.parentId, id, userId)) {
|
||||
return reply.status(400).send({ error: "Cannot move a folder into itself or its subfolders" });
|
||||
}
|
||||
}
|
||||
|
||||
const updatedFolder = await prisma.folder.update({
|
||||
where: { id },
|
||||
data: { parentId: validatedInput.parentId },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
files: true,
|
||||
children: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const totalSize = await this.folderService.calculateFolderSize(updatedFolder.id, userId);
|
||||
|
||||
const folderResponse = {
|
||||
id: updatedFolder.id,
|
||||
name: updatedFolder.name,
|
||||
description: updatedFolder.description,
|
||||
objectName: updatedFolder.objectName,
|
||||
parentId: updatedFolder.parentId,
|
||||
userId: updatedFolder.userId,
|
||||
createdAt: updatedFolder.createdAt,
|
||||
updatedAt: updatedFolder.updatedAt,
|
||||
totalSize: totalSize.toString(),
|
||||
_count: updatedFolder._count,
|
||||
};
|
||||
|
||||
return reply.send({
|
||||
folder: folderResponse,
|
||||
message: "Folder moved successfully.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error in moveFolder:", error);
|
||||
const statusCode = error.message === "Folder not found" ? 404 : 400;
|
||||
return reply.status(statusCode).send({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFolder(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
const { id } = request.params as { id: string };
|
||||
if (!id) {
|
||||
return reply.status(400).send({ error: "The 'id' parameter is required." });
|
||||
}
|
||||
|
||||
const folderRecord = await prisma.folder.findUnique({ where: { id } });
|
||||
if (!folderRecord) {
|
||||
return reply.status(404).send({ error: "Folder not found." });
|
||||
}
|
||||
|
||||
const userId = (request as any).user?.userId;
|
||||
if (folderRecord.userId !== userId) {
|
||||
return reply.status(403).send({ error: "Access denied." });
|
||||
}
|
||||
|
||||
await this.folderService.deleteObject(folderRecord.objectName);
|
||||
|
||||
await prisma.folder.delete({ where: { id } });
|
||||
|
||||
return reply.send({ message: "Folder deleted successfully." });
|
||||
} catch (error) {
|
||||
console.error("Error in deleteFolder:", error);
|
||||
return reply.status(500).send({ error: "Internal server error." });
|
||||
}
|
||||
}
|
||||
|
||||
private async isDescendantOf(potentialDescendantId: string, ancestorId: string, userId: string): Promise<boolean> {
|
||||
let currentId: string | null = potentialDescendantId;
|
||||
|
||||
while (currentId) {
|
||||
if (currentId === ancestorId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const folder: { parentId: string | null } | null = await prisma.folder.findFirst({
|
||||
where: { id: currentId, userId },
|
||||
});
|
||||
|
||||
if (!folder) break;
|
||||
currentId = folder.parentId;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
56
apps/server/src/modules/folder/dto.ts
Normal file
56
apps/server/src/modules/folder/dto.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const RegisterFolderSchema = z.object({
|
||||
name: z.string().min(1, "O nome da pasta é obrigatório"),
|
||||
description: z.string().optional(),
|
||||
objectName: z.string().min(1, "O objectName é obrigatório"),
|
||||
parentId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const UpdateFolderSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
export const MoveFolderSchema = z.object({
|
||||
parentId: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const FolderResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
parentId: z.string().nullable(),
|
||||
userId: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
totalSize: z
|
||||
.bigint()
|
||||
.transform((val) => val.toString())
|
||||
.optional(),
|
||||
_count: z
|
||||
.object({
|
||||
files: z.number(),
|
||||
children: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const CheckFolderSchema = z.object({
|
||||
name: z.string().min(1, "O nome da pasta é obrigatório"),
|
||||
description: z.string().optional(),
|
||||
objectName: z.string().min(1, "O objectName é obrigatório"),
|
||||
parentId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ListFoldersSchema = z.object({
|
||||
parentId: z.string().optional(),
|
||||
recursive: z.string().optional().default("true"),
|
||||
});
|
||||
|
||||
export type RegisterFolderInput = z.infer<typeof RegisterFolderSchema>;
|
||||
export type UpdateFolderInput = z.infer<typeof UpdateFolderSchema>;
|
||||
export type MoveFolderInput = z.infer<typeof MoveFolderSchema>;
|
||||
export type CheckFolderInput = z.infer<typeof CheckFolderSchema>;
|
||||
export type ListFoldersInput = z.infer<typeof ListFoldersSchema>;
|
||||
export type FolderResponse = z.infer<typeof FolderResponseSchema>;
|
245
apps/server/src/modules/folder/routes.ts
Normal file
245
apps/server/src/modules/folder/routes.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
|
||||
import { FolderController } from "./controller";
|
||||
import {
|
||||
CheckFolderSchema,
|
||||
FolderResponseSchema,
|
||||
ListFoldersSchema,
|
||||
MoveFolderSchema,
|
||||
RegisterFolderSchema,
|
||||
UpdateFolderSchema,
|
||||
} from "./dto";
|
||||
|
||||
export async function folderRoutes(app: FastifyInstance) {
|
||||
const folderController = new FolderController();
|
||||
|
||||
const preValidation = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
reply.status(401).send({ error: "Token inválido ou ausente." });
|
||||
}
|
||||
};
|
||||
|
||||
app.post(
|
||||
"/folders",
|
||||
{
|
||||
schema: {
|
||||
tags: ["Folder"],
|
||||
operationId: "registerFolder",
|
||||
summary: "Register Folder Metadata",
|
||||
description: "Registers folder metadata in the database",
|
||||
body: RegisterFolderSchema,
|
||||
response: {
|
||||
201: z.object({
|
||||
folder: z.object({
|
||||
id: z.string().describe("The folder ID"),
|
||||
name: z.string().describe("The folder name"),
|
||||
description: z.string().nullable().describe("The folder description"),
|
||||
parentId: z.string().nullable().describe("The parent folder ID"),
|
||||
userId: z.string().describe("The user ID"),
|
||||
createdAt: z.date().describe("The folder creation date"),
|
||||
updatedAt: z.date().describe("The folder last update date"),
|
||||
totalSize: z.string().optional().describe("The total size of the folder"),
|
||||
_count: z
|
||||
.object({
|
||||
files: z.number().describe("Number of files in folder"),
|
||||
children: z.number().describe("Number of subfolders"),
|
||||
})
|
||||
.optional()
|
||||
.describe("Count statistics"),
|
||||
}),
|
||||
message: z.string().describe("The folder registration message"),
|
||||
}),
|
||||
400: z.object({ error: z.string().describe("Error message") }),
|
||||
401: z.object({ error: z.string().describe("Error message") }),
|
||||
},
|
||||
},
|
||||
},
|
||||
folderController.registerFolder.bind(folderController)
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/folders/check",
|
||||
{
|
||||
preValidation,
|
||||
schema: {
|
||||
tags: ["Folder"],
|
||||
operationId: "checkFolder",
|
||||
summary: "Check Folder validity",
|
||||
description: "Checks if the folder meets all requirements",
|
||||
body: CheckFolderSchema,
|
||||
response: {
|
||||
201: z.object({
|
||||
message: z.string().describe("The folder check success message"),
|
||||
}),
|
||||
400: z.object({
|
||||
error: z.string().describe("Error message"),
|
||||
code: z.string().optional().describe("Error code"),
|
||||
details: z.string().optional().describe("Error details"),
|
||||
}),
|
||||
401: z.object({
|
||||
error: z.string().describe("Error message"),
|
||||
code: z.string().optional().describe("Error code"),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
folderController.checkFolder.bind(folderController)
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/folders",
|
||||
{
|
||||
preValidation,
|
||||
schema: {
|
||||
tags: ["Folder"],
|
||||
operationId: "listFolders",
|
||||
summary: "List Folders",
|
||||
description: "Lists user folders recursively by default, optionally filtered by folder",
|
||||
querystring: ListFoldersSchema,
|
||||
response: {
|
||||
200: z.object({
|
||||
folders: z.array(
|
||||
z.object({
|
||||
id: z.string().describe("The folder ID"),
|
||||
name: z.string().describe("The folder name"),
|
||||
description: z.string().nullable().describe("The folder description"),
|
||||
parentId: z.string().nullable().describe("The parent folder ID"),
|
||||
userId: z.string().describe("The user ID"),
|
||||
createdAt: z.date().describe("The folder creation date"),
|
||||
updatedAt: z.date().describe("The folder last update date"),
|
||||
totalSize: z.string().optional().describe("The total size of the folder"),
|
||||
_count: z
|
||||
.object({
|
||||
files: z.number().describe("Number of files in folder"),
|
||||
children: z.number().describe("Number of subfolders"),
|
||||
})
|
||||
.optional()
|
||||
.describe("Count statistics"),
|
||||
})
|
||||
),
|
||||
}),
|
||||
500: z.object({ error: z.string().describe("Error message") }),
|
||||
},
|
||||
},
|
||||
},
|
||||
folderController.listFolders.bind(folderController)
|
||||
);
|
||||
|
||||
app.patch(
|
||||
"/folders/:id",
|
||||
{
|
||||
preValidation,
|
||||
schema: {
|
||||
tags: ["Folder"],
|
||||
operationId: "updateFolder",
|
||||
summary: "Update Folder Metadata",
|
||||
description: "Updates folder metadata in the database",
|
||||
params: z.object({
|
||||
id: z.string().min(1, "The folder id is required").describe("The folder ID"),
|
||||
}),
|
||||
body: UpdateFolderSchema,
|
||||
response: {
|
||||
200: z.object({
|
||||
folder: z.object({
|
||||
id: z.string().describe("The folder ID"),
|
||||
name: z.string().describe("The folder name"),
|
||||
description: z.string().nullable().describe("The folder description"),
|
||||
parentId: z.string().nullable().describe("The parent folder ID"),
|
||||
userId: z.string().describe("The user ID"),
|
||||
createdAt: z.date().describe("The folder creation date"),
|
||||
updatedAt: z.date().describe("The folder last update date"),
|
||||
totalSize: z.string().optional().describe("The total size of the folder"),
|
||||
_count: z
|
||||
.object({
|
||||
files: z.number().describe("Number of files in folder"),
|
||||
children: z.number().describe("Number of subfolders"),
|
||||
})
|
||||
.optional()
|
||||
.describe("Count statistics"),
|
||||
}),
|
||||
message: z.string().describe("Success message"),
|
||||
}),
|
||||
400: z.object({ error: z.string().describe("Error message") }),
|
||||
401: z.object({ error: z.string().describe("Error message") }),
|
||||
403: z.object({ error: z.string().describe("Error message") }),
|
||||
404: z.object({ error: z.string().describe("Error message") }),
|
||||
},
|
||||
},
|
||||
},
|
||||
folderController.updateFolder.bind(folderController)
|
||||
);
|
||||
|
||||
app.put(
|
||||
"/folders/:id/move",
|
||||
{
|
||||
preValidation,
|
||||
schema: {
|
||||
tags: ["Folder"],
|
||||
operationId: "moveFolder",
|
||||
summary: "Move Folder",
|
||||
description: "Moves a folder to a different parent folder",
|
||||
params: z.object({
|
||||
id: z.string().min(1, "The folder id is required").describe("The folder ID"),
|
||||
}),
|
||||
body: MoveFolderSchema,
|
||||
response: {
|
||||
200: z.object({
|
||||
folder: z.object({
|
||||
id: z.string().describe("The folder ID"),
|
||||
name: z.string().describe("The folder name"),
|
||||
description: z.string().nullable().describe("The folder description"),
|
||||
parentId: z.string().nullable().describe("The parent folder ID"),
|
||||
userId: z.string().describe("The user ID"),
|
||||
createdAt: z.date().describe("The folder creation date"),
|
||||
updatedAt: z.date().describe("The folder last update date"),
|
||||
totalSize: z.string().optional().describe("The total size of the folder"),
|
||||
_count: z
|
||||
.object({
|
||||
files: z.number().describe("Number of files in folder"),
|
||||
children: z.number().describe("Number of subfolders"),
|
||||
})
|
||||
.optional()
|
||||
.describe("Count statistics"),
|
||||
}),
|
||||
message: z.string().describe("Success message"),
|
||||
}),
|
||||
400: z.object({ error: z.string().describe("Error message") }),
|
||||
401: z.object({ error: z.string().describe("Error message") }),
|
||||
403: z.object({ error: z.string().describe("Error message") }),
|
||||
404: z.object({ error: z.string().describe("Error message") }),
|
||||
},
|
||||
},
|
||||
},
|
||||
folderController.moveFolder.bind(folderController)
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/folders/:id",
|
||||
{
|
||||
preValidation,
|
||||
schema: {
|
||||
tags: ["Folder"],
|
||||
operationId: "deleteFolder",
|
||||
summary: "Delete Folder",
|
||||
description: "Deletes a folder and all its contents",
|
||||
params: z.object({
|
||||
id: z.string().min(1, "The folder id is required").describe("The folder ID"),
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
message: z.string().describe("The folder deletion message"),
|
||||
}),
|
||||
400: z.object({ error: z.string().describe("Error message") }),
|
||||
401: z.object({ error: z.string().describe("Error message") }),
|
||||
404: z.object({ error: z.string().describe("Error message") }),
|
||||
500: z.object({ error: z.string().describe("Error message") }),
|
||||
},
|
||||
},
|
||||
},
|
||||
folderController.deleteFolder.bind(folderController)
|
||||
);
|
||||
}
|
93
apps/server/src/modules/folder/service.ts
Normal file
93
apps/server/src/modules/folder/service.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { isS3Enabled } from "../../config/storage.config";
|
||||
import { FilesystemStorageProvider } from "../../providers/filesystem-storage.provider";
|
||||
import { S3StorageProvider } from "../../providers/s3-storage.provider";
|
||||
import { prisma } from "../../shared/prisma";
|
||||
import { StorageProvider } from "../../types/storage";
|
||||
|
||||
export class FolderService {
|
||||
private storageProvider: StorageProvider;
|
||||
|
||||
constructor() {
|
||||
if (isS3Enabled) {
|
||||
this.storageProvider = new S3StorageProvider();
|
||||
} else {
|
||||
this.storageProvider = FilesystemStorageProvider.getInstance();
|
||||
}
|
||||
}
|
||||
|
||||
async getPresignedPutUrl(objectName: string, expires: number): Promise<string> {
|
||||
try {
|
||||
return await this.storageProvider.getPresignedPutUrl(objectName, expires);
|
||||
} catch (err) {
|
||||
console.error("Erro no presignedPutObject:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async getPresignedGetUrl(objectName: string, expires: number, folderName?: string): Promise<string> {
|
||||
try {
|
||||
return await this.storageProvider.getPresignedGetUrl(objectName, expires, folderName);
|
||||
} catch (err) {
|
||||
console.error("Erro no presignedGetObject:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteObject(objectName: string): Promise<void> {
|
||||
try {
|
||||
await this.storageProvider.deleteObject(objectName);
|
||||
} catch (err) {
|
||||
console.error("Erro no removeObject:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
isFilesystemMode(): boolean {
|
||||
return !isS3Enabled;
|
||||
}
|
||||
|
||||
async getAllFilesInFolder(folderId: string, userId: string, basePath: string = ""): Promise<any[]> {
|
||||
const files = await prisma.file.findMany({
|
||||
where: { folderId, userId },
|
||||
});
|
||||
|
||||
const subfolders = await prisma.folder.findMany({
|
||||
where: { parentId: folderId, userId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
|
||||
let allFiles = files.map((file: any) => ({
|
||||
...file,
|
||||
relativePath: basePath + file.name,
|
||||
}));
|
||||
|
||||
for (const subfolder of subfolders) {
|
||||
const subfolderPath = basePath + subfolder.name + "/";
|
||||
const subfolderFiles = await this.getAllFilesInFolder(subfolder.id, userId, subfolderPath);
|
||||
allFiles = [...allFiles, ...subfolderFiles];
|
||||
}
|
||||
|
||||
return allFiles;
|
||||
}
|
||||
|
||||
async calculateFolderSize(folderId: string, userId: string): Promise<bigint> {
|
||||
const files = await prisma.file.findMany({
|
||||
where: { folderId, userId },
|
||||
select: { size: true },
|
||||
});
|
||||
|
||||
const subfolders = await prisma.folder.findMany({
|
||||
where: { parentId: folderId, userId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
let totalSize = files.reduce((sum, file) => sum + file.size, BigInt(0));
|
||||
|
||||
for (const subfolder of subfolders) {
|
||||
const subfolderSize = await this.calculateFolderSize(subfolder.id, userId);
|
||||
totalSize += subfolderSize;
|
||||
}
|
||||
|
||||
return totalSize;
|
||||
}
|
||||
}
|
@@ -536,4 +536,17 @@ export class ReverseShareController {
|
||||
return reply.status(500).send({ error: "Internal server error" });
|
||||
}
|
||||
}
|
||||
|
||||
async getReverseShareMetadataByAlias(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const { alias } = request.params as { alias: string };
|
||||
const metadata = await this.reverseShareService.getReverseShareMetadataByAlias(alias);
|
||||
return reply.send(metadata);
|
||||
} catch (error: any) {
|
||||
if (error.message === "Reverse share not found") {
|
||||
return reply.status(404).send({ error: error.message });
|
||||
}
|
||||
return reply.status(400).send({ error: error.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -592,4 +592,32 @@ export async function reverseShareRoutes(app: FastifyInstance) {
|
||||
},
|
||||
reverseShareController.copyFileToUserFiles.bind(reverseShareController)
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/reverse-shares/alias/:alias/metadata",
|
||||
{
|
||||
schema: {
|
||||
tags: ["Reverse Share"],
|
||||
operationId: "getReverseShareMetadataByAlias",
|
||||
summary: "Get reverse share metadata by alias for Open Graph",
|
||||
description: "Get lightweight metadata for a reverse share by alias, used for social media previews",
|
||||
params: z.object({
|
||||
alias: z.string().describe("Alias of the reverse share"),
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
name: z.string().nullable(),
|
||||
description: z.string().nullable(),
|
||||
totalFiles: z.number(),
|
||||
hasPassword: z.boolean(),
|
||||
isExpired: z.boolean(),
|
||||
isInactive: z.boolean(),
|
||||
maxFiles: z.number().nullable(),
|
||||
}),
|
||||
404: z.object({ error: z.string() }),
|
||||
},
|
||||
},
|
||||
},
|
||||
reverseShareController.getReverseShareMetadataByAlias.bind(reverseShareController)
|
||||
);
|
||||
}
|
||||
|
@@ -773,4 +773,30 @@ export class ReverseShareService {
|
||||
updatedAt: file.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async getReverseShareMetadataByAlias(alias: string) {
|
||||
const reverseShare = await this.reverseShareRepository.findByAlias(alias);
|
||||
if (!reverseShare) {
|
||||
throw new Error("Reverse share not found");
|
||||
}
|
||||
|
||||
// Check if reverse share is expired
|
||||
const isExpired = reverseShare.expiration && new Date(reverseShare.expiration) < new Date();
|
||||
|
||||
// Check if inactive
|
||||
const isInactive = !reverseShare.isActive;
|
||||
|
||||
const totalFiles = reverseShare.files?.length || 0;
|
||||
const hasPassword = !!reverseShare.password;
|
||||
|
||||
return {
|
||||
name: reverseShare.name,
|
||||
description: reverseShare.description,
|
||||
totalFiles,
|
||||
hasPassword,
|
||||
isExpired,
|
||||
isInactive,
|
||||
maxFiles: reverseShare.maxFiles,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@@ -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") {
|
||||
@@ -295,4 +295,17 @@ export class ShareController {
|
||||
return reply.status(400).send({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async getShareMetadataByAlias(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const { alias } = request.params as { alias: string };
|
||||
const metadata = await this.shareService.getShareMetadataByAlias(alias);
|
||||
return reply.send(metadata);
|
||||
} catch (error: any) {
|
||||
if (error.message === "Share not found") {
|
||||
return reply.status(404).send({ error: error.message });
|
||||
}
|
||||
return reply.status(400).send({ error: error.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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")),
|
||||
|
@@ -9,30 +9,42 @@ 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>;
|
||||
findShareByAlias(
|
||||
alias: string
|
||||
): Promise<(Share & { security: ShareSecurity; files: any[]; folders: any[]; recipients: any[] }) | null>;
|
||||
updateShare(id: string, data: Partial<Share>): Promise<Share>;
|
||||
updateShareSecurity(id: string, data: Partial<ShareSecurity>): Promise<ShareSecurity>;
|
||||
deleteShare(id: string): Promise<Share>;
|
||||
incrementViews(id: string): Promise<Share>;
|
||||
addFilesToShare(shareId: string, fileIds: string[]): Promise<void>;
|
||||
removeFilesFromShare(shareId: string, fileIds: string[]): Promise<void>;
|
||||
addFoldersToShare(shareId: string, folderIds: string[]): Promise<void>;
|
||||
removeFoldersFromShare(shareId: string, folderIds: string[]): Promise<void>;
|
||||
findFilesByIds(fileIds: string[]): Promise<any[]>;
|
||||
findFoldersByIds(folderIds: string[]): Promise<any[]>;
|
||||
addRecipients(shareId: string, emails: string[]): Promise<void>;
|
||||
removeRecipients(shareId: string, emails: string[]): Promise<void>;
|
||||
findSharesByUserId(userId: string): Promise<Share[]>;
|
||||
findSharesByUserId(
|
||||
userId: string
|
||||
): Promise<(Share & { security: ShareSecurity; files: any[]; folders: any[]; recipients: any[]; alias: any })[]>;
|
||||
}
|
||||
|
||||
export class PrismaShareRepository implements IShareRepository {
|
||||
async createShare(
|
||||
data: Omit<CreateShareInput, "password" | "maxViews"> & { securityId: string; creatorId: string }
|
||||
): Promise<Share> {
|
||||
const { files, recipients, expiration, ...shareData } = data;
|
||||
const { files, folders, recipients, expiration, ...shareData } = data;
|
||||
|
||||
const validFiles = (files ?? []).filter((id) => id && id.trim().length > 0);
|
||||
const validFolders = (folders ?? []).filter((id) => id && id.trim().length > 0);
|
||||
const validRecipients = (recipients ?? []).filter((email) => email && email.trim().length > 0);
|
||||
|
||||
return prisma.share.create({
|
||||
@@ -45,6 +57,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 +79,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,10 +111,63 @@ 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findShareByAlias(alias: string) {
|
||||
const shareAlias = await prisma.shareAlias.findUnique({
|
||||
where: { alias },
|
||||
include: {
|
||||
share: {
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return shareAlias?.share || null;
|
||||
}
|
||||
|
||||
async updateShare(id: string, data: Partial<Share>): Promise<Share> {
|
||||
return prisma.share.update({
|
||||
where: { id },
|
||||
@@ -121,6 +210,17 @@ export class PrismaShareRepository implements IShareRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async addFoldersToShare(shareId: string, folderIds: string[]): Promise<void> {
|
||||
await prisma.share.update({
|
||||
where: { id: shareId },
|
||||
data: {
|
||||
folders: {
|
||||
connect: folderIds.map((id) => ({ id })),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async removeFilesFromShare(shareId: string, fileIds: string[]): Promise<void> {
|
||||
await prisma.share.update({
|
||||
where: { id: shareId },
|
||||
@@ -132,6 +232,17 @@ export class PrismaShareRepository implements IShareRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async removeFoldersFromShare(shareId: string, folderIds: string[]): Promise<void> {
|
||||
await prisma.share.update({
|
||||
where: { id: shareId },
|
||||
data: {
|
||||
folders: {
|
||||
disconnect: folderIds.map((id) => ({ id })),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findFilesByIds(fileIds: string[]): Promise<any[]> {
|
||||
return prisma.file.findMany({
|
||||
where: {
|
||||
@@ -142,6 +253,16 @@ export class PrismaShareRepository implements IShareRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async findFoldersByIds(folderIds: string[]): Promise<any[]> {
|
||||
return prisma.folder.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: folderIds,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async addRecipients(shareId: string, emails: string[]): Promise<void> {
|
||||
await prisma.share.update({
|
||||
where: { id: shareId },
|
||||
@@ -178,6 +299,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,
|
||||
},
|
||||
|
@@ -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(
|
||||
@@ -347,4 +347,32 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
},
|
||||
shareController.notifyRecipients.bind(shareController)
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/shares/alias/:alias/metadata",
|
||||
{
|
||||
schema: {
|
||||
tags: ["Share"],
|
||||
operationId: "getShareMetadataByAlias",
|
||||
summary: "Get share metadata by alias for Open Graph",
|
||||
description: "Get lightweight metadata for a share by alias, used for social media previews",
|
||||
params: z.object({
|
||||
alias: z.string().describe("The share alias"),
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
name: z.string().nullable(),
|
||||
description: z.string().nullable(),
|
||||
totalFiles: z.number(),
|
||||
totalFolders: z.number(),
|
||||
hasPassword: z.boolean(),
|
||||
isExpired: z.boolean(),
|
||||
isMaxViewsReached: z.boolean(),
|
||||
}),
|
||||
404: z.object({ error: z.string() }),
|
||||
},
|
||||
},
|
||||
},
|
||||
shareController.getShareMetadataByAlias.bind(shareController)
|
||||
);
|
||||
}
|
||||
|
@@ -2,6 +2,7 @@ import bcrypt from "bcryptjs";
|
||||
|
||||
import { prisma } from "../../shared/prisma";
|
||||
import { EmailService } from "../email/service";
|
||||
import { FolderService } from "../folder/service";
|
||||
import { UserService } from "../user/service";
|
||||
import { CreateShareInput, ShareResponseSchema, UpdateShareInput } from "./dto";
|
||||
import { IShareRepository, PrismaShareRepository } from "./repository";
|
||||
@@ -11,8 +12,9 @@ export class ShareService {
|
||||
|
||||
private emailService = new EmailService();
|
||||
private userService = new UserService();
|
||||
private folderService = new FolderService();
|
||||
|
||||
private formatShareResponse(share: any) {
|
||||
private async formatShareResponse(share: any) {
|
||||
return {
|
||||
...share,
|
||||
createdAt: share.createdAt.toISOString(),
|
||||
@@ -36,6 +38,20 @@ export class ShareService {
|
||||
createdAt: file.createdAt.toISOString(),
|
||||
updatedAt: file.updatedAt.toISOString(),
|
||||
})) || [],
|
||||
folders:
|
||||
share.folders && share.folders.length > 0
|
||||
? await Promise.all(
|
||||
share.folders.map(async (folder: any) => {
|
||||
const totalSize = await this.folderService.calculateFolderSize(folder.id, folder.userId);
|
||||
return {
|
||||
...folder,
|
||||
totalSize: totalSize.toString(),
|
||||
createdAt: folder.createdAt.toISOString(),
|
||||
updatedAt: folder.updatedAt.toISOString(),
|
||||
};
|
||||
})
|
||||
)
|
||||
: [],
|
||||
recipients:
|
||||
share.recipients?.map((recipient: any) => ({
|
||||
...recipient,
|
||||
@@ -46,7 +62,37 @@ export class ShareService {
|
||||
}
|
||||
|
||||
async createShare(data: CreateShareInput, userId: string) {
|
||||
const { password, maxViews, ...shareData } = data;
|
||||
const { password, maxViews, files, folders, ...shareData } = data;
|
||||
|
||||
if (files && files.length > 0) {
|
||||
const existingFiles = await prisma.file.findMany({
|
||||
where: {
|
||||
id: { in: files },
|
||||
userId: userId,
|
||||
},
|
||||
});
|
||||
const notFoundFiles = files.filter((id) => !existingFiles.some((file) => file.id === id));
|
||||
if (notFoundFiles.length > 0) {
|
||||
throw new Error(`Files not found or access denied: ${notFoundFiles.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (folders && folders.length > 0) {
|
||||
const existingFolders = await prisma.folder.findMany({
|
||||
where: {
|
||||
id: { in: folders },
|
||||
userId: userId,
|
||||
},
|
||||
});
|
||||
const notFoundFolders = folders.filter((id) => !existingFolders.some((folder) => folder.id === id));
|
||||
if (notFoundFolders.length > 0) {
|
||||
throw new Error(`Folders not found or access denied: ${notFoundFolders.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
if ((!files || files.length === 0) && (!folders || folders.length === 0)) {
|
||||
throw new Error("At least one file or folder must be selected to create a share");
|
||||
}
|
||||
|
||||
const security = await prisma.shareSecurity.create({
|
||||
data: {
|
||||
@@ -57,12 +103,14 @@ export class ShareService {
|
||||
|
||||
const share = await this.shareRepository.createShare({
|
||||
...shareData,
|
||||
files,
|
||||
folders,
|
||||
securityId: security.id,
|
||||
creatorId: userId,
|
||||
});
|
||||
|
||||
const shareWithRelations = await this.shareRepository.findShareById(share.id);
|
||||
return ShareResponseSchema.parse(this.formatShareResponse(shareWithRelations));
|
||||
return ShareResponseSchema.parse(await this.formatShareResponse(shareWithRelations));
|
||||
}
|
||||
|
||||
async getShare(shareId: string, password?: string, userId?: string) {
|
||||
@@ -73,7 +121,7 @@ export class ShareService {
|
||||
}
|
||||
|
||||
if (userId && share.creatorId === userId) {
|
||||
return ShareResponseSchema.parse(this.formatShareResponse(share));
|
||||
return ShareResponseSchema.parse(await this.formatShareResponse(share));
|
||||
}
|
||||
|
||||
if (share.expiration && new Date() > new Date(share.expiration)) {
|
||||
@@ -98,7 +146,7 @@ export class ShareService {
|
||||
await this.shareRepository.incrementViews(shareId);
|
||||
|
||||
const updatedShare = await this.shareRepository.findShareById(shareId);
|
||||
return ShareResponseSchema.parse(this.formatShareResponse(updatedShare));
|
||||
return ShareResponseSchema.parse(await this.formatShareResponse(updatedShare));
|
||||
}
|
||||
|
||||
async updateShare(shareId: string, data: Omit<UpdateShareInput, "id">, userId: string) {
|
||||
@@ -136,7 +184,7 @@ export class ShareService {
|
||||
});
|
||||
const shareWithRelations = await this.shareRepository.findShareById(shareId);
|
||||
|
||||
return this.formatShareResponse(shareWithRelations);
|
||||
return await this.formatShareResponse(shareWithRelations);
|
||||
}
|
||||
|
||||
async deleteShare(id: string) {
|
||||
@@ -172,12 +220,12 @@ export class ShareService {
|
||||
return deletedShare;
|
||||
});
|
||||
|
||||
return ShareResponseSchema.parse(this.formatShareResponse(deleted));
|
||||
return ShareResponseSchema.parse(await this.formatShareResponse(deleted));
|
||||
}
|
||||
|
||||
async listUserShares(userId: string) {
|
||||
const shares = await this.shareRepository.findSharesByUserId(userId);
|
||||
return shares.map((share) => this.formatShareResponse(share));
|
||||
return await Promise.all(shares.map(async (share) => await this.formatShareResponse(share)));
|
||||
}
|
||||
|
||||
async updateSharePassword(shareId: string, userId: string, password: string | null) {
|
||||
@@ -195,10 +243,10 @@ export class ShareService {
|
||||
});
|
||||
|
||||
const updated = await this.shareRepository.findShareById(shareId);
|
||||
return ShareResponseSchema.parse(this.formatShareResponse(updated));
|
||||
return ShareResponseSchema.parse(await this.formatShareResponse(updated));
|
||||
}
|
||||
|
||||
async addFilesToShare(shareId: string, userId: string, fileIds: string[]) {
|
||||
async addItemsToShare(shareId: string, userId: string, fileIds: string[], folderIds: string[]) {
|
||||
const share = await this.shareRepository.findShareById(shareId);
|
||||
if (!share) {
|
||||
throw new Error("Share not found");
|
||||
@@ -208,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);
|
||||
@@ -372,4 +440,31 @@ export class ShareService {
|
||||
notifiedRecipients,
|
||||
};
|
||||
}
|
||||
|
||||
async getShareMetadataByAlias(alias: string) {
|
||||
const share = await this.shareRepository.findShareByAlias(alias);
|
||||
if (!share) {
|
||||
throw new Error("Share not found");
|
||||
}
|
||||
|
||||
// Check if share is expired
|
||||
const isExpired = share.expiration && new Date(share.expiration) < new Date();
|
||||
|
||||
// Check if max views reached
|
||||
const isMaxViewsReached = share.security.maxViews !== null && share.views >= share.security.maxViews;
|
||||
|
||||
const totalFiles = share.files?.length || 0;
|
||||
const totalFolders = share.folders?.length || 0;
|
||||
const hasPassword = !!share.security.password;
|
||||
|
||||
return {
|
||||
name: share.name,
|
||||
description: share.description,
|
||||
totalFiles,
|
||||
totalFolders,
|
||||
hasPassword,
|
||||
isExpired,
|
||||
isMaxViewsReached,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@@ -284,7 +284,7 @@ export class StorageService {
|
||||
private async _getDiskSpaceMultiplePaths(): Promise<{ total: number; available: number } | null> {
|
||||
const basePaths = IS_RUNNING_IN_CONTAINER
|
||||
? ["/app/server/uploads", "/app/server/temp-uploads", "/app/server/temp-chunks", "/app/server", "/app", "/"]
|
||||
: [".", "./uploads", process.cwd()];
|
||||
: [env.CUSTOM_PATH || ".", "./uploads", process.cwd()];
|
||||
|
||||
const synologyPaths = await this._detectSynologyVolumes();
|
||||
|
||||
|
@@ -192,13 +192,9 @@ export class FilesystemStorageProvider implements StorageProvider {
|
||||
return `/api/filesystem/upload/${token}`;
|
||||
}
|
||||
|
||||
async getPresignedGetUrl(objectName: string, expires: number, fileName?: string): Promise<string> {
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
const expiresAt = Date.now() + expires * 1000;
|
||||
|
||||
this.downloadTokens.set(token, { objectName, expiresAt, fileName });
|
||||
|
||||
return `/api/filesystem/download/${token}`;
|
||||
async getPresignedGetUrl(objectName: string): Promise<string> {
|
||||
const encodedObjectName = encodeURIComponent(objectName);
|
||||
return `/api/files/download?objectName=${encodedObjectName}`;
|
||||
}
|
||||
|
||||
async deleteObject(objectName: string): Promise<void> {
|
||||
@@ -240,7 +236,7 @@ export class FilesystemStorageProvider implements StorageProvider {
|
||||
|
||||
try {
|
||||
await pipeline(inputStream, encryptStream, writeStream);
|
||||
await fs.rename(tempPath, filePath);
|
||||
await this.moveFile(tempPath, filePath);
|
||||
} catch (error) {
|
||||
await this.cleanupTempFile(tempPath);
|
||||
throw error;
|
||||
@@ -636,13 +632,8 @@ export class FilesystemStorageProvider implements StorageProvider {
|
||||
return { objectName: data.objectName, fileName: data.fileName };
|
||||
}
|
||||
|
||||
consumeUploadToken(token: string): void {
|
||||
this.uploadTokens.delete(token);
|
||||
}
|
||||
|
||||
consumeDownloadToken(token: string): void {
|
||||
this.downloadTokens.delete(token);
|
||||
}
|
||||
// Tokens are automatically cleaned up by cleanExpiredTokens() every 5 minutes
|
||||
// No need to manually consume tokens - allows reuse for previews, range requests, etc.
|
||||
|
||||
private async cleanupTempFile(tempPath: string): Promise<void> {
|
||||
try {
|
||||
@@ -707,4 +698,18 @@ export class FilesystemStorageProvider implements StorageProvider {
|
||||
console.error("Error during temp directory cleanup:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private async moveFile(src: string, dest: string): Promise<void> {
|
||||
try {
|
||||
await fs.rename(src, dest);
|
||||
} catch (err: any) {
|
||||
if (err.code === "EXDEV") {
|
||||
// cross-device: fallback to copy + delete
|
||||
await fs.copyFile(src, dest);
|
||||
await fs.unlink(src);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
|
||||
import { bucketName, s3Client } from "../config/storage.config";
|
||||
@@ -14,6 +14,20 @@ export class S3StorageProvider implements StorageProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a character is valid in an HTTP token (RFC 2616)
|
||||
* Tokens can contain: alphanumeric and !#$%&'*+-.^_`|~
|
||||
* Must exclude separators: ()<>@,;:\"/[]?={} and space/tab
|
||||
*/
|
||||
private isTokenChar(char: string): boolean {
|
||||
const code = char.charCodeAt(0);
|
||||
// Basic ASCII range check
|
||||
if (code < 33 || code > 126) return false;
|
||||
// Exclude separator characters per RFC 2616
|
||||
const separators = '()<>@,;:\\"/[]?={} \t';
|
||||
return !separators.includes(char);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely encode filename for Content-Disposition header
|
||||
*/
|
||||
@@ -41,12 +55,10 @@ export class S3StorageProvider implements StorageProvider {
|
||||
return 'attachment; filename="download"';
|
||||
}
|
||||
|
||||
// Create ASCII-safe version with only valid token characters
|
||||
const asciiSafe = sanitized
|
||||
.split("")
|
||||
.filter((char) => {
|
||||
const code = char.charCodeAt(0);
|
||||
return code >= 32 && code <= 126;
|
||||
})
|
||||
.filter((char) => this.isTokenChar(char))
|
||||
.join("");
|
||||
|
||||
if (asciiSafe && asciiSafe.trim()) {
|
||||
@@ -110,4 +122,25 @@ export class S3StorageProvider implements StorageProvider {
|
||||
|
||||
await s3Client.send(command);
|
||||
}
|
||||
|
||||
async fileExists(objectName: string): Promise<boolean> {
|
||||
if (!s3Client) {
|
||||
throw new Error("S3 client is not available");
|
||||
}
|
||||
|
||||
try {
|
||||
const command = new HeadObjectCommand({
|
||||
Bucket: bucketName,
|
||||
Key: objectName,
|
||||
});
|
||||
|
||||
await s3Client.send(command);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
if (error.name === "NotFound" || error.$metadata?.httpStatusCode === 404) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
102
apps/server/src/scripts/cleanup-orphan-files.ts
Normal file
102
apps/server/src/scripts/cleanup-orphan-files.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
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";
|
||||
|
||||
/**
|
||||
* Script to clean up orphan file records in the database
|
||||
* (files that are registered in DB but don't exist in storage)
|
||||
*/
|
||||
async function cleanupOrphanFiles() {
|
||||
console.log("Starting orphan file cleanup...");
|
||||
console.log(`Storage mode: ${isS3Enabled ? "S3" : "Filesystem"}`);
|
||||
|
||||
let storageProvider: StorageProvider;
|
||||
if (isS3Enabled) {
|
||||
storageProvider = new S3StorageProvider();
|
||||
} else {
|
||||
storageProvider = FilesystemStorageProvider.getInstance();
|
||||
}
|
||||
|
||||
// Get all files from database
|
||||
const allFiles = await prisma.file.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
objectName: true,
|
||||
userId: true,
|
||||
size: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Found ${allFiles.length} files in database`);
|
||||
|
||||
const orphanFiles: typeof allFiles = [];
|
||||
const existingFiles: typeof allFiles = [];
|
||||
|
||||
// Check each file
|
||||
for (const file of allFiles) {
|
||||
const exists = await storageProvider.fileExists(file.objectName);
|
||||
if (!exists) {
|
||||
orphanFiles.push(file);
|
||||
console.log(`❌ Orphan: ${file.name} (${file.objectName})`);
|
||||
} else {
|
||||
existingFiles.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n📊 Summary:`);
|
||||
console.log(` Total files in DB: ${allFiles.length}`);
|
||||
console.log(` ✅ Files with storage: ${existingFiles.length}`);
|
||||
console.log(` ❌ Orphan files: ${orphanFiles.length}`);
|
||||
|
||||
if (orphanFiles.length === 0) {
|
||||
console.log("\n✨ No orphan files found!");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n🗑️ Orphan files to be deleted:`);
|
||||
orphanFiles.forEach((file) => {
|
||||
const sizeMB = Number(file.size) / (1024 * 1024);
|
||||
console.log(` - ${file.name} (${sizeMB.toFixed(2)} MB) - ${file.objectName}`);
|
||||
});
|
||||
|
||||
// Ask for confirmation (if running interactively)
|
||||
const shouldDelete = process.argv.includes("--confirm");
|
||||
|
||||
if (!shouldDelete) {
|
||||
console.log(`\n⚠️ Dry run mode. To actually delete orphan records, run with --confirm flag:`);
|
||||
console.log(` node dist/scripts/cleanup-orphan-files.js --confirm`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n🗑️ Deleting orphan file records...`);
|
||||
|
||||
let deletedCount = 0;
|
||||
for (const file of orphanFiles) {
|
||||
try {
|
||||
await prisma.file.delete({
|
||||
where: { id: file.id },
|
||||
});
|
||||
deletedCount++;
|
||||
console.log(` ✓ Deleted: ${file.name}`);
|
||||
} catch (error) {
|
||||
console.error(` ✗ Failed to delete ${file.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n✅ Cleanup complete!`);
|
||||
console.log(` Deleted ${deletedCount} orphan file records`);
|
||||
}
|
||||
|
||||
// Run the cleanup
|
||||
cleanupOrphanFiles()
|
||||
.then(() => {
|
||||
console.log("\nScript completed successfully");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("\n❌ Script failed:", error);
|
||||
process.exit(1);
|
||||
});
|
@@ -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);
|
||||
|
@@ -2,6 +2,7 @@ export interface StorageProvider {
|
||||
getPresignedPutUrl(objectName: string, expires: number): Promise<string>;
|
||||
getPresignedGetUrl(objectName: string, expires: number, fileName?: string): Promise<string>;
|
||||
deleteObject(objectName: string): Promise<void>;
|
||||
fileExists(objectName: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface StorageConfig {
|
||||
|
206
apps/server/src/utils/file-name-generator.ts
Normal file
206
apps/server/src/utils/file-name-generator.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { prisma } from "../shared/prisma";
|
||||
|
||||
/**
|
||||
* Generates a unique filename by checking for duplicates in the database
|
||||
* and appending a numeric suffix if necessary (e.g., file (1).txt, file (2).txt)
|
||||
*
|
||||
* @param baseName - The original filename without extension
|
||||
* @param extension - The file extension
|
||||
* @param userId - The user ID who owns the file
|
||||
* @param folderId - The folder ID where the file will be stored (null for root)
|
||||
* @returns A unique filename with extension
|
||||
*/
|
||||
export async function generateUniqueFileName(
|
||||
baseName: string,
|
||||
extension: string,
|
||||
userId: string,
|
||||
folderId: string | null | undefined
|
||||
): Promise<string> {
|
||||
const fullName = `${baseName}.${extension}`;
|
||||
const targetFolderId = folderId || null;
|
||||
|
||||
// Check if the original filename exists in the target folder
|
||||
const existingFile = await prisma.file.findFirst({
|
||||
where: {
|
||||
name: fullName,
|
||||
userId,
|
||||
folderId: targetFolderId,
|
||||
},
|
||||
});
|
||||
|
||||
// If no duplicate, return the original name
|
||||
if (!existingFile) {
|
||||
return fullName;
|
||||
}
|
||||
|
||||
// Find the next available suffix number
|
||||
let suffix = 1;
|
||||
let uniqueName = `${baseName} (${suffix}).${extension}`;
|
||||
|
||||
while (true) {
|
||||
const duplicateFile = await prisma.file.findFirst({
|
||||
where: {
|
||||
name: uniqueName,
|
||||
userId,
|
||||
folderId: targetFolderId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!duplicateFile) {
|
||||
return uniqueName;
|
||||
}
|
||||
|
||||
suffix++;
|
||||
uniqueName = `${baseName} (${suffix}).${extension}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique filename for rename operations by checking for duplicates
|
||||
* and appending a numeric suffix if necessary (e.g., file (1).txt, file (2).txt)
|
||||
*
|
||||
* @param baseName - The original filename without extension
|
||||
* @param extension - The file extension
|
||||
* @param userId - The user ID who owns the file
|
||||
* @param folderId - The folder ID where the file will be stored (null for root)
|
||||
* @param excludeFileId - The ID of the file being renamed (to exclude from duplicate check)
|
||||
* @returns A unique filename with extension
|
||||
*/
|
||||
export async function generateUniqueFileNameForRename(
|
||||
baseName: string,
|
||||
extension: string,
|
||||
userId: string,
|
||||
folderId: string | null | undefined,
|
||||
excludeFileId: string
|
||||
): Promise<string> {
|
||||
const fullName = `${baseName}.${extension}`;
|
||||
const targetFolderId = folderId || null;
|
||||
|
||||
// Check if the original filename exists in the target folder (excluding current file)
|
||||
const existingFile = await prisma.file.findFirst({
|
||||
where: {
|
||||
name: fullName,
|
||||
userId,
|
||||
folderId: targetFolderId,
|
||||
id: { not: excludeFileId },
|
||||
},
|
||||
});
|
||||
|
||||
// If no duplicate, return the original name
|
||||
if (!existingFile) {
|
||||
return fullName;
|
||||
}
|
||||
|
||||
// Find the next available suffix number
|
||||
let suffix = 1;
|
||||
let uniqueName = `${baseName} (${suffix}).${extension}`;
|
||||
|
||||
while (true) {
|
||||
const duplicateFile = await prisma.file.findFirst({
|
||||
where: {
|
||||
name: uniqueName,
|
||||
userId,
|
||||
folderId: targetFolderId,
|
||||
id: { not: excludeFileId },
|
||||
},
|
||||
});
|
||||
|
||||
if (!duplicateFile) {
|
||||
return uniqueName;
|
||||
}
|
||||
|
||||
suffix++;
|
||||
uniqueName = `${baseName} (${suffix}).${extension}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique folder name by checking for duplicates in the database
|
||||
* and appending a numeric suffix if necessary (e.g., folder (1), folder (2))
|
||||
*
|
||||
* @param name - The original folder name
|
||||
* @param userId - The user ID who owns the folder
|
||||
* @param parentId - The parent folder ID (null for root)
|
||||
* @param excludeFolderId - The ID of the folder being renamed (to exclude from duplicate check)
|
||||
* @returns A unique folder name
|
||||
*/
|
||||
export async function generateUniqueFolderName(
|
||||
name: string,
|
||||
userId: string,
|
||||
parentId: string | null | undefined,
|
||||
excludeFolderId?: string
|
||||
): Promise<string> {
|
||||
const targetParentId = parentId || null;
|
||||
|
||||
// Build the where clause
|
||||
const whereClause: any = {
|
||||
name,
|
||||
userId,
|
||||
parentId: targetParentId,
|
||||
};
|
||||
|
||||
// Exclude the current folder if this is a rename operation
|
||||
if (excludeFolderId) {
|
||||
whereClause.id = { not: excludeFolderId };
|
||||
}
|
||||
|
||||
// Check if the original folder name exists in the target location
|
||||
const existingFolder = await prisma.folder.findFirst({
|
||||
where: whereClause,
|
||||
});
|
||||
|
||||
// If no duplicate, return the original name
|
||||
if (!existingFolder) {
|
||||
return name;
|
||||
}
|
||||
|
||||
// Find the next available suffix number
|
||||
let suffix = 1;
|
||||
let uniqueName = `${name} (${suffix})`;
|
||||
|
||||
while (true) {
|
||||
const whereClauseForSuffix: any = {
|
||||
name: uniqueName,
|
||||
userId,
|
||||
parentId: targetParentId,
|
||||
};
|
||||
|
||||
if (excludeFolderId) {
|
||||
whereClauseForSuffix.id = { not: excludeFolderId };
|
||||
}
|
||||
|
||||
const duplicateFolder = await prisma.folder.findFirst({
|
||||
where: whereClauseForSuffix,
|
||||
});
|
||||
|
||||
if (!duplicateFolder) {
|
||||
return uniqueName;
|
||||
}
|
||||
|
||||
suffix++;
|
||||
uniqueName = `${name} (${suffix})`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a filename into base name and extension
|
||||
*
|
||||
* @param filename - The full filename with extension
|
||||
* @returns Object with baseName and extension
|
||||
*/
|
||||
export function parseFileName(filename: string): { baseName: string; extension: string } {
|
||||
const lastDotIndex = filename.lastIndexOf(".");
|
||||
|
||||
if (lastDotIndex === -1 || lastDotIndex === 0) {
|
||||
// No extension or hidden file with no name before dot
|
||||
return {
|
||||
baseName: filename,
|
||||
extension: "",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
baseName: filename.substring(0, lastDotIndex),
|
||||
extension: filename.substring(lastDotIndex + 1),
|
||||
};
|
||||
}
|
@@ -1,2 +1,5 @@
|
||||
API_BASE_URL=http:localhost:3333
|
||||
NEXT_PUBLIC_DEFAULT_LANGUAGE=en-US
|
||||
NEXT_PUBLIC_DEFAULT_LANGUAGE=en-US
|
||||
|
||||
# Configuration options
|
||||
NEXT_PUBLIC_UPLOAD_CHUNK_SIZE_MB=
|
@@ -144,7 +144,15 @@
|
||||
"update": "تحديث",
|
||||
"click": "انقر على",
|
||||
"creating": "جاري الإنشاء...",
|
||||
"loadingSimple": "جاري التحميل..."
|
||||
"loadingSimple": "جاري التحميل...",
|
||||
"create": "إنشاء",
|
||||
"deleting": "جاري الحذف...",
|
||||
"move": "نقل",
|
||||
"rename": "إعادة تسمية",
|
||||
"search": "بحث",
|
||||
"share": "مشاركة",
|
||||
"copied": "تم النسخ",
|
||||
"copy": "نسخ"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "إنشاء مشاركة",
|
||||
@@ -160,7 +168,13 @@
|
||||
"create": "إنشاء مشاركة",
|
||||
"success": "تم إنشاء المشاركة بنجاح",
|
||||
"error": "فشل في إنشاء المشاركة",
|
||||
"namePlaceholder": "أدخل اسمًا لمشاركتك"
|
||||
"namePlaceholder": "أدخل اسمًا لمشاركتك",
|
||||
"nextSelectFiles": "التالي: اختيار الملفات",
|
||||
"searchLabel": "بحث",
|
||||
"tabs": {
|
||||
"shareDetails": "تفاصيل المشاركة",
|
||||
"selectFiles": "اختيار الملفات"
|
||||
}
|
||||
},
|
||||
"customization": {
|
||||
"breadcrumb": "التخصيص",
|
||||
@@ -214,7 +228,9 @@
|
||||
},
|
||||
"deleteConfirmation": {
|
||||
"filesToDelete": "الملفات المراد حذفها",
|
||||
"sharesToDelete": "المشاركات التي سيتم حذفها"
|
||||
"sharesToDelete": "المشاركات التي سيتم حذفها",
|
||||
"foldersToDelete": "المجلدات المراد حذفها",
|
||||
"itemsToDelete": "العناصر المراد حذفها"
|
||||
},
|
||||
"downloadQueue": {
|
||||
"downloadQueued": "تم إضافة التنزيل إلى قائمة الانتظار: {fileName}",
|
||||
@@ -288,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "معاينة الملف",
|
||||
"description": "معاينة وتنزيل الملف",
|
||||
"loading": "جاري التحميل...",
|
||||
"notAvailable": "المعاينة غير متاحة لهذا النوع من الملفات.",
|
||||
"downloadToView": "استخدم زر التحميل لتنزيل الملف.",
|
||||
@@ -322,7 +339,8 @@
|
||||
"previewFile": "معاينة الملف",
|
||||
"addToShare": "إضافة إلى المشاركة",
|
||||
"removeFromShare": "إزالة من المشاركة",
|
||||
"saveChanges": "حفظ التغييرات"
|
||||
"saveChanges": "حفظ التغييرات",
|
||||
"editFolder": "تحرير المجلد"
|
||||
},
|
||||
"files": {
|
||||
"title": "جميع الملفات",
|
||||
@@ -347,7 +365,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 +406,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 +534,13 @@
|
||||
"removeFailed": "فشل في حذف الشعار"
|
||||
}
|
||||
},
|
||||
"moveItems": {
|
||||
"itemsToMove": "العناصر المراد نقلها:",
|
||||
"movingTo": "النقل إلى:",
|
||||
"title": "نقل {count, plural, =1 {عنصر} other {عناصر}}",
|
||||
"description": "نقل {count, plural, =1 {عنصر} other {عناصر}} إلى موقع جديد",
|
||||
"success": "تم نقل {count} {count, plural, =1 {عنصر} other {عناصر}} بنجاح"
|
||||
},
|
||||
"navbar": {
|
||||
"logoAlt": "شعار التطبيق",
|
||||
"profileMenu": "قائمة الملف الشخصي",
|
||||
@@ -996,7 +1059,8 @@
|
||||
"upload": {
|
||||
"metadata": {
|
||||
"title": "رفع الملفات - Palmr",
|
||||
"description": "رفع الملفات عبر الرابط المشترك"
|
||||
"description": "رفع الملفات عبر الرابط المشترك",
|
||||
"descriptionWithLimit": "تحميل الملفات (الحد الأقصى {limit} ملفات)"
|
||||
},
|
||||
"layout": {
|
||||
"defaultTitle": "رفع الملفات",
|
||||
@@ -1108,7 +1172,10 @@
|
||||
},
|
||||
"searchBar": {
|
||||
"placeholder": "ابحث عن الملفات...",
|
||||
"results": "تم العثور على {filtered} من {total} ملف"
|
||||
"results": "تم العثور على {filtered} من {total} ملف",
|
||||
"placeholderFolders": "البحث في المجلدات...",
|
||||
"noResults": "لم يتم العثور على نتائج لـ \"{query}\"",
|
||||
"placeholderFiles": "البحث في الملفات..."
|
||||
},
|
||||
"settings": {
|
||||
"groups": {
|
||||
@@ -1237,6 +1304,10 @@
|
||||
"passwordAuthEnabled": {
|
||||
"title": "المصادقة بالكلمة السرية",
|
||||
"description": "تمكين أو تعطيل المصادقة بالكلمة السرية"
|
||||
},
|
||||
"hideVersion": {
|
||||
"title": "إخفاء الإصدار",
|
||||
"description": "إخفاء إصدار Palmr في تذييل جميع الصفحات"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1298,7 +1369,11 @@
|
||||
"description": "قد يكون تم حذف هذه المشاركة أو انتهت صلاحيتها."
|
||||
},
|
||||
"pageTitle": "المشاركة",
|
||||
"downloadAll": "تحميل الكل"
|
||||
"downloadAll": "تحميل الكل",
|
||||
"metadata": {
|
||||
"defaultDescription": "مشاركة الملفات بشكل آمن",
|
||||
"filesShared": "{count, plural, =1 {ملف واحد تمت مشاركته} other {# ملفات تمت مشاركتها}}"
|
||||
}
|
||||
},
|
||||
"shareActions": {
|
||||
"deleteTitle": "حذف المشاركة",
|
||||
@@ -1322,7 +1397,17 @@
|
||||
"editError": "فشل في تحديث المشاركة",
|
||||
"bulkDeleteConfirmation": "هل أنت متأكد من أنك تريد حذف {count, plural, =1 {مشاركة واحدة} other {# مشاركات}} محددة؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||
"bulkDeleteTitle": "حذف المشاركات المحددة",
|
||||
"addDescriptionPlaceholder": "إضافة وصف..."
|
||||
"addDescriptionPlaceholder": "إضافة وصف...",
|
||||
"aliasLabel": "اسم مستعار للرابط",
|
||||
"aliasPlaceholder": "أدخل اسمًا مستعارًا مخصصًا",
|
||||
"copyLink": "نسخ الرابط",
|
||||
"fileTitle": "مشاركة ملف",
|
||||
"folderTitle": "مشاركة مجلد",
|
||||
"generateLink": "إنشاء رابط",
|
||||
"linkDescriptionFile": "إنشاء رابط مخصص لمشاركة الملف",
|
||||
"linkDescriptionFolder": "إنشاء رابط مخصص لمشاركة المجلد",
|
||||
"linkReady": "رابط المشاركة جاهز:",
|
||||
"linkTitle": "إنشاء رابط"
|
||||
},
|
||||
"shareDetails": {
|
||||
"title": "تفاصيل المشاركة",
|
||||
@@ -1449,7 +1534,8 @@
|
||||
"files": "ملفات",
|
||||
"totalSize": "الحجم الإجمالي",
|
||||
"creating": "جاري الإنشاء...",
|
||||
"create": "إنشاء مشاركة"
|
||||
"create": "إنشاء مشاركة",
|
||||
"itemsToShare": "العناصر للمشاركة ({count} {count, plural, =1 {عنصر} other {عناصر}})"
|
||||
},
|
||||
"shareSecurity": {
|
||||
"subtitle": "تكوين حماية كلمة المرور وخيارات الأمان لهذه المشاركة",
|
||||
@@ -1554,7 +1640,8 @@
|
||||
"download": "تنزيل محدد"
|
||||
},
|
||||
"selectAll": "تحديد الكل",
|
||||
"selectShare": "تحديد المشاركة {shareName}"
|
||||
"selectShare": "تحديد المشاركة {shareName}",
|
||||
"folderCount": "مجلدات"
|
||||
},
|
||||
"storageUsage": {
|
||||
"title": "استخدام التخزين",
|
||||
@@ -1848,5 +1935,17 @@
|
||||
"passwordRequired": "كلمة المرور مطلوبة",
|
||||
"nameRequired": "الاسم مطلوب",
|
||||
"required": "هذا الحقل مطلوب"
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "تضمين الصورة",
|
||||
"description": "استخدم هذه الأكواد لتضمين هذه الصورة في المنتديات أو المواقع الإلكترونية أو المنصات الأخرى",
|
||||
"tabs": {
|
||||
"directLink": "رابط مباشر",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "عنوان URL مباشر لملف الصورة",
|
||||
"htmlDescription": "استخدم هذا الكود لتضمين الصورة في صفحات HTML",
|
||||
"bbcodeDescription": "استخدم هذا الكود لتضمين الصورة في المنتديات التي تدعم BBCode"
|
||||
}
|
||||
}
|
||||
}
|
@@ -144,7 +144,15 @@
|
||||
"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",
|
||||
"copied": "Kopiert",
|
||||
"copy": "Kopieren"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "Freigabe Erstellen",
|
||||
@@ -160,7 +168,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 +228,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}",
|
||||
@@ -288,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "Datei-Vorschau",
|
||||
"description": "Vorschau und Download der Datei",
|
||||
"loading": "Laden...",
|
||||
"notAvailable": "Vorschau für diesen Dateityp nicht verfügbar.",
|
||||
"downloadToView": "Verwenden Sie die Download-Schaltfläche, um die Datei herunterzuladen.",
|
||||
@@ -322,7 +339,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 +365,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 +406,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 +534,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ü",
|
||||
@@ -996,7 +1059,8 @@
|
||||
"upload": {
|
||||
"metadata": {
|
||||
"title": "Dateien senden - Palmr",
|
||||
"description": "Senden Sie Dateien über den geteilten Link"
|
||||
"description": "Senden Sie Dateien über den geteilten Link",
|
||||
"descriptionWithLimit": "Dateien hochladen (max. {limit} Dateien)"
|
||||
},
|
||||
"layout": {
|
||||
"defaultTitle": "Dateien senden",
|
||||
@@ -1106,7 +1170,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": {
|
||||
@@ -1235,6 +1302,10 @@
|
||||
"passwordAuthEnabled": {
|
||||
"title": "Passwort-Authentifizierung",
|
||||
"description": "Passwort-basierte Authentifizierung aktivieren oder deaktivieren"
|
||||
},
|
||||
"hideVersion": {
|
||||
"title": "Version Ausblenden",
|
||||
"description": "Die Palmr-Version in der Fußzeile aller Seiten ausblenden"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1296,7 +1367,11 @@
|
||||
"description": "Diese Freigabe wurde möglicherweise gelöscht oder ist abgelaufen."
|
||||
},
|
||||
"pageTitle": "Freigabe",
|
||||
"downloadAll": "Alle herunterladen"
|
||||
"downloadAll": "Alle herunterladen",
|
||||
"metadata": {
|
||||
"defaultDescription": "Dateien sicher teilen",
|
||||
"filesShared": "{count, plural, =1 {1 Datei geteilt} other {# Dateien geteilt}}"
|
||||
}
|
||||
},
|
||||
"shareActions": {
|
||||
"deleteTitle": "Freigabe Löschen",
|
||||
@@ -1320,7 +1395,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 +1532,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 +1638,8 @@
|
||||
"download": "Download ausgewählt"
|
||||
},
|
||||
"selectAll": "Alle auswählen",
|
||||
"selectShare": "Freigabe {shareName} auswählen"
|
||||
"selectShare": "Freigabe {shareName} auswählen",
|
||||
"folderCount": "Ordner"
|
||||
},
|
||||
"storageUsage": {
|
||||
"title": "Speichernutzung",
|
||||
@@ -1846,5 +1933,17 @@
|
||||
"passwordRequired": "Passwort ist erforderlich",
|
||||
"nameRequired": "Name ist erforderlich",
|
||||
"required": "Dieses Feld ist erforderlich"
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "Bild einbetten",
|
||||
"description": "Verwenden Sie diese Codes, um dieses Bild in Foren, Websites oder anderen Plattformen einzubetten",
|
||||
"tabs": {
|
||||
"directLink": "Direkter Link",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "Direkte URL zur Bilddatei",
|
||||
"htmlDescription": "Verwenden Sie diesen Code, um das Bild in HTML-Seiten einzubetten",
|
||||
"bbcodeDescription": "Verwenden Sie diesen Code, um das Bild in Foren einzubetten, die BBCode unterstützen"
|
||||
}
|
||||
}
|
||||
}
|
@@ -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,14 @@
|
||||
"dashboard": "Dashboard",
|
||||
"back": "Back",
|
||||
"click": "Click to",
|
||||
"creating": "Creating..."
|
||||
"creating": "Creating...",
|
||||
"create": "Create",
|
||||
"rename": "Rename",
|
||||
"move": "Move",
|
||||
"share": "Share",
|
||||
"search": "Search",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "Create Share",
|
||||
@@ -160,7 +168,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 +228,8 @@
|
||||
},
|
||||
"deleteConfirmation": {
|
||||
"filesToDelete": "Files to be deleted",
|
||||
"foldersToDelete": "Folders to be deleted",
|
||||
"itemsToDelete": "Items to be deleted",
|
||||
"sharesToDelete": "Shares to be deleted"
|
||||
},
|
||||
"downloadQueue": {
|
||||
@@ -288,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "Preview File",
|
||||
"description": "Preview and download file",
|
||||
"loading": "Loading...",
|
||||
"notAvailable": "Preview not available for this file type",
|
||||
"downloadToView": "Use the download button to view this file",
|
||||
@@ -319,6 +336,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 +357,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 +406,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 +534,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",
|
||||
@@ -997,7 +1060,8 @@
|
||||
"upload": {
|
||||
"metadata": {
|
||||
"title": "Send Files - Palmr",
|
||||
"description": "Send files through the shared link"
|
||||
"description": "Send files through the shared link",
|
||||
"descriptionWithLimit": "Upload files (max {limit} files)"
|
||||
},
|
||||
"layout": {
|
||||
"defaultTitle": "Send Files",
|
||||
@@ -1103,8 +1167,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": {
|
||||
@@ -1148,6 +1215,10 @@
|
||||
"title": "Show Home Page",
|
||||
"description": "Show Home Page after installation"
|
||||
},
|
||||
"hideVersion": {
|
||||
"title": "Hide Version",
|
||||
"description": "Hide the Palmr version from the footer on all pages"
|
||||
},
|
||||
"smtpEnabled": {
|
||||
"title": "SMTP Enabled",
|
||||
"description": "Enable or disable SMTP email functionality"
|
||||
@@ -1294,23 +1365,27 @@
|
||||
"title": "Share Not Found",
|
||||
"description": "This share may have been deleted or expired."
|
||||
},
|
||||
"pageTitle": "Share"
|
||||
"pageTitle": "Share",
|
||||
"metadata": {
|
||||
"defaultDescription": "Share files securely",
|
||||
"filesShared": "{count, plural, =1 {1 file shared} other {# files shared}}"
|
||||
}
|
||||
},
|
||||
"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 +1456,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 +1493,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 +1581,7 @@
|
||||
"public": "Public"
|
||||
},
|
||||
"filesCount": "files",
|
||||
"folderCount": "folders",
|
||||
"recipientsCount": "recipients",
|
||||
"actions": {
|
||||
"menu": "Share actions menu",
|
||||
@@ -1829,6 +1884,18 @@
|
||||
"userr": "User"
|
||||
}
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "Embed Image",
|
||||
"description": "Use these codes to embed this image in forums, websites, or other platforms",
|
||||
"tabs": {
|
||||
"directLink": "Direct Link",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "Direct URL to the image file",
|
||||
"htmlDescription": "Use this code to embed the image in HTML pages",
|
||||
"bbcodeDescription": "Use this code to embed the image in forums that support BBCode"
|
||||
},
|
||||
"validation": {
|
||||
"firstNameRequired": "First name is required",
|
||||
"lastNameRequired": "Last name is required",
|
||||
@@ -1844,4 +1911,4 @@
|
||||
"nameRequired": "Name is required",
|
||||
"required": "This field is required"
|
||||
}
|
||||
}
|
||||
}
|
@@ -144,7 +144,15 @@
|
||||
"update": "Actualizar",
|
||||
"click": "Haga clic para",
|
||||
"creating": "Creando...",
|
||||
"loadingSimple": "Cargando..."
|
||||
"loadingSimple": "Cargando...",
|
||||
"create": "Crear",
|
||||
"deleting": "Eliminando...",
|
||||
"move": "Mover",
|
||||
"rename": "Renombrar",
|
||||
"search": "Buscar",
|
||||
"share": "Compartir",
|
||||
"copied": "Copiado",
|
||||
"copy": "Copiar"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "Crear Compartir",
|
||||
@@ -160,7 +168,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 +228,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}",
|
||||
@@ -288,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "Vista Previa del Archivo",
|
||||
"description": "Vista previa y descarga de archivo",
|
||||
"loading": "Cargando...",
|
||||
"notAvailable": "Vista previa no disponible para este tipo de archivo.",
|
||||
"downloadToView": "Use el botón de descarga para descargar el archivo.",
|
||||
@@ -322,7 +339,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 +365,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 +406,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 +534,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",
|
||||
@@ -996,7 +1059,8 @@
|
||||
"upload": {
|
||||
"metadata": {
|
||||
"title": "Enviar Archivos - Palmr",
|
||||
"description": "Envía archivos a través del enlace compartido"
|
||||
"description": "Envía archivos a través del enlace compartido",
|
||||
"descriptionWithLimit": "Subir archivos (máx. {limit} archivos)"
|
||||
},
|
||||
"layout": {
|
||||
"defaultTitle": "Enviar Archivos",
|
||||
@@ -1106,7 +1170,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": {
|
||||
@@ -1235,6 +1302,10 @@
|
||||
"passwordAuthEnabled": {
|
||||
"title": "Autenticación por Contraseña",
|
||||
"description": "Habilitar o deshabilitar la autenticación basada en contraseña"
|
||||
},
|
||||
"hideVersion": {
|
||||
"title": "Ocultar Versión",
|
||||
"description": "Ocultar la versión de Palmr en el pie de página de todas las páginas"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1296,7 +1367,11 @@
|
||||
"description": "Esta compartición puede haber sido eliminada o haber expirado."
|
||||
},
|
||||
"pageTitle": "Compartición",
|
||||
"downloadAll": "Descargar Todo"
|
||||
"downloadAll": "Descargar Todo",
|
||||
"metadata": {
|
||||
"defaultDescription": "Compartir archivos de forma segura",
|
||||
"filesShared": "{count, plural, =1 {1 archivo compartido} other {# archivos compartidos}}"
|
||||
}
|
||||
},
|
||||
"shareActions": {
|
||||
"deleteTitle": "Eliminar Compartir",
|
||||
@@ -1320,7 +1395,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 +1532,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 +1616,7 @@
|
||||
"public": "Público"
|
||||
},
|
||||
"filesCount": "archivos",
|
||||
"folderCount": "carpetas",
|
||||
"recipientsCount": "destinatarios",
|
||||
"actions": {
|
||||
"menu": "Menú de acciones de compartir",
|
||||
@@ -1846,5 +1933,17 @@
|
||||
"passwordRequired": "Se requiere la contraseña",
|
||||
"nameRequired": "El nombre es obligatorio",
|
||||
"required": "Este campo es obligatorio"
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "Insertar imagen",
|
||||
"description": "Utiliza estos códigos para insertar esta imagen en foros, sitios web u otras plataformas",
|
||||
"tabs": {
|
||||
"directLink": "Enlace directo",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "URL directa al archivo de imagen",
|
||||
"htmlDescription": "Utiliza este código para insertar la imagen en páginas HTML",
|
||||
"bbcodeDescription": "Utiliza este código para insertar la imagen en foros que admiten BBCode"
|
||||
}
|
||||
}
|
||||
}
|
@@ -144,7 +144,15 @@
|
||||
"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",
|
||||
"copied": "Copié",
|
||||
"copy": "Copier"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "Créer un Partage",
|
||||
@@ -160,7 +168,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 +228,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}",
|
||||
@@ -288,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "Aperçu du Fichier",
|
||||
"description": "Aperçu et téléchargement du fichier",
|
||||
"loading": "Chargement...",
|
||||
"notAvailable": "Aperçu non disponible pour ce type de fichier.",
|
||||
"downloadToView": "Utilisez le bouton de téléchargement pour télécharger le fichier.",
|
||||
@@ -322,7 +339,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 +365,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 +406,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 +534,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",
|
||||
@@ -996,7 +1059,8 @@
|
||||
"upload": {
|
||||
"metadata": {
|
||||
"title": "Envoyer des Fichiers - Palmr",
|
||||
"description": "Envoyez des fichiers via le lien partagé"
|
||||
"description": "Envoyez des fichiers via le lien partagé",
|
||||
"descriptionWithLimit": "Télécharger des fichiers (max {limit} fichiers)"
|
||||
},
|
||||
"layout": {
|
||||
"defaultTitle": "Envoyer des Fichiers",
|
||||
@@ -1106,7 +1170,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",
|
||||
@@ -1238,6 +1305,10 @@
|
||||
"passwordAuthEnabled": {
|
||||
"title": "Authentification par Mot de Passe",
|
||||
"description": "Activer ou désactiver l'authentification basée sur mot de passe"
|
||||
},
|
||||
"hideVersion": {
|
||||
"title": "Masquer la Version",
|
||||
"description": "Masquer la version de Palmr dans le pied de page de toutes les pages"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1296,7 +1367,11 @@
|
||||
"description": "Ce partage a peut-être été supprimé ou a expiré."
|
||||
},
|
||||
"pageTitle": "Partage",
|
||||
"downloadAll": "Tout Télécharger"
|
||||
"downloadAll": "Tout Télécharger",
|
||||
"metadata": {
|
||||
"defaultDescription": "Partager des fichiers en toute sécurité",
|
||||
"filesShared": "{count, plural, =1 {1 fichier partagé} other {# fichiers partagés}}"
|
||||
}
|
||||
},
|
||||
"shareActions": {
|
||||
"deleteTitle": "Supprimer le Partage",
|
||||
@@ -1320,7 +1395,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 +1532,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 +1638,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",
|
||||
@@ -1846,5 +1933,17 @@
|
||||
"passwordRequired": "Le mot de passe est requis",
|
||||
"nameRequired": "Nome é obrigatório",
|
||||
"required": "Este campo é obrigatório"
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "Intégrer l'image",
|
||||
"description": "Utilisez ces codes pour intégrer cette image dans des forums, sites web ou autres plateformes",
|
||||
"tabs": {
|
||||
"directLink": "Lien direct",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "URL directe vers le fichier image",
|
||||
"htmlDescription": "Utilisez ce code pour intégrer l'image dans des pages HTML",
|
||||
"bbcodeDescription": "Utilisez ce code pour intégrer l'image dans des forums prenant en charge BBCode"
|
||||
}
|
||||
}
|
||||
}
|
@@ -144,7 +144,15 @@
|
||||
"update": "अपडेट करें",
|
||||
"click": "क्लिक करें",
|
||||
"creating": "बना रहा है...",
|
||||
"loadingSimple": "लोड हो रहा है..."
|
||||
"loadingSimple": "लोड हो रहा है...",
|
||||
"create": "बनाएं",
|
||||
"deleting": "हटा रहे हैं...",
|
||||
"move": "स्थानांतरित करें",
|
||||
"rename": "नाम बदलें",
|
||||
"search": "खोजें",
|
||||
"share": "साझा करें",
|
||||
"copied": "कॉपी किया गया",
|
||||
"copy": "कॉपी करें"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "साझाकरण बनाएं",
|
||||
@@ -160,7 +168,13 @@
|
||||
"create": "साझाकरण बनाएं",
|
||||
"success": "साझाकरण सफलतापूर्वक बनाया गया",
|
||||
"error": "साझाकरण बनाने में विफल",
|
||||
"namePlaceholder": "अपने साझाकरण के लिए एक नाम दर्ज करें"
|
||||
"namePlaceholder": "अपने साझाकरण के लिए एक नाम दर्ज करें",
|
||||
"nextSelectFiles": "आगे: फ़ाइलें चुनें",
|
||||
"searchLabel": "खोजें",
|
||||
"tabs": {
|
||||
"shareDetails": "साझाकरण विवरण",
|
||||
"selectFiles": "फ़ाइलें चुनें"
|
||||
}
|
||||
},
|
||||
"customization": {
|
||||
"breadcrumb": "अनुकूलन",
|
||||
@@ -214,7 +228,9 @@
|
||||
},
|
||||
"deleteConfirmation": {
|
||||
"filesToDelete": "हटाई जाने वाली फाइलें",
|
||||
"sharesToDelete": "साझाकरण जो हटाए जाएंगे"
|
||||
"sharesToDelete": "साझाकरण जो हटाए जाएंगे",
|
||||
"foldersToDelete": "हटाए जाने वाले फ़ोल्डर",
|
||||
"itemsToDelete": "हटाए जाने वाले आइटम"
|
||||
},
|
||||
"downloadQueue": {
|
||||
"downloadQueued": "डाउनलोड कतार में: {fileName}",
|
||||
@@ -288,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "फ़ाइल पूर्वावलोकन",
|
||||
"description": "फ़ाइल पूर्वावलोकन और डाउनलोड",
|
||||
"loading": "लोड हो रहा है...",
|
||||
"notAvailable": "इस फ़ाइल प्रकार के लिए पूर्वावलोकन उपलब्ध नहीं है।",
|
||||
"downloadToView": "फ़ाइल डाउनलोड करने के लिए डाउनलोड बटन का उपयोग करें।",
|
||||
@@ -322,7 +339,8 @@
|
||||
"previewFile": "फाइल पूर्वावलोकन",
|
||||
"addToShare": "साझाकरण में जोड़ें",
|
||||
"removeFromShare": "साझाकरण से हटाएं",
|
||||
"saveChanges": "परिवर्तन सहेजें"
|
||||
"saveChanges": "परिवर्तन सहेजें",
|
||||
"editFolder": "फ़ोल्डर संपादित करें"
|
||||
},
|
||||
"files": {
|
||||
"title": "सभी फाइलें",
|
||||
@@ -347,7 +365,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 +406,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 +534,13 @@
|
||||
"removeFailed": "लोगो हटाने में विफल"
|
||||
}
|
||||
},
|
||||
"moveItems": {
|
||||
"itemsToMove": "स्थानांतरित करने वाले आइटम:",
|
||||
"movingTo": "यहाँ स्थानांतरित कर रहे हैं:",
|
||||
"title": "आइटम स्थानांतरित करें",
|
||||
"description": "आइटम को नए स्थान पर स्थानांतरित करें",
|
||||
"success": "{count} आइटम सफलतापूर्वक स्थानांतरित किए गए"
|
||||
},
|
||||
"navbar": {
|
||||
"logoAlt": "एप्लिकेशन लोगो",
|
||||
"profileMenu": "प्रोफ़ाइल मेन्यू",
|
||||
@@ -996,7 +1059,8 @@
|
||||
"upload": {
|
||||
"metadata": {
|
||||
"title": "फ़ाइलें भेजें - पाल्मर",
|
||||
"description": "साझा किए गए लिंक के माध्यम से फ़ाइलें भेजें"
|
||||
"description": "साझा किए गए लिंक के माध्यम से फ़ाइलें भेजें",
|
||||
"descriptionWithLimit": "फ़ाइलें अपलोड करें (अधिकतम {limit} फ़ाइलें)"
|
||||
},
|
||||
"layout": {
|
||||
"defaultTitle": "फ़ाइलें भेजें",
|
||||
@@ -1106,7 +1170,10 @@
|
||||
},
|
||||
"searchBar": {
|
||||
"placeholder": "फाइलें खोजें...",
|
||||
"results": "{total} में से {filtered} फाइलें मिलीं"
|
||||
"results": "{total} में से {filtered} फाइलें मिलीं",
|
||||
"placeholderFolders": "फ़ोल्डर खोजें...",
|
||||
"noResults": "\"{query}\" के लिए कोई परिणाम नहीं मिला",
|
||||
"placeholderFiles": "फाइलें खोजें..."
|
||||
},
|
||||
"settings": {
|
||||
"groups": {
|
||||
@@ -1235,6 +1302,10 @@
|
||||
"passwordAuthEnabled": {
|
||||
"title": "पासवर्ड प्रमाणीकरण",
|
||||
"description": "पासवर्ड आधारित प्रमाणीकरण सक्षम या अक्षम करें"
|
||||
},
|
||||
"hideVersion": {
|
||||
"title": "संस्करण छुपाएं",
|
||||
"description": "सभी पृष्ठों के फुटर में Palmr संस्करण छुपाएं"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1296,7 +1367,11 @@
|
||||
"description": "यह साझाकरण हटा दिया गया हो सकता है या समाप्त हो चुका है।"
|
||||
},
|
||||
"pageTitle": "साझाकरण",
|
||||
"downloadAll": "सभी डाउनलोड करें"
|
||||
"downloadAll": "सभी डाउनलोड करें",
|
||||
"metadata": {
|
||||
"defaultDescription": "फाइलों को सुरक्षित रूप से साझा करें",
|
||||
"filesShared": "{count, plural, =1 {1 फ़ाइल साझा की गई} other {# फ़ाइलें साझा की गईं}}"
|
||||
}
|
||||
},
|
||||
"shareActions": {
|
||||
"deleteTitle": "साझाकरण हटाएं",
|
||||
@@ -1320,7 +1395,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 +1532,8 @@
|
||||
"files": "फाइलें",
|
||||
"totalSize": "कुल आकार",
|
||||
"creating": "बनाया जा रहा है...",
|
||||
"create": "साझाकरण बनाएं"
|
||||
"create": "साझाकरण बनाएं",
|
||||
"itemsToShare": "साझा करने वाले आइटम ({count} आइटम)"
|
||||
},
|
||||
"shareSecurity": {
|
||||
"subtitle": "इस साझाकरण के लिए पासवर्ड सुरक्षा और सुरक्षा विकल्प कॉन्फ़िगर करें",
|
||||
@@ -1552,7 +1638,8 @@
|
||||
"download": "चयनित डाउनलोड करें"
|
||||
},
|
||||
"selectAll": "सभी चुनें",
|
||||
"selectShare": "साझाकरण {shareName} चुनें"
|
||||
"selectShare": "साझाकरण {shareName} चुनें",
|
||||
"folderCount": "फ़ोल्डर"
|
||||
},
|
||||
"storageUsage": {
|
||||
"title": "स्टोरेज उपयोग",
|
||||
@@ -1846,5 +1933,17 @@
|
||||
"passwordRequired": "पासवर्ड आवश्यक है",
|
||||
"nameRequired": "नाम आवश्यक है",
|
||||
"required": "यह फ़ील्ड आवश्यक है"
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "छवि एम्बेड करें",
|
||||
"description": "इस छवि को मंचों, वेबसाइटों या अन्य प्लेटफार्मों में एम्बेड करने के लिए इन कोड का उपयोग करें",
|
||||
"tabs": {
|
||||
"directLink": "सीधा लिंक",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "छवि फ़ाइल का सीधा URL",
|
||||
"htmlDescription": "HTML पेजों में छवि एम्बेड करने के लिए इस कोड का उपयोग करें",
|
||||
"bbcodeDescription": "BBCode का समर्थन करने वाले मंचों में छवि एम्बेड करने के लिए इस कोड का उपयोग करें"
|
||||
}
|
||||
}
|
||||
}
|
@@ -144,7 +144,15 @@
|
||||
"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",
|
||||
"copied": "Copiato",
|
||||
"copy": "Copia"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "Crea Condivisione",
|
||||
@@ -160,7 +168,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 +228,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}",
|
||||
@@ -288,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "Anteprima File",
|
||||
"description": "Anteprima e download del file",
|
||||
"loading": "Caricamento...",
|
||||
"notAvailable": "Anteprima non disponibile per questo tipo di file.",
|
||||
"downloadToView": "Utilizzare il pulsante di download per scaricare il file.",
|
||||
@@ -322,7 +339,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 +365,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 +406,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 +534,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",
|
||||
@@ -996,7 +1059,8 @@
|
||||
"upload": {
|
||||
"metadata": {
|
||||
"title": "Invia File - Palmr",
|
||||
"description": "Invia file attraverso il link condiviso"
|
||||
"description": "Invia file attraverso il link condiviso",
|
||||
"descriptionWithLimit": "Carica file (max {limit} file)"
|
||||
},
|
||||
"layout": {
|
||||
"defaultTitle": "Invia File",
|
||||
@@ -1106,7 +1170,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": {
|
||||
@@ -1235,6 +1302,10 @@
|
||||
"passwordAuthEnabled": {
|
||||
"title": "Autenticazione Password",
|
||||
"description": "Abilita o disabilita l'autenticazione basata su password"
|
||||
},
|
||||
"hideVersion": {
|
||||
"title": "Nascondi Versione",
|
||||
"description": "Nascondi la versione di Palmr nel piè di pagina di tutte le pagine"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1296,7 +1367,11 @@
|
||||
"description": "Questa condivisione potrebbe essere stata eliminata o è scaduta."
|
||||
},
|
||||
"pageTitle": "Condivisione",
|
||||
"downloadAll": "Scarica Tutto"
|
||||
"downloadAll": "Scarica Tutto",
|
||||
"metadata": {
|
||||
"defaultDescription": "Condividi file in modo sicuro",
|
||||
"filesShared": "{count, plural, =1 {1 file condiviso} other {# file condivisi}}"
|
||||
}
|
||||
},
|
||||
"shareActions": {
|
||||
"deleteTitle": "Elimina Condivisione",
|
||||
@@ -1320,7 +1395,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 +1532,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 +1638,8 @@
|
||||
"download": "Scarica selezionato"
|
||||
},
|
||||
"selectAll": "Seleziona tutto",
|
||||
"selectShare": "Seleziona condivisione {shareName}"
|
||||
"selectShare": "Seleziona condivisione {shareName}",
|
||||
"folderCount": "cartelle"
|
||||
},
|
||||
"storageUsage": {
|
||||
"title": "Utilizzo Archiviazione",
|
||||
@@ -1846,5 +1933,17 @@
|
||||
"passwordMinLength": "La password deve contenere almeno 6 caratteri",
|
||||
"nameRequired": "Il nome è obbligatorio",
|
||||
"required": "Questo campo è obbligatorio"
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "Incorpora immagine",
|
||||
"description": "Usa questi codici per incorporare questa immagine in forum, siti web o altre piattaforme",
|
||||
"tabs": {
|
||||
"directLink": "Link diretto",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "URL diretto al file immagine",
|
||||
"htmlDescription": "Usa questo codice per incorporare l'immagine nelle pagine HTML",
|
||||
"bbcodeDescription": "Usa questo codice per incorporare l'immagine nei forum che supportano BBCode"
|
||||
}
|
||||
}
|
||||
}
|
@@ -144,7 +144,15 @@
|
||||
"update": "更新",
|
||||
"click": "クリックして",
|
||||
"creating": "作成中...",
|
||||
"loadingSimple": "読み込み中..."
|
||||
"loadingSimple": "読み込み中...",
|
||||
"create": "作成",
|
||||
"deleting": "削除中...",
|
||||
"move": "移動",
|
||||
"rename": "名前を変更",
|
||||
"search": "検索",
|
||||
"share": "共有",
|
||||
"copied": "コピーしました",
|
||||
"copy": "コピー"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "共有を作成",
|
||||
@@ -160,7 +168,13 @@
|
||||
"create": "共有を作成",
|
||||
"success": "共有が正常に作成されました",
|
||||
"error": "共有の作成に失敗しました",
|
||||
"namePlaceholder": "共有の名前を入力してください"
|
||||
"namePlaceholder": "共有の名前を入力してください",
|
||||
"nextSelectFiles": "次へ:ファイルを選択",
|
||||
"searchLabel": "検索",
|
||||
"tabs": {
|
||||
"shareDetails": "共有の詳細",
|
||||
"selectFiles": "ファイルを選択"
|
||||
}
|
||||
},
|
||||
"customization": {
|
||||
"breadcrumb": "カスタマイズ",
|
||||
@@ -214,7 +228,9 @@
|
||||
},
|
||||
"deleteConfirmation": {
|
||||
"filesToDelete": "削除するファイル",
|
||||
"sharesToDelete": "削除される共有"
|
||||
"sharesToDelete": "削除される共有",
|
||||
"foldersToDelete": "削除するフォルダ",
|
||||
"itemsToDelete": "削除するアイテム"
|
||||
},
|
||||
"downloadQueue": {
|
||||
"downloadQueued": "ダウンロードキューに追加: {fileName}",
|
||||
@@ -288,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "ファイルプレビュー",
|
||||
"description": "ファイルをプレビューしてダウンロード",
|
||||
"loading": "読み込み中...",
|
||||
"notAvailable": "このファイルタイプのプレビューは利用できません。",
|
||||
"downloadToView": "ダウンロードボタンを使用してファイルをダウンロードしてください。",
|
||||
@@ -322,7 +339,8 @@
|
||||
"previewFile": "ファイルをプレビュー",
|
||||
"addToShare": "共有に追加",
|
||||
"removeFromShare": "共有から削除",
|
||||
"saveChanges": "変更を保存"
|
||||
"saveChanges": "変更を保存",
|
||||
"editFolder": "フォルダを編集"
|
||||
},
|
||||
"files": {
|
||||
"title": "すべてのファイル",
|
||||
@@ -347,7 +365,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 +406,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 +534,13 @@
|
||||
"removeFailed": "ロゴの削除に失敗しました"
|
||||
}
|
||||
},
|
||||
"moveItems": {
|
||||
"itemsToMove": "移動するアイテム:",
|
||||
"movingTo": "移動先:",
|
||||
"title": "アイテムを移動",
|
||||
"description": "アイテムを新しい場所に移動",
|
||||
"success": "{count}個のアイテムが正常に移動されました"
|
||||
},
|
||||
"navbar": {
|
||||
"logoAlt": "アプリケーションロゴ",
|
||||
"profileMenu": "プロフィールメニュー",
|
||||
@@ -996,7 +1059,8 @@
|
||||
"upload": {
|
||||
"metadata": {
|
||||
"title": "ファイルを送信 - Palmr",
|
||||
"description": "共有リンクを通じてファイルを送信"
|
||||
"description": "共有リンクを通じてファイルを送信",
|
||||
"descriptionWithLimit": "ファイルをアップロード(最大{limit}ファイル)"
|
||||
},
|
||||
"layout": {
|
||||
"defaultTitle": "ファイルを送信",
|
||||
@@ -1106,7 +1170,10 @@
|
||||
},
|
||||
"searchBar": {
|
||||
"placeholder": "ファイルを検索...",
|
||||
"results": "全{total}件中{filtered}件が見つかりました"
|
||||
"results": "全{total}件中{filtered}件が見つかりました",
|
||||
"placeholderFolders": "フォルダを検索...",
|
||||
"noResults": "\"{query}\"の検索結果が見つかりませんでした",
|
||||
"placeholderFiles": "ファイルを検索..."
|
||||
},
|
||||
"settings": {
|
||||
"groups": {
|
||||
@@ -1235,6 +1302,10 @@
|
||||
"passwordAuthEnabled": {
|
||||
"title": "パスワード認証",
|
||||
"description": "パスワード認証を有効または無効にする"
|
||||
},
|
||||
"hideVersion": {
|
||||
"title": "バージョンを非表示",
|
||||
"description": "すべてのページのフッターにあるPalmrバージョンを非表示にする"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1296,7 +1367,11 @@
|
||||
"description": "この共有は削除されたか、期限が切れている可能性があります。"
|
||||
},
|
||||
"pageTitle": "共有",
|
||||
"downloadAll": "すべてダウンロード"
|
||||
"downloadAll": "すべてダウンロード",
|
||||
"metadata": {
|
||||
"defaultDescription": "ファイルを安全に共有",
|
||||
"filesShared": "{count, plural, =1 {1 ファイルが共有されました} other {# ファイルが共有されました}}"
|
||||
}
|
||||
},
|
||||
"shareActions": {
|
||||
"deleteTitle": "共有を削除",
|
||||
@@ -1320,7 +1395,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 +1532,8 @@
|
||||
"files": "ファイル",
|
||||
"totalSize": "合計サイズ",
|
||||
"creating": "作成中...",
|
||||
"create": "共有を作成"
|
||||
"create": "共有を作成",
|
||||
"itemsToShare": "共有するアイテム({count}個のアイテム)"
|
||||
},
|
||||
"shareSecurity": {
|
||||
"subtitle": "この共有のパスワード保護とセキュリティオプションを設定",
|
||||
@@ -1552,7 +1638,8 @@
|
||||
"download": "選択したダウンロード"
|
||||
},
|
||||
"selectAll": "すべて選択",
|
||||
"selectShare": "共有{shareName}を選択"
|
||||
"selectShare": "共有{shareName}を選択",
|
||||
"folderCount": "フォルダ"
|
||||
},
|
||||
"storageUsage": {
|
||||
"title": "ストレージ使用量",
|
||||
@@ -1846,5 +1933,17 @@
|
||||
"passwordRequired": "パスワードは必須です",
|
||||
"nameRequired": "名前は必須です",
|
||||
"required": "このフィールドは必須です"
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "画像を埋め込む",
|
||||
"description": "これらのコードを使用して、この画像をフォーラム、ウェブサイト、またはその他のプラットフォームに埋め込みます",
|
||||
"tabs": {
|
||||
"directLink": "直接リンク",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "画像ファイルへの直接URL",
|
||||
"htmlDescription": "このコードを使用してHTMLページに画像を埋め込みます",
|
||||
"bbcodeDescription": "BBCodeをサポートするフォーラムに画像を埋め込むには、このコードを使用します"
|
||||
}
|
||||
}
|
||||
}
|
@@ -144,7 +144,15 @@
|
||||
"update": "업데이트",
|
||||
"click": "클릭하여",
|
||||
"creating": "생성 중...",
|
||||
"loadingSimple": "로딩 중..."
|
||||
"loadingSimple": "로딩 중...",
|
||||
"create": "생성",
|
||||
"deleting": "삭제 중...",
|
||||
"move": "이동",
|
||||
"rename": "이름 변경",
|
||||
"search": "검색",
|
||||
"share": "공유",
|
||||
"copied": "복사됨",
|
||||
"copy": "복사"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "공유 생성",
|
||||
@@ -160,7 +168,13 @@
|
||||
"create": "공유 생성",
|
||||
"success": "공유가 성공적으로 생성되었습니다",
|
||||
"error": "공유 생성에 실패했습니다",
|
||||
"namePlaceholder": "공유 이름을 입력하세요"
|
||||
"namePlaceholder": "공유 이름을 입력하세요",
|
||||
"nextSelectFiles": "다음: 파일 선택",
|
||||
"searchLabel": "검색",
|
||||
"tabs": {
|
||||
"shareDetails": "공유 세부사항",
|
||||
"selectFiles": "파일 선택"
|
||||
}
|
||||
},
|
||||
"customization": {
|
||||
"breadcrumb": "커스터마이징",
|
||||
@@ -214,7 +228,9 @@
|
||||
},
|
||||
"deleteConfirmation": {
|
||||
"filesToDelete": "삭제할 파일",
|
||||
"sharesToDelete": "삭제될 공유"
|
||||
"sharesToDelete": "삭제될 공유",
|
||||
"foldersToDelete": "삭제할 폴더",
|
||||
"itemsToDelete": "삭제할 항목"
|
||||
},
|
||||
"downloadQueue": {
|
||||
"downloadQueued": "다운로드 대기 중: {fileName}",
|
||||
@@ -288,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "파일 미리보기",
|
||||
"description": "파일 미리보기 및 다운로드",
|
||||
"loading": "로딩 중...",
|
||||
"notAvailable": "이 파일 유형에 대한 미리보기를 사용할 수 없습니다.",
|
||||
"downloadToView": "다운로드 버튼을 사용하여 파일을 다운로드하세요.",
|
||||
@@ -322,7 +339,8 @@
|
||||
"previewFile": "파일 미리보기",
|
||||
"addToShare": "공유에 추가",
|
||||
"removeFromShare": "공유에서 제거",
|
||||
"saveChanges": "변경사항 저장"
|
||||
"saveChanges": "변경사항 저장",
|
||||
"editFolder": "폴더 편집"
|
||||
},
|
||||
"files": {
|
||||
"title": "모든 파일",
|
||||
@@ -347,7 +365,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 +406,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 +534,13 @@
|
||||
"removeFailed": "로고 삭제에 실패했습니다"
|
||||
}
|
||||
},
|
||||
"moveItems": {
|
||||
"itemsToMove": "이동할 항목:",
|
||||
"movingTo": "이동 위치:",
|
||||
"title": "항목 이동",
|
||||
"description": "항목을 새 위치로 이동",
|
||||
"success": "{count}개 항목이 성공적으로 이동되었습니다"
|
||||
},
|
||||
"navbar": {
|
||||
"logoAlt": "애플리케이션 로고",
|
||||
"profileMenu": "프로필 메뉴",
|
||||
@@ -996,7 +1059,8 @@
|
||||
"upload": {
|
||||
"metadata": {
|
||||
"title": "파일 보내기 - Palmr",
|
||||
"description": "공유된 링크를 통해 파일 보내기"
|
||||
"description": "공유된 링크를 통해 파일 보내기",
|
||||
"descriptionWithLimit": "파일 업로드 (최대 {limit}개 파일)"
|
||||
},
|
||||
"layout": {
|
||||
"defaultTitle": "파일 보내기",
|
||||
@@ -1106,7 +1170,10 @@
|
||||
},
|
||||
"searchBar": {
|
||||
"placeholder": "파일 검색...",
|
||||
"results": "전체 {total}개 중 {filtered}개 파일을 찾았습니다"
|
||||
"results": "전체 {total}개 중 {filtered}개 파일을 찾았습니다",
|
||||
"placeholderFolders": "폴더 검색...",
|
||||
"noResults": "\"{query}\"에 대한 검색 결과가 없습니다",
|
||||
"placeholderFiles": "파일 검색..."
|
||||
},
|
||||
"settings": {
|
||||
"groups": {
|
||||
@@ -1235,6 +1302,10 @@
|
||||
"passwordAuthEnabled": {
|
||||
"title": "비밀번호 인증",
|
||||
"description": "비밀번호 기반 인증 활성화 또는 비활성화"
|
||||
},
|
||||
"hideVersion": {
|
||||
"title": "버전 숨기기",
|
||||
"description": "모든 페이지의 바닥글에서 Palmr 버전 숨기기"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1296,7 +1367,11 @@
|
||||
"description": "이 공유는 삭제되었거나 만료되었을 수 있습니다."
|
||||
},
|
||||
"pageTitle": "공유",
|
||||
"downloadAll": "모두 다운로드"
|
||||
"downloadAll": "모두 다운로드",
|
||||
"metadata": {
|
||||
"defaultDescription": "파일을 안전하게 공유",
|
||||
"filesShared": "{count, plural, =1 {1개 파일 공유됨} other {#개 파일 공유됨}}"
|
||||
}
|
||||
},
|
||||
"shareActions": {
|
||||
"deleteTitle": "공유 삭제",
|
||||
@@ -1320,7 +1395,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 +1532,8 @@
|
||||
"files": "파일",
|
||||
"totalSize": "전체 크기",
|
||||
"creating": "생성 중...",
|
||||
"create": "공유 생성"
|
||||
"create": "공유 생성",
|
||||
"itemsToShare": "공유할 항목 ({count}개 항목)"
|
||||
},
|
||||
"shareSecurity": {
|
||||
"subtitle": "이 공유의 비밀번호 보호 및 보안 옵션을 구성하세요",
|
||||
@@ -1552,7 +1638,8 @@
|
||||
"download": "선택한 다운로드"
|
||||
},
|
||||
"selectAll": "모두 선택",
|
||||
"selectShare": "공유 {shareName} 선택"
|
||||
"selectShare": "공유 {shareName} 선택",
|
||||
"folderCount": "폴더"
|
||||
},
|
||||
"storageUsage": {
|
||||
"title": "스토리지 사용량",
|
||||
@@ -1846,5 +1933,17 @@
|
||||
"passwordRequired": "비밀번호는 필수입니다",
|
||||
"nameRequired": "이름은 필수입니다",
|
||||
"required": "이 필드는 필수입니다"
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "이미지 삽입",
|
||||
"description": "이 코드를 사용하여 포럼, 웹사이트 또는 기타 플랫폼에 이 이미지를 삽입하세요",
|
||||
"tabs": {
|
||||
"directLink": "직접 링크",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "이미지 파일에 대한 직접 URL",
|
||||
"htmlDescription": "이 코드를 사용하여 HTML 페이지에 이미지를 삽입하세요",
|
||||
"bbcodeDescription": "BBCode를 지원하는 포럼에 이미지를 삽입하려면 이 코드를 사용하세요"
|
||||
}
|
||||
}
|
||||
}
|
@@ -144,7 +144,15 @@
|
||||
"update": "Bijwerken",
|
||||
"click": "Klik om",
|
||||
"creating": "Maken...",
|
||||
"loadingSimple": "Laden..."
|
||||
"loadingSimple": "Laden...",
|
||||
"create": "Aanmaken",
|
||||
"deleting": "Verwijderen...",
|
||||
"move": "Verplaatsen",
|
||||
"rename": "Hernoemen",
|
||||
"search": "Zoeken",
|
||||
"share": "Delen",
|
||||
"copied": "Gekopieerd",
|
||||
"copy": "Kopiëren"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "Delen Maken",
|
||||
@@ -160,7 +168,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 +228,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}",
|
||||
@@ -288,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "Bestandsvoorbeeld",
|
||||
"description": "Bestand bekijken en downloaden",
|
||||
"loading": "Laden...",
|
||||
"notAvailable": "Voorbeeld niet beschikbaar voor dit bestandstype.",
|
||||
"downloadToView": "Gebruik de downloadknop om het bestand te downloaden.",
|
||||
@@ -322,7 +339,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 +365,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 +406,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 +534,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",
|
||||
@@ -996,7 +1059,8 @@
|
||||
"upload": {
|
||||
"metadata": {
|
||||
"title": "Bestanden Verzenden - Palmr",
|
||||
"description": "Verzend bestanden via de gedeelde link"
|
||||
"description": "Verzend bestanden via de gedeelde link",
|
||||
"descriptionWithLimit": "Upload bestanden (max {limit} bestanden)"
|
||||
},
|
||||
"layout": {
|
||||
"defaultTitle": "Bestanden Verzenden",
|
||||
@@ -1106,7 +1170,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": {
|
||||
@@ -1235,6 +1302,10 @@
|
||||
"passwordAuthEnabled": {
|
||||
"title": "Wachtwoord Authenticatie",
|
||||
"description": "Wachtwoord-gebaseerde authenticatie inschakelen of uitschakelen"
|
||||
},
|
||||
"hideVersion": {
|
||||
"title": "Versie Verbergen",
|
||||
"description": "Verberg de Palmr-versie in de voettekst van alle pagina's"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1296,7 +1367,11 @@
|
||||
"description": "Dit delen is mogelijk verwijderd of verlopen."
|
||||
},
|
||||
"pageTitle": "Delen",
|
||||
"downloadAll": "Alles Downloaden"
|
||||
"downloadAll": "Alles Downloaden",
|
||||
"metadata": {
|
||||
"defaultDescription": "Bestanden veilig delen",
|
||||
"filesShared": "{count, plural, =1 {1 bestand gedeeld} other {# bestanden gedeeld}}"
|
||||
}
|
||||
},
|
||||
"shareActions": {
|
||||
"deleteTitle": "Delen Verwijderen",
|
||||
@@ -1320,7 +1395,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 +1532,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 +1638,8 @@
|
||||
"download": "Download geselecteerd"
|
||||
},
|
||||
"selectAll": "Alles selecteren",
|
||||
"selectShare": "Deel {shareName} selecteren"
|
||||
"selectShare": "Deel {shareName} selecteren",
|
||||
"folderCount": "mappen"
|
||||
},
|
||||
"storageUsage": {
|
||||
"title": "Opslaggebruik",
|
||||
@@ -1846,5 +1933,17 @@
|
||||
"passwordMinLength": "Wachtwoord moet minimaal 6 tekens bevatten",
|
||||
"nameRequired": "Naam is verplicht",
|
||||
"required": "Dit veld is verplicht"
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "Afbeelding insluiten",
|
||||
"description": "Gebruik deze codes om deze afbeelding in te sluiten in forums, websites of andere platforms",
|
||||
"tabs": {
|
||||
"directLink": "Directe link",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "Directe URL naar het afbeeldingsbestand",
|
||||
"htmlDescription": "Gebruik deze code om de afbeelding in te sluiten in HTML-pagina's",
|
||||
"bbcodeDescription": "Gebruik deze code om de afbeelding in te sluiten in forums die BBCode ondersteunen"
|
||||
}
|
||||
}
|
||||
}
|
@@ -144,7 +144,15 @@
|
||||
"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",
|
||||
"copied": "Skopiowano",
|
||||
"copy": "Kopiuj"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "Utwórz Udostępnienie",
|
||||
@@ -160,7 +168,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 +228,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}",
|
||||
@@ -288,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "Podgląd pliku",
|
||||
"description": "Podgląd i pobieranie pliku",
|
||||
"loading": "Ładowanie...",
|
||||
"notAvailable": "Podgląd niedostępny dla tego typu pliku",
|
||||
"downloadToView": "Użyj przycisku pobierania, aby wyświetlić ten plik",
|
||||
@@ -322,7 +339,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 +365,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 +406,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 +534,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",
|
||||
@@ -996,7 +1059,8 @@
|
||||
"upload": {
|
||||
"metadata": {
|
||||
"title": "Wyślij pliki - Palmr",
|
||||
"description": "Wysyłaj pliki za pośrednictwem udostępnionego linku"
|
||||
"description": "Wysyłaj pliki za pośrednictwem udostępnionego linku",
|
||||
"descriptionWithLimit": "Prześlij pliki (maks. {limit} plików)"
|
||||
},
|
||||
"layout": {
|
||||
"defaultTitle": "Wyślij pliki",
|
||||
@@ -1106,7 +1170,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": {
|
||||
@@ -1235,6 +1302,10 @@
|
||||
"passwordAuthEnabled": {
|
||||
"title": "Uwierzytelnianie hasłem",
|
||||
"description": "Włącz lub wyłącz uwierzytelnianie oparte na haśle"
|
||||
},
|
||||
"hideVersion": {
|
||||
"title": "Ukryj Wersję",
|
||||
"description": "Ukryj wersję Palmr w stopce wszystkich stron"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1296,7 +1367,11 @@
|
||||
"description": "To udostępnienie mogło zostać usunięte lub wygasło."
|
||||
},
|
||||
"pageTitle": "Udostępnij",
|
||||
"downloadAll": "Pobierz wszystkie"
|
||||
"downloadAll": "Pobierz wszystkie",
|
||||
"metadata": {
|
||||
"defaultDescription": "Bezpiecznie udostępniaj pliki",
|
||||
"filesShared": "{count, plural, =1 {1 plik udostępniony} other {# plików udostępnionych}}"
|
||||
}
|
||||
},
|
||||
"shareActions": {
|
||||
"deleteTitle": "Usuń udostępnienie",
|
||||
@@ -1320,7 +1395,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 +1532,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 +1638,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",
|
||||
@@ -1846,5 +1933,17 @@
|
||||
"passwordMinLength": "Hasło musi mieć co najmniej 6 znaków",
|
||||
"nameRequired": "Nazwa jest wymagana",
|
||||
"required": "To pole jest wymagane"
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "Osadź obraz",
|
||||
"description": "Użyj tych kodów, aby osadzić ten obraz na forach, stronach internetowych lub innych platformach",
|
||||
"tabs": {
|
||||
"directLink": "Link bezpośredni",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "Bezpośredni adres URL pliku obrazu",
|
||||
"htmlDescription": "Użyj tego kodu, aby osadzić obraz na stronach HTML",
|
||||
"bbcodeDescription": "Użyj tego kodu, aby osadzić obraz na forach obsługujących BBCode"
|
||||
}
|
||||
}
|
||||
}
|
@@ -144,7 +144,15 @@
|
||||
"update": "Atualizar",
|
||||
"creating": "Criando...",
|
||||
"click": "Clique para",
|
||||
"loadingSimple": "Carregando..."
|
||||
"loadingSimple": "Carregando...",
|
||||
"create": "Criar",
|
||||
"deleting": "Excluindo...",
|
||||
"move": "Mover",
|
||||
"rename": "Renomear",
|
||||
"search": "Pesquisar",
|
||||
"share": "Compartilhar",
|
||||
"copied": "Copiado",
|
||||
"copy": "Copiar"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "Criar compartilhamento",
|
||||
@@ -160,7 +168,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 +228,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}",
|
||||
@@ -288,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "Visualizar Arquivo",
|
||||
"description": "Visualizar e baixar arquivo",
|
||||
"loading": "Carregando...",
|
||||
"notAvailable": "Preview não disponível para este tipo de arquivo.",
|
||||
"downloadToView": "Use o botão de download para baixar o arquivo.",
|
||||
@@ -322,7 +339,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 +365,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 +406,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 +534,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",
|
||||
@@ -997,7 +1060,8 @@
|
||||
"upload": {
|
||||
"metadata": {
|
||||
"title": "Enviar Arquivos - Palmr",
|
||||
"description": "Envie arquivos através do link compartilhado"
|
||||
"description": "Envie arquivos através do link compartilhado",
|
||||
"descriptionWithLimit": "Enviar arquivos (máx. {limit} arquivos)"
|
||||
},
|
||||
"layout": {
|
||||
"defaultTitle": "Enviar Arquivos",
|
||||
@@ -1107,7 +1171,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": {
|
||||
@@ -1244,6 +1311,10 @@
|
||||
"passwordAuthEnabled": {
|
||||
"title": "Autenticação por Senha",
|
||||
"description": "Ative ou desative a autenticação baseada em senha"
|
||||
},
|
||||
"hideVersion": {
|
||||
"title": "Ocultar Versão",
|
||||
"description": "Ocultar a versão do Palmr no rodapé de todas as páginas"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1297,7 +1368,11 @@
|
||||
"description": "Este compartilhamento pode ter sido excluído ou expirado."
|
||||
},
|
||||
"pageTitle": "Compartilhamento",
|
||||
"downloadAll": "Baixar todos"
|
||||
"downloadAll": "Baixar todos",
|
||||
"metadata": {
|
||||
"defaultDescription": "Compartilhar arquivos com segurança",
|
||||
"filesShared": "{count, plural, =1 {1 arquivo compartilhado} other {# arquivos compartilhados}}"
|
||||
}
|
||||
},
|
||||
"shareActions": {
|
||||
"deleteTitle": "Excluir Compartilhamento",
|
||||
@@ -1321,7 +1396,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 +1533,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 +1639,8 @@
|
||||
"delete": "Excluir",
|
||||
"downloadShareFiles": "Baixar todos os arquivos",
|
||||
"viewQrCode": "Visualizar QR Code"
|
||||
}
|
||||
},
|
||||
"folderCount": "pastas"
|
||||
},
|
||||
"storageUsage": {
|
||||
"title": "Uso de armazenamento",
|
||||
@@ -1847,5 +1934,17 @@
|
||||
"lastNameRequired": "O sobrenome é necessário",
|
||||
"usernameLength": "O nome de usuário deve ter pelo menos 3 caracteres",
|
||||
"usernameSpaces": "O nome de usuário não pode conter espaços"
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "Incorporar imagem",
|
||||
"description": "Use estes códigos para incorporar esta imagem em fóruns, sites ou outras plataformas",
|
||||
"tabs": {
|
||||
"directLink": "Link direto",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "URL direto para o arquivo de imagem",
|
||||
"htmlDescription": "Use este código para incorporar a imagem em páginas HTML",
|
||||
"bbcodeDescription": "Use este código para incorporar a imagem em fóruns que suportam BBCode"
|
||||
}
|
||||
}
|
||||
}
|
@@ -144,7 +144,15 @@
|
||||
"update": "Обновить",
|
||||
"click": "Нажмите для",
|
||||
"creating": "Создание...",
|
||||
"loadingSimple": "Загрузка..."
|
||||
"loadingSimple": "Загрузка...",
|
||||
"create": "Создать",
|
||||
"deleting": "Удаление...",
|
||||
"move": "Переместить",
|
||||
"rename": "Переименовать",
|
||||
"search": "Поиск",
|
||||
"share": "Поделиться",
|
||||
"copied": "Скопировано",
|
||||
"copy": "Копировать"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "Создать общий доступ",
|
||||
@@ -160,7 +168,13 @@
|
||||
"error": "Не удалось создать общий доступ",
|
||||
"descriptionLabel": "Описание",
|
||||
"descriptionPlaceholder": "Введите описание (опционально)",
|
||||
"namePlaceholder": "Введите имя для вашего общего доступа"
|
||||
"namePlaceholder": "Введите имя для вашего общего доступа",
|
||||
"nextSelectFiles": "Далее: Выбор файлов",
|
||||
"searchLabel": "Поиск",
|
||||
"tabs": {
|
||||
"shareDetails": "Детали общего доступа",
|
||||
"selectFiles": "Выбрать файлы"
|
||||
}
|
||||
},
|
||||
"customization": {
|
||||
"breadcrumb": "Настройка",
|
||||
@@ -214,7 +228,9 @@
|
||||
},
|
||||
"deleteConfirmation": {
|
||||
"filesToDelete": "Файлы для удаления",
|
||||
"sharesToDelete": "Общие папки, которые будут удалены"
|
||||
"sharesToDelete": "Общие папки, которые будут удалены",
|
||||
"foldersToDelete": "Папки для удаления",
|
||||
"itemsToDelete": "Элементы для удаления"
|
||||
},
|
||||
"downloadQueue": {
|
||||
"downloadQueued": "Загрузка в очереди: {fileName}",
|
||||
@@ -288,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "Предварительный просмотр файла",
|
||||
"description": "Просмотр и загрузка файла",
|
||||
"loading": "Загрузка...",
|
||||
"notAvailable": "Предварительный просмотр недоступен для этого типа файла.",
|
||||
"downloadToView": "Используйте кнопку загрузки для скачивания файла.",
|
||||
@@ -322,7 +339,8 @@
|
||||
"previewFile": "Предпросмотр файла",
|
||||
"addToShare": "Добавить в общий доступ",
|
||||
"removeFromShare": "Удалить из общего доступа",
|
||||
"saveChanges": "Сохранить Изменения"
|
||||
"saveChanges": "Сохранить Изменения",
|
||||
"editFolder": "Редактировать папку"
|
||||
},
|
||||
"files": {
|
||||
"title": "Все файлы",
|
||||
@@ -347,7 +365,18 @@
|
||||
"grid": "Сетка"
|
||||
},
|
||||
"bulkDeleteConfirmation": "Вы уверены, что хотите удалить {count, plural, =1 {1 файл} other {# файлов}}? Это действие нельзя отменить.",
|
||||
"bulkDeleteTitle": "Удалить Выбранные Файлы"
|
||||
"bulkDeleteTitle": "Удалить Выбранные Файлы",
|
||||
"actions": {
|
||||
"open": "Открыть",
|
||||
"rename": "Переименовать",
|
||||
"delete": "Удалить"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Пока нет файлов или папок",
|
||||
"description": "Загрузите свой первый файл или создайте папку для начала работы"
|
||||
},
|
||||
"files": "файлы",
|
||||
"folders": "папки"
|
||||
},
|
||||
"filesTable": {
|
||||
"ariaLabel": "Таблица файлов",
|
||||
@@ -377,6 +406,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 +534,13 @@
|
||||
"removeFailed": "Ошибка удаления логотипа"
|
||||
}
|
||||
},
|
||||
"moveItems": {
|
||||
"itemsToMove": "Элементы для перемещения:",
|
||||
"movingTo": "Перемещение в:",
|
||||
"title": "Переместить {count, plural, =1 {элемент} other {элементы}}",
|
||||
"description": "Переместить {count, plural, =1 {элемент} other {элементы}} в новое место",
|
||||
"success": "Успешно перемещено {count} {count, plural, =1 {элемент} other {элементов}}"
|
||||
},
|
||||
"navbar": {
|
||||
"logoAlt": "Логотип приложения",
|
||||
"profileMenu": "Меню профиля",
|
||||
@@ -996,7 +1059,8 @@
|
||||
"upload": {
|
||||
"metadata": {
|
||||
"title": "Отправка файлов - Palmr",
|
||||
"description": "Отправка файлов через общую ссылку"
|
||||
"description": "Отправка файлов через общую ссылку",
|
||||
"descriptionWithLimit": "Загрузить файлы (макс. {limit} файлов)"
|
||||
},
|
||||
"layout": {
|
||||
"defaultTitle": "Отправка файлов",
|
||||
@@ -1106,7 +1170,10 @@
|
||||
},
|
||||
"searchBar": {
|
||||
"placeholder": "Поиск файлов...",
|
||||
"results": "Найдено {filtered} из {total} файлов"
|
||||
"results": "Найдено {filtered} из {total} файлов",
|
||||
"placeholderFolders": "Поиск папок...",
|
||||
"noResults": "Не найдено результатов для \"{query}\"",
|
||||
"placeholderFiles": "Поиск файлов..."
|
||||
},
|
||||
"settings": {
|
||||
"groups": {
|
||||
@@ -1235,6 +1302,10 @@
|
||||
"passwordAuthEnabled": {
|
||||
"title": "Парольная аутентификация",
|
||||
"description": "Включить или отключить парольную аутентификацию"
|
||||
},
|
||||
"hideVersion": {
|
||||
"title": "Скрыть Версию",
|
||||
"description": "Скрыть версию Palmr в нижнем колонтитуле всех страниц"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1296,7 +1367,11 @@
|
||||
"description": "Этот общий доступ может быть удален или истек."
|
||||
},
|
||||
"pageTitle": "Общий доступ",
|
||||
"downloadAll": "Скачать все"
|
||||
"downloadAll": "Скачать все",
|
||||
"metadata": {
|
||||
"defaultDescription": "Безопасный обмен файлами",
|
||||
"filesShared": "{count, plural, =1 {1 файл отправлен} other {# файлов отправлено}}"
|
||||
}
|
||||
},
|
||||
"shareActions": {
|
||||
"deleteTitle": "Удалить Общий Доступ",
|
||||
@@ -1320,7 +1395,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 +1532,8 @@
|
||||
"files": "файлов",
|
||||
"totalSize": "Общий размер",
|
||||
"creating": "Создание...",
|
||||
"create": "Создать Общий Доступ"
|
||||
"create": "Создать Общий Доступ",
|
||||
"itemsToShare": "Элементы для обмена ({count} {count, plural, =1 {элемент} other {элементов}})"
|
||||
},
|
||||
"shareSecurity": {
|
||||
"subtitle": "Настройте защиту паролем и параметры безопасности для этого общего доступа",
|
||||
@@ -1552,7 +1638,8 @@
|
||||
"download": "Скачать выбранный"
|
||||
},
|
||||
"selectAll": "Выбрать все",
|
||||
"selectShare": "Выбрать общую папку {shareName}"
|
||||
"selectShare": "Выбрать общую папку {shareName}",
|
||||
"folderCount": "папки"
|
||||
},
|
||||
"storageUsage": {
|
||||
"title": "Использование хранилища",
|
||||
@@ -1846,5 +1933,17 @@
|
||||
"passwordRequired": "Требуется пароль",
|
||||
"nameRequired": "Требуется имя",
|
||||
"required": "Это поле обязательно"
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "Встроить изображение",
|
||||
"description": "Используйте эти коды для встраивания этого изображения на форумах, веб-сайтах или других платформах",
|
||||
"tabs": {
|
||||
"directLink": "Прямая ссылка",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "Прямой URL-адрес файла изображения",
|
||||
"htmlDescription": "Используйте этот код для встраивания изображения в HTML-страницы",
|
||||
"bbcodeDescription": "Используйте этот код для встраивания изображения на форумах, поддерживающих BBCode"
|
||||
}
|
||||
}
|
||||
}
|
@@ -144,7 +144,15 @@
|
||||
"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ş",
|
||||
"copied": "Kopyalandı",
|
||||
"copy": "Kopyala"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "Paylaşım Oluştur",
|
||||
@@ -160,7 +168,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 +228,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}",
|
||||
@@ -288,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "Dosya Önizleme",
|
||||
"description": "Dosyayı önizleyin ve indirin",
|
||||
"loading": "Yükleniyor...",
|
||||
"notAvailable": "Bu dosya türü için önizleme mevcut değil.",
|
||||
"downloadToView": "Dosyayı indirmek için indirme düğmesini kullanın.",
|
||||
@@ -322,7 +339,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 +365,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 +406,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 +534,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ü",
|
||||
@@ -996,7 +1059,8 @@
|
||||
"upload": {
|
||||
"metadata": {
|
||||
"title": "Dosya Gönder - Palmr",
|
||||
"description": "Paylaşılan bağlantı üzerinden dosya gönderin"
|
||||
"description": "Paylaşılan bağlantı üzerinden dosya gönderin",
|
||||
"descriptionWithLimit": "Dosya yükle (maks. {limit} dosya)"
|
||||
},
|
||||
"layout": {
|
||||
"defaultTitle": "Dosya Gönder",
|
||||
@@ -1106,7 +1170,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": {
|
||||
@@ -1235,6 +1302,10 @@
|
||||
"passwordAuthEnabled": {
|
||||
"title": "Şifre Doğrulama",
|
||||
"description": "Şifre tabanlı doğrulamayı etkinleştirme veya devre dışı bırakma"
|
||||
},
|
||||
"hideVersion": {
|
||||
"title": "Sürümü Gizle",
|
||||
"description": "Tüm sayfaların alt bilgisinde Palmr sürümünü gizle"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1296,7 +1367,11 @@
|
||||
"description": "Bu paylaşım silinmiş veya süresi dolmuş olabilir."
|
||||
},
|
||||
"pageTitle": "Paylaşım",
|
||||
"downloadAll": "Tümünü İndir"
|
||||
"downloadAll": "Tümünü İndir",
|
||||
"metadata": {
|
||||
"defaultDescription": "Dosyaları güvenli bir şekilde paylaşın",
|
||||
"filesShared": "{count, plural, =1 {1 dosya paylaşıldı} other {# dosya paylaşıldı}}"
|
||||
}
|
||||
},
|
||||
"shareActions": {
|
||||
"deleteTitle": "Paylaşımı Sil",
|
||||
@@ -1320,7 +1395,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 +1532,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 +1638,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ı",
|
||||
@@ -1846,5 +1933,17 @@
|
||||
"passwordRequired": "Şifre gerekli",
|
||||
"nameRequired": "İsim gereklidir",
|
||||
"required": "Bu alan zorunludur"
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "Resmi Yerleştir",
|
||||
"description": "Bu görüntüyü forumlara, web sitelerine veya diğer platformlara yerleştirmek için bu kodları kullanın",
|
||||
"tabs": {
|
||||
"directLink": "Doğrudan Bağlantı",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "Resim dosyasının doğrudan URL'si",
|
||||
"htmlDescription": "Resmi HTML sayfalarına yerleştirmek için bu kodu kullanın",
|
||||
"bbcodeDescription": "BBCode destekleyen forumlara resmi yerleştirmek için bu kodu kullanın"
|
||||
}
|
||||
}
|
||||
}
|
@@ -144,7 +144,15 @@
|
||||
"update": "更新",
|
||||
"click": "点击",
|
||||
"creating": "创建中...",
|
||||
"loadingSimple": "加载中..."
|
||||
"loadingSimple": "加载中...",
|
||||
"create": "创建",
|
||||
"deleting": "删除中...",
|
||||
"move": "移动",
|
||||
"rename": "重命名",
|
||||
"search": "搜索",
|
||||
"share": "分享",
|
||||
"copied": "已复制",
|
||||
"copy": "复制"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "创建分享",
|
||||
@@ -160,7 +168,13 @@
|
||||
"create": "创建分享",
|
||||
"success": "分享创建成功",
|
||||
"error": "创建分享失败",
|
||||
"namePlaceholder": "输入分享名称"
|
||||
"namePlaceholder": "输入分享名称",
|
||||
"nextSelectFiles": "下一步:选择文件",
|
||||
"searchLabel": "搜索",
|
||||
"tabs": {
|
||||
"shareDetails": "分享详情",
|
||||
"selectFiles": "选择文件"
|
||||
}
|
||||
},
|
||||
"customization": {
|
||||
"breadcrumb": "自定义",
|
||||
@@ -214,7 +228,9 @@
|
||||
},
|
||||
"deleteConfirmation": {
|
||||
"filesToDelete": "要删除的文件",
|
||||
"sharesToDelete": "将被删除的共享"
|
||||
"sharesToDelete": "将被删除的共享",
|
||||
"foldersToDelete": "要删除的文件夹",
|
||||
"itemsToDelete": "要删除的项目"
|
||||
},
|
||||
"downloadQueue": {
|
||||
"downloadQueued": "已加入下载队列:{fileName}",
|
||||
@@ -288,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "文件预览",
|
||||
"description": "预览和下载文件",
|
||||
"loading": "加载中...",
|
||||
"notAvailable": "此文件类型不支持预览。",
|
||||
"downloadToView": "使用下载按钮下载文件。",
|
||||
@@ -322,7 +339,8 @@
|
||||
"previewFile": "预览文件",
|
||||
"addToShare": "添加到共享",
|
||||
"removeFromShare": "从共享中移除",
|
||||
"saveChanges": "保存更改"
|
||||
"saveChanges": "保存更改",
|
||||
"editFolder": "编辑文件夹"
|
||||
},
|
||||
"files": {
|
||||
"title": "所有文件",
|
||||
@@ -347,7 +365,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 +406,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 +534,13 @@
|
||||
"removeFailed": "Logo删除失败"
|
||||
}
|
||||
},
|
||||
"moveItems": {
|
||||
"itemsToMove": "要移动的项目:",
|
||||
"movingTo": "移动到:",
|
||||
"title": "移动 {count, plural, =1 {项目} other {项目}}",
|
||||
"description": "将 {count, plural, =1 {项目} other {项目}} 移动到新位置",
|
||||
"success": "成功移动了 {count} 个项目"
|
||||
},
|
||||
"navbar": {
|
||||
"logoAlt": "应用Logo",
|
||||
"profileMenu": "个人菜单",
|
||||
@@ -996,7 +1059,8 @@
|
||||
"upload": {
|
||||
"metadata": {
|
||||
"title": "上传文件 - Palmr",
|
||||
"description": "通过共享链接上传文件"
|
||||
"description": "通过共享链接上传文件",
|
||||
"descriptionWithLimit": "上传文件(最多 {limit} 个文件)"
|
||||
},
|
||||
"layout": {
|
||||
"defaultTitle": "上传文件",
|
||||
@@ -1106,7 +1170,10 @@
|
||||
},
|
||||
"searchBar": {
|
||||
"placeholder": "搜索文件...",
|
||||
"results": "共{total}个文件,找到{filtered}个"
|
||||
"results": "共{total}个文件,找到{filtered}个",
|
||||
"placeholderFolders": "搜索文件夹...",
|
||||
"noResults": "未找到 \"{query}\" 的搜索结果",
|
||||
"placeholderFiles": "搜索文件..."
|
||||
},
|
||||
"settings": {
|
||||
"groups": {
|
||||
@@ -1235,6 +1302,10 @@
|
||||
"passwordAuthEnabled": {
|
||||
"title": "密码认证",
|
||||
"description": "启用或禁用基于密码的认证"
|
||||
},
|
||||
"hideVersion": {
|
||||
"title": "隐藏版本",
|
||||
"description": "在所有页面的页脚中隐藏Palmr版本"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1296,7 +1367,11 @@
|
||||
"description": "该共享可能已被删除或已过期。"
|
||||
},
|
||||
"pageTitle": "共享",
|
||||
"downloadAll": "下载所有"
|
||||
"downloadAll": "下载所有",
|
||||
"metadata": {
|
||||
"defaultDescription": "安全共享文件",
|
||||
"filesShared": "{count, plural, =1 {已共享 1 个文件} other {已共享 # 个文件}}"
|
||||
}
|
||||
},
|
||||
"shareActions": {
|
||||
"deleteTitle": "删除共享",
|
||||
@@ -1320,7 +1395,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 +1532,8 @@
|
||||
"files": "文件",
|
||||
"totalSize": "总大小",
|
||||
"creating": "创建中...",
|
||||
"create": "创建分享"
|
||||
"create": "创建分享",
|
||||
"itemsToShare": "要分享的项目({count} 个项目)"
|
||||
},
|
||||
"shareSecurity": {
|
||||
"subtitle": "为此分享配置密码保护和安全选项",
|
||||
@@ -1552,7 +1638,8 @@
|
||||
"download": "选择下载"
|
||||
},
|
||||
"selectAll": "全选",
|
||||
"selectShare": "选择共享 {shareName}"
|
||||
"selectShare": "选择共享 {shareName}",
|
||||
"folderCount": "文件夹"
|
||||
},
|
||||
"storageUsage": {
|
||||
"title": "存储使用情况",
|
||||
@@ -1609,7 +1696,11 @@
|
||||
"copyToClipboard": "复制到剪贴板",
|
||||
"savedMessage": "我已保存备用码",
|
||||
"available": "可用备用码:{count}个",
|
||||
"instructions": ["• 将这些代码保存在安全的位置", "• 每个备用码只能使用一次", "• 您可以随时生成新的备用码"]
|
||||
"instructions": [
|
||||
"• 将这些代码保存在安全的位置",
|
||||
"• 每个备用码只能使用一次",
|
||||
"• 您可以随时生成新的备用码"
|
||||
]
|
||||
},
|
||||
"verification": {
|
||||
"title": "双重认证",
|
||||
@@ -1842,5 +1933,17 @@
|
||||
"passwordRequired": "密码为必填项",
|
||||
"nameRequired": "名称为必填项",
|
||||
"required": "此字段为必填项"
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "嵌入图片",
|
||||
"description": "使用这些代码将此图片嵌入到论坛、网站或其他平台中",
|
||||
"tabs": {
|
||||
"directLink": "直接链接",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "图片文件的直接URL",
|
||||
"htmlDescription": "使用此代码将图片嵌入HTML页面",
|
||||
"bbcodeDescription": "使用此代码将图片嵌入支持BBCode的论坛"
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "palmr-web",
|
||||
"version": "3.2.0-beta",
|
||||
"version": "3.2.5-beta",
|
||||
"description": "Frontend for Palmr",
|
||||
"private": true,
|
||||
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
|
||||
@@ -11,7 +11,7 @@
|
||||
"react",
|
||||
"typescript"
|
||||
],
|
||||
"license": "BSD-2-Clause Copyright 2023 Kyantech",
|
||||
"license": "Apache-2.0",
|
||||
"packageManager": "pnpm@10.6.0",
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
@@ -100,4 +100,4 @@
|
||||
"tailwindcss": "4.1.11",
|
||||
"typescript": "5.8.3"
|
||||
}
|
||||
}
|
||||
}
|
@@ -150,6 +150,18 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
|
||||
return fileName.split(".").pop() || "";
|
||||
};
|
||||
|
||||
const calculateUploadTimeout = (fileSize: number): number => {
|
||||
const baseTimeout = 300000;
|
||||
const fileSizeMB = fileSize / (1024 * 1024);
|
||||
if (fileSizeMB > 500) {
|
||||
const extraMB = fileSizeMB - 500;
|
||||
const extraMinutes = Math.ceil(extraMB / 100);
|
||||
return baseTimeout + extraMinutes * 60000;
|
||||
}
|
||||
|
||||
return baseTimeout;
|
||||
};
|
||||
|
||||
const uploadFileToStorage = async (
|
||||
file: File,
|
||||
presignedUrl: string,
|
||||
@@ -172,10 +184,14 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
|
||||
throw new Error(result.error || "Chunked upload failed");
|
||||
}
|
||||
} else {
|
||||
const uploadTimeout = calculateUploadTimeout(file.size);
|
||||
await axios.put(presignedUrl, file, {
|
||||
headers: {
|
||||
"Content-Type": file.type,
|
||||
},
|
||||
timeout: uploadTimeout,
|
||||
maxContentLength: Infinity,
|
||||
maxBodyLength: Infinity,
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (onProgress && progressEvent.total) {
|
||||
const progress = (progressEvent.loaded / progressEvent.total) * 100;
|
||||
|
@@ -1,12 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useSecureConfigValue } from "@/hooks/use-secure-configs";
|
||||
import packageJson from "../../../../../../package.json";
|
||||
|
||||
const { version } = packageJson;
|
||||
|
||||
export function TransparentFooter() {
|
||||
const t = useTranslations();
|
||||
const { value: hideVersion } = useSecureConfigValue("hideVersion");
|
||||
|
||||
const shouldHideVersion = hideVersion === "true";
|
||||
|
||||
return (
|
||||
<footer className="absolute bottom-0 left-0 right-0 z-50 w-full flex items-center justify-center py-3 h-16 pointer-events-none">
|
||||
@@ -22,7 +28,7 @@ export function TransparentFooter() {
|
||||
Kyantech Solutions
|
||||
</p>
|
||||
</Link>
|
||||
<span className="text-white text-[11px] mt-1">v{version}</span>
|
||||
{!shouldHideVersion && <span className="text-white text-[11px] mt-1">v{version}</span>}
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
@@ -1,12 +1,91 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
async function getReverseShareMetadata(alias: string) {
|
||||
try {
|
||||
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
|
||||
const response = await fetch(`${API_BASE_URL}/reverse-shares/alias/${alias}/metadata`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("Error fetching reverse share metadata:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getAppInfo() {
|
||||
try {
|
||||
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
|
||||
const response = await fetch(`${API_BASE_URL}/app/info`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return { appName: "Palmr", appDescription: "File sharing platform", appLogo: null };
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("Error fetching app info:", error);
|
||||
return { appName: "Palmr", appDescription: "File sharing platform", appLogo: null };
|
||||
}
|
||||
}
|
||||
|
||||
async function getBaseUrl(): Promise<string> {
|
||||
const headersList = await headers();
|
||||
const protocol = headersList.get("x-forwarded-proto") || "http";
|
||||
const host = headersList.get("x-forwarded-host") || headersList.get("host") || "localhost:3000";
|
||||
return `${protocol}://${host}`;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: { alias: string } }): Promise<Metadata> {
|
||||
const t = await getTranslations();
|
||||
const metadata = await getReverseShareMetadata(params.alias);
|
||||
const appInfo = await getAppInfo();
|
||||
|
||||
const title = metadata?.name || t("reverseShares.upload.metadata.title");
|
||||
const description =
|
||||
metadata?.description ||
|
||||
(metadata?.maxFiles
|
||||
? t("reverseShares.upload.metadata.descriptionWithLimit", { limit: metadata.maxFiles })
|
||||
: t("reverseShares.upload.metadata.description"));
|
||||
|
||||
const baseUrl = await getBaseUrl();
|
||||
const shareUrl = `${baseUrl}/r/${params.alias}`;
|
||||
|
||||
return {
|
||||
title: t("reverseShares.upload.metadata.title"),
|
||||
description: t("reverseShares.upload.metadata.description"),
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: shareUrl,
|
||||
siteName: appInfo.appName || "Palmr",
|
||||
type: "website",
|
||||
images: appInfo.appLogo
|
||||
? [
|
||||
{
|
||||
url: appInfo.appLogo,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: appInfo.appName || "Palmr",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title,
|
||||
description,
|
||||
images: appInfo.appLogo ? [appInfo.appLogo] : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
@@ -120,10 +120,11 @@ function mapReverseShareToFormData(reverseShare: ReverseShare): EditReverseShare
|
||||
pageLayout: (reverseShare.pageLayout as "DEFAULT" | "WETRANSFER") || DEFAULT_VALUES.PAGE_LAYOUT,
|
||||
nameFieldRequired: (reverseShare.nameFieldRequired as "HIDDEN" | "OPTIONAL" | "REQUIRED") || "OPTIONAL",
|
||||
emailFieldRequired: (reverseShare.emailFieldRequired as "HIDDEN" | "OPTIONAL" | "REQUIRED") || "OPTIONAL",
|
||||
hasExpiration: false,
|
||||
hasFileLimits: false,
|
||||
hasFieldRequirements: false,
|
||||
hasPassword: false,
|
||||
hasExpiration: !!reverseShare.expiration,
|
||||
hasFileLimits: !!(reverseShare.maxFiles || reverseShare.maxFileSize || reverseShare.allowedFileTypes),
|
||||
hasFieldRequirements:
|
||||
reverseShare.nameFieldRequired !== "OPTIONAL" || reverseShare.emailFieldRequired !== "OPTIONAL",
|
||||
hasPassword: reverseShare.hasPassword,
|
||||
password: DEFAULT_VALUES.EMPTY_STRING,
|
||||
isActive: reverseShare.isActive,
|
||||
noFilesLimit: !reverseShare.maxFiles,
|
||||
|
@@ -900,16 +900,7 @@ export function ReceivedFilesModal({
|
||||
</Dialog>
|
||||
|
||||
{previewFile && (
|
||||
<ReverseShareFilePreviewModal
|
||||
isOpen={!!previewFile}
|
||||
onClose={() => setPreviewFile(null)}
|
||||
file={{
|
||||
id: previewFile.id,
|
||||
name: previewFile.name,
|
||||
objectName: previewFile.objectName,
|
||||
extension: previewFile.extension,
|
||||
}}
|
||||
/>
|
||||
<ReverseShareFilePreviewModal isOpen={!!previewFile} onClose={() => setPreviewFile(null)} file={previewFile} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
@@ -7,23 +7,11 @@ import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { deleteReverseShareFile } from "@/http/endpoints/reverse-shares";
|
||||
import type { ReverseShareFile } from "@/http/endpoints/reverse-shares/types";
|
||||
import { downloadReverseShareWithQueue } from "@/utils/download-queue-utils";
|
||||
import { getFileIcon } from "@/utils/file-icons";
|
||||
import { ReverseShareFilePreviewModal } from "./reverse-share-file-preview-modal";
|
||||
|
||||
interface ReverseShareFile {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
extension: string;
|
||||
size: string;
|
||||
objectName: string;
|
||||
uploaderEmail: string | null;
|
||||
uploaderName: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface ReceivedFilesSectionProps {
|
||||
files: ReverseShareFile[];
|
||||
onFileDeleted?: () => void;
|
||||
@@ -159,16 +147,7 @@ export function ReceivedFilesSection({ files, onFileDeleted }: ReceivedFilesSect
|
||||
</div>
|
||||
|
||||
{previewFile && (
|
||||
<ReverseShareFilePreviewModal
|
||||
isOpen={!!previewFile}
|
||||
onClose={() => setPreviewFile(null)}
|
||||
file={{
|
||||
id: previewFile.id,
|
||||
name: previewFile.name,
|
||||
objectName: previewFile.objectName,
|
||||
extension: previewFile.extension,
|
||||
}}
|
||||
/>
|
||||
<ReverseShareFilePreviewModal isOpen={!!previewFile} onClose={() => setPreviewFile(null)} file={previewFile} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
@@ -1,26 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { FilePreviewModal } from "@/components/modals/file-preview-modal";
|
||||
import type { ReverseShareFile } from "@/http/endpoints/reverse-shares/types";
|
||||
|
||||
interface ReverseShareFilePreviewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
file: {
|
||||
id: string;
|
||||
name: string;
|
||||
objectName: string;
|
||||
extension?: string;
|
||||
} | null;
|
||||
file: ReverseShareFile | null;
|
||||
}
|
||||
|
||||
export function ReverseShareFilePreviewModal({ isOpen, onClose, file }: ReverseShareFilePreviewModalProps) {
|
||||
if (!file) return null;
|
||||
|
||||
const adaptedFile = {
|
||||
name: file.name,
|
||||
objectName: file.objectName,
|
||||
type: file.extension,
|
||||
id: file.id,
|
||||
...file,
|
||||
description: file.description ?? undefined,
|
||||
};
|
||||
|
||||
return <FilePreviewModal isOpen={isOpen} onClose={onClose} file={adaptedFile} isReverseShare={true} />;
|
||||
|
@@ -27,13 +27,13 @@ export function ReverseSharesSearch({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
|
||||
<h2 className="text-xl font-semibold">{t("reverseShares.search.title")}</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||
<Button variant="outline" size="icon" onClick={onRefresh} disabled={isRefreshing}>
|
||||
<IconRefresh className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
<Button onClick={onCreateReverseShare}>
|
||||
<Button onClick={onCreateReverseShare} className="w-full sm:w-auto">
|
||||
<IconPlus className="h-4 w-4" />
|
||||
{t("reverseShares.search.createButton")}
|
||||
</Button>
|
||||
|
@@ -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,41 @@ import { Button } from "@/components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { getFileIcon } from "@/utils/file-icons";
|
||||
import { formatFileSize } from "@/utils/format-file-size";
|
||||
import { ShareFilesTableProps } from "../types";
|
||||
|
||||
export function ShareFilesTable({ files, onDownload }: ShareFilesTableProps) {
|
||||
interface ShareFile {
|
||||
id: string;
|
||||
name: string;
|
||||
size: string | number;
|
||||
objectName: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ShareFolder {
|
||||
id: string;
|
||||
name: string;
|
||||
totalSize?: string | number | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ShareFilesTableProps {
|
||||
files?: ShareFile[];
|
||||
folders?: ShareFolder[];
|
||||
onDownload: (objectName: string, fileName: string) => Promise<void>;
|
||||
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
|
||||
onNavigateToFolder?: (folderId: string) => void;
|
||||
enableNavigation?: boolean;
|
||||
sharePassword?: string;
|
||||
}
|
||||
|
||||
export function ShareFilesTable({
|
||||
files = [],
|
||||
folders = [],
|
||||
onDownload,
|
||||
onDownloadFolder,
|
||||
onNavigateToFolder,
|
||||
enableNavigation = false,
|
||||
sharePassword,
|
||||
}: ShareFilesTableProps) {
|
||||
const t = useTranslations();
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<{ name: string; objectName: string; type?: string } | null>(null);
|
||||
@@ -36,6 +68,25 @@ export function ShareFilesTable({ files, onDownload }: ShareFilesTableProps) {
|
||||
setSelectedFile(null);
|
||||
};
|
||||
|
||||
const handleFolderClick = (folderId: string) => {
|
||||
if (enableNavigation && onNavigateToFolder) {
|
||||
onNavigateToFolder(folderId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFolderDownload = async (folderId: string, folderName: string) => {
|
||||
if (enableNavigation && onDownloadFolder) {
|
||||
await onDownloadFolder(folderId, folderName);
|
||||
} else {
|
||||
await onDownload(`folder:${folderId}`, folderName);
|
||||
}
|
||||
};
|
||||
|
||||
const allItems = [
|
||||
...folders.map((folder) => ({ ...folder, type: "folder" as const })),
|
||||
...files.map((file) => ({ ...file, type: "file" as const })),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded-lg border shadow-sm overflow-hidden">
|
||||
@@ -57,49 +108,123 @@ export function ShareFilesTable({ files, onDownload }: ShareFilesTableProps) {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{files.map((file) => {
|
||||
const { icon: FileIcon, color } = getFileIcon(file.name);
|
||||
|
||||
return (
|
||||
<TableRow key={file.id} className="hover:bg-muted/50 transition-colors border-0">
|
||||
<TableCell className="h-12 px-4 border-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileIcon className={`h-5 w-5 ${color}`} />
|
||||
<span className="truncate max-w-[250px] font-medium">{file.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="h-12 px-4">{formatFileSize(Number(file.size))}</TableCell>
|
||||
<TableCell className="h-12 px-4">{formatDateTime(file.createdAt)}</TableCell>
|
||||
<TableCell className="h-12 px-4">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 hover:bg-muted"
|
||||
onClick={() => handlePreview({ name: file.name, objectName: file.objectName })}
|
||||
>
|
||||
<IconEye className="h-4 w-4" />
|
||||
<span className="sr-only">{t("filesTable.actions.preview")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 hover:bg-muted"
|
||||
onClick={() => onDownload(file.objectName, file.name)}
|
||||
>
|
||||
<IconDownload className="h-4 w-4" />
|
||||
<span className="sr-only">{t("filesTable.actions.download")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{allItems.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="h-32 text-center text-muted-foreground">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<IconFolderOpen className="h-16 w-16 text-muted-foreground/50" />
|
||||
<p className="font-medium">
|
||||
{enableNavigation ? "No files or folders" : "No files or folders shared"}
|
||||
</p>
|
||||
<p className="text-sm">{enableNavigation ? "This location is empty" : "This share is empty"}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
allItems.map((item) => {
|
||||
if (item.type === "folder") {
|
||||
return (
|
||||
<TableRow key={`folder-${item.id}`} className="hover:bg-muted/50 transition-colors border-0">
|
||||
<TableCell className="h-12 px-4 border-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconFolder className="h-5 w-5 text-blue-600" />
|
||||
{enableNavigation ? (
|
||||
<button
|
||||
className="truncate max-w-[250px] font-medium text-left hover:underline"
|
||||
onClick={() => handleFolderClick(item.id)}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
) : (
|
||||
<span className="truncate max-w-[250px] font-medium">{item.name}</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="h-12 px-4">
|
||||
{item.totalSize ? formatFileSize(Number(item.totalSize)) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="h-12 px-4">{formatDateTime(item.createdAt)}</TableCell>
|
||||
<TableCell className="h-12 px-4">
|
||||
<div className="flex items-center gap-1">
|
||||
{enableNavigation && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 hover:bg-muted"
|
||||
onClick={() => handleFolderClick(item.id)}
|
||||
title="Open folder"
|
||||
>
|
||||
<IconFolder className="h-4 w-4" />
|
||||
<span className="sr-only">Open folder</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 hover:bg-muted"
|
||||
onClick={() => handleFolderDownload(item.id, item.name)}
|
||||
title={t("filesTable.actions.download")}
|
||||
>
|
||||
<IconDownload className="h-4 w-4" />
|
||||
<span className="sr-only">Download folder</span>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
} else {
|
||||
const { icon: FileIcon, color } = getFileIcon(item.name);
|
||||
return (
|
||||
<TableRow key={`file-${item.id}`} className="hover:bg-muted/50 transition-colors border-0">
|
||||
<TableCell className="h-12 px-4 border-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileIcon className={`h-5 w-5 ${color}`} />
|
||||
<span className="truncate max-w-[250px] font-medium">{item.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="h-12 px-4">{formatFileSize(Number(item.size))}</TableCell>
|
||||
<TableCell className="h-12 px-4">{formatDateTime(item.createdAt)}</TableCell>
|
||||
<TableCell className="h-12 px-4">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 hover:bg-muted"
|
||||
onClick={() => handlePreview({ name: item.name, objectName: item.objectName })}
|
||||
>
|
||||
<IconEye className="h-4 w-4" />
|
||||
<span className="sr-only">{t("filesTable.actions.preview")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 hover:bg-muted"
|
||||
onClick={() => onDownload(item.objectName, item.name)}
|
||||
>
|
||||
<IconDownload className="h-4 w-4" />
|
||||
<span className="sr-only">{t("filesTable.actions.download")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{selectedFile && <FilePreviewModal isOpen={isPreviewOpen} onClose={handleClosePreview} file={selectedFile} />}
|
||||
{selectedFile && (
|
||||
<FilePreviewModal
|
||||
isOpen={isPreviewOpen}
|
||||
onClose={handleClosePreview}
|
||||
file={selectedFile}
|
||||
sharePassword={sharePassword}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ShareContentTable = ShareFilesTable;
|
||||
|
@@ -2,7 +2,7 @@ import { IconLock } from "@tabler/icons-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader } from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PasswordModalProps } from "../types";
|
||||
|
||||
@@ -13,7 +13,7 @@ export function PasswordModal({ isOpen, password, isError, onPasswordChange, onS
|
||||
<Dialog open={isOpen} onOpenChange={() => {}} modal>
|
||||
<DialogContent>
|
||||
<DialogHeader className="flex flex-col gap-1">
|
||||
<h2>{t("share.password.title")}</h2>
|
||||
<DialogTitle>{t("share.password.title")}</DialogTitle>
|
||||
<div className="flex items-center gap-2 text-warning text-sm">
|
||||
<IconLock size={16} />
|
||||
<p>{t("share.password.protected")}</p>
|
||||
|
@@ -1,54 +1,195 @@
|
||||
import { IconDownload, IconShare } from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import { IconDownload, IconFolderOff, IconShare } from "@tabler/icons-react";
|
||||
import { format } from "date-fns";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { FilesViewManager } from "@/app/files/components/files-view-manager";
|
||||
import { FilePreviewModal } from "@/components/modals/file-preview-modal";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ShareDetailsProps } from "../types";
|
||||
import { ShareFilesTable } from "./files-table";
|
||||
|
||||
export function ShareDetails({ share, onDownload, onBulkDownload }: ShareDetailsProps) {
|
||||
interface File {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
extension: string;
|
||||
size: number;
|
||||
objectName: string;
|
||||
userId: string;
|
||||
folderId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface Folder {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
objectName: string;
|
||||
parentId?: string;
|
||||
userId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
totalSize?: string;
|
||||
_count?: {
|
||||
files: number;
|
||||
children: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface ShareDetailsPropsExtended extends Omit<ShareDetailsProps, "onBulkDownload"> {
|
||||
onBulkDownload?: () => Promise<void>;
|
||||
onSelectedItemsBulkDownload?: (files: File[], folders: Folder[]) => Promise<void>;
|
||||
folders: Folder[];
|
||||
files: File[];
|
||||
path: Folder[];
|
||||
isBrowseLoading: boolean;
|
||||
searchQuery: string;
|
||||
navigateToFolder: (folderId?: string) => void;
|
||||
handleSearch: (query: string) => void;
|
||||
}
|
||||
|
||||
export function ShareDetails({
|
||||
share,
|
||||
password,
|
||||
onDownload,
|
||||
onBulkDownload,
|
||||
onSelectedItemsBulkDownload,
|
||||
folders,
|
||||
files,
|
||||
path,
|
||||
isBrowseLoading,
|
||||
searchQuery,
|
||||
navigateToFolder,
|
||||
handleSearch,
|
||||
}: ShareDetailsPropsExtended) {
|
||||
const t = useTranslations();
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<{ name: string; objectName: string; type?: string } | null>(null);
|
||||
|
||||
const hasMultipleFiles = share.files && share.files.length > 1;
|
||||
const shareHasItems = (share.files && share.files.length > 0) || (share.folders && share.folders.length > 0);
|
||||
const totalShareItems = (share.files?.length || 0) + (share.folders?.length || 0);
|
||||
const hasMultipleFiles = totalShareItems > 1;
|
||||
|
||||
const handleFolderDownload = async (folderId: string, folderName: string) => {
|
||||
// Use the download handler from the hook which uses toast.promise
|
||||
await onDownload(`folder:${folderId}`, folderName);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconShare className="w-6 h-6 text-muted-foreground" />
|
||||
<h1 className="text-2xl font-semibold">{share.name || t("share.details.untitled")}</h1>
|
||||
<>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconShare className="w-6 h-6 text-muted-foreground" />
|
||||
<h1 className="text-2xl font-semibold">{share.name || t("share.details.untitled")}</h1>
|
||||
</div>
|
||||
{shareHasItems && hasMultipleFiles && (
|
||||
<Button onClick={onBulkDownload} className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<IconDownload className="w-4 h-4" />
|
||||
{t("share.downloadAll")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{hasMultipleFiles && (
|
||||
<Button onClick={onBulkDownload} className="flex items-center gap-2">
|
||||
<IconDownload className="w-4 h-4" />
|
||||
{t("share.downloadAll")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{share.description && <p className="text-muted-foreground">{share.description}</p>}
|
||||
<div className="flex gap-4 text-sm text-muted-foreground">
|
||||
<span>
|
||||
{t("share.details.created", {
|
||||
date: format(new Date(share.createdAt), "MM/dd/yyyy HH:mm"),
|
||||
})}
|
||||
</span>
|
||||
{share.expiration && (
|
||||
{share.description && <p className="text-muted-foreground">{share.description}</p>}
|
||||
<div className="flex gap-4 text-sm text-muted-foreground">
|
||||
<span>
|
||||
{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"),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{share.expiration && (
|
||||
<span>
|
||||
{t("share.details.expires", {
|
||||
date: format(new Date(share.expiration), "MM/dd/yyyy HH:mm"),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ShareFilesTable files={share.files} onDownload={onDownload} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FilesViewManager
|
||||
files={files}
|
||||
folders={folders}
|
||||
searchQuery={searchQuery}
|
||||
onSearch={handleSearch}
|
||||
onDownload={onDownload}
|
||||
onBulkDownload={onSelectedItemsBulkDownload}
|
||||
isLoading={isBrowseLoading}
|
||||
isShareMode={true}
|
||||
emptyStateComponent={() => (
|
||||
<div className="text-center py-16">
|
||||
<div className="flex justify-center mb-6">
|
||||
<IconFolderOff className="h-24 w-24 text-muted-foreground/30" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">{t("fileSelector.noFilesInShare")}</h3>
|
||||
<p className="text-muted-foreground max-w-sm mx-auto">{t("files.empty.description")}</p>
|
||||
</div>
|
||||
)}
|
||||
breadcrumbs={
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink
|
||||
className="flex items-center gap-1 cursor-pointer"
|
||||
onClick={() => navigateToFolder()}
|
||||
>
|
||||
<IconShare size={16} />
|
||||
{t("folderActions.rootFolder")}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
|
||||
{path.map((folder, index) => (
|
||||
<div key={folder.id} className="contents">
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
{index === path.length - 1 ? (
|
||||
<BreadcrumbPage>{folder.name}</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink className="cursor-pointer" onClick={() => navigateToFolder(folder.id)}>
|
||||
{folder.name}
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
</div>
|
||||
))}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
}
|
||||
onNavigateToFolder={navigateToFolder}
|
||||
onDownloadFolder={handleFolderDownload}
|
||||
onPreview={(file) => {
|
||||
setSelectedFile({ name: file.name, objectName: file.objectName });
|
||||
setIsPreviewOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selectedFile && (
|
||||
<FilePreviewModal
|
||||
isOpen={isPreviewOpen}
|
||||
onClose={() => {
|
||||
setIsPreviewOpen(false);
|
||||
setSelectedFile(null);
|
||||
}}
|
||||
file={selectedFile}
|
||||
sharePassword={password}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@@ -1,17 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getShareByAlias } from "@/http/endpoints";
|
||||
import { getShareByAlias } from "@/http/endpoints/index";
|
||||
import type { Share } from "@/http/endpoints/shares/types";
|
||||
import { bulkDownloadWithQueue, downloadFileWithQueue } from "@/utils/download-queue-utils";
|
||||
import {
|
||||
bulkDownloadShareWithQueue,
|
||||
downloadFileWithQueue,
|
||||
downloadShareFolderWithQueue,
|
||||
} from "@/utils/download-queue-utils";
|
||||
|
||||
const createSlug = (name: string): string => {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, "")
|
||||
.replace(/[\s_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
};
|
||||
|
||||
const createFolderPathSlug = (allFolders: any[], folderId: string): string => {
|
||||
const path: string[] = [];
|
||||
let currentId: string | null = folderId;
|
||||
|
||||
while (currentId) {
|
||||
const folder = allFolders.find((f) => f.id === currentId);
|
||||
if (folder) {
|
||||
const slug = createSlug(folder.name);
|
||||
path.unshift(slug || folder.id);
|
||||
currentId = folder.parentId;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return path.join("/");
|
||||
};
|
||||
|
||||
const findFolderByPathSlug = (folders: any[], pathSlug: string): any | null => {
|
||||
const pathParts = pathSlug.split("/");
|
||||
let currentFolders = folders.filter((f) => !f.parentId);
|
||||
let currentFolder: any = null;
|
||||
|
||||
for (const slugPart of pathParts) {
|
||||
currentFolder = currentFolders.find((folder) => {
|
||||
const slug = createSlug(folder.name);
|
||||
return slug === slugPart || folder.id === slugPart;
|
||||
});
|
||||
|
||||
if (!currentFolder) return null;
|
||||
currentFolders = folders.filter((f) => f.parentId === currentFolder.id);
|
||||
}
|
||||
|
||||
return currentFolder;
|
||||
};
|
||||
|
||||
interface ShareBrowseState {
|
||||
folders: any[];
|
||||
files: any[];
|
||||
path: any[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function usePublicShare() {
|
||||
const t = useTranslations();
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const alias = params?.alias as string;
|
||||
const [share, setShare] = useState<Share | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -19,6 +78,28 @@ export function usePublicShare() {
|
||||
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false);
|
||||
const [isPasswordError, setIsPasswordError] = useState(false);
|
||||
|
||||
const [browseState, setBrowseState] = useState<ShareBrowseState>({
|
||||
folders: [],
|
||||
files: [],
|
||||
path: [],
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
const urlFolderSlug = searchParams.get("folder") || null;
|
||||
const [currentFolderId, setCurrentFolderId] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const getFolderIdFromPathSlug = useCallback((pathSlug: string | null, folders: any[]): string | null => {
|
||||
if (!pathSlug) return null;
|
||||
const folder = findFolderByPathSlug(folders, pathSlug);
|
||||
return folder ? folder.id : null;
|
||||
}, []);
|
||||
|
||||
const getFolderPathSlugFromId = useCallback((folderId: string | null, folders: any[]): string | null => {
|
||||
if (!folderId) return null;
|
||||
return createFolderPathSlug(folders, folderId);
|
||||
}, []);
|
||||
|
||||
const loadShare = useCallback(
|
||||
async (sharePassword?: string) => {
|
||||
if (!alias) return;
|
||||
@@ -51,39 +132,205 @@ 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,
|
||||
sharePassword: password,
|
||||
});
|
||||
} 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,
|
||||
sharePassword: password,
|
||||
}),
|
||||
{
|
||||
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,
|
||||
password
|
||||
).then(() => {}),
|
||||
{
|
||||
loading: t("shareManager.creatingZip"),
|
||||
success: t("shareManager.zipDownloadSuccess"),
|
||||
@@ -95,11 +342,106 @@ export function usePublicShare() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectedItemsBulkDownload = async (files: any[], folders: any[]) => {
|
||||
if (files.length === 0 && folders.length === 0) {
|
||||
toast.error(t("shareManager.noFilesToDownload"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!share) {
|
||||
toast.error(t("share.errors.loadFailed"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get all file IDs that belong to selected folders
|
||||
const filesInSelectedFolders = new Set<string>();
|
||||
for (const folder of folders) {
|
||||
const folderFiles = share.files?.filter((f) => f.folderId === folder.id) || [];
|
||||
folderFiles.forEach((f) => filesInSelectedFolders.add(f.id));
|
||||
|
||||
// Also check nested folders recursively
|
||||
const checkNestedFolders = (parentId: string) => {
|
||||
const nestedFolders = share.folders?.filter((f) => f.parentId === parentId) || [];
|
||||
for (const nestedFolder of nestedFolders) {
|
||||
const nestedFiles = share.files?.filter((f) => f.folderId === nestedFolder.id) || [];
|
||||
nestedFiles.forEach((f) => filesInSelectedFolders.add(f.id));
|
||||
checkNestedFolders(nestedFolder.id);
|
||||
}
|
||||
};
|
||||
checkNestedFolders(folder.id);
|
||||
}
|
||||
|
||||
const allItems = [
|
||||
...files
|
||||
.filter((file) => !filesInSelectedFolders.has(file.id))
|
||||
.map((file) => ({
|
||||
objectName: file.objectName,
|
||||
name: file.name,
|
||||
type: "file" as const,
|
||||
})),
|
||||
// Add only top-level folders (avoid duplicating nested folders)
|
||||
...folders
|
||||
.filter((folder) => {
|
||||
return !folder.parentId || !folders.some((f) => f.id === folder.parentId);
|
||||
})
|
||||
.map((folder) => ({
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
type: "folder" as const,
|
||||
})),
|
||||
];
|
||||
|
||||
const zipName = `${share.name || t("shareManager.defaultShareName")}-selected.zip`;
|
||||
|
||||
toast.promise(
|
||||
bulkDownloadShareWithQueue(
|
||||
allItems,
|
||||
share.files || [],
|
||||
share.folders || [],
|
||||
zipName,
|
||||
undefined,
|
||||
false,
|
||||
password
|
||||
).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 +451,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),
|
||||
};
|
||||
}
|
||||
|
@@ -1,15 +1,96 @@
|
||||
import { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
params: { alias: string };
|
||||
}
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
async function getShareMetadata(alias: string) {
|
||||
try {
|
||||
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
|
||||
const response = await fetch(`${API_BASE_URL}/shares/alias/${alias}/metadata`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("Error fetching share metadata:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getAppInfo() {
|
||||
try {
|
||||
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
|
||||
const response = await fetch(`${API_BASE_URL}/app/info`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return { appName: "Palmr", appDescription: "File sharing platform", appLogo: null };
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("Error fetching app info:", error);
|
||||
return { appName: "Palmr", appDescription: "File sharing platform", appLogo: null };
|
||||
}
|
||||
}
|
||||
|
||||
async function getBaseUrl(): Promise<string> {
|
||||
const headersList = await headers();
|
||||
const protocol = headersList.get("x-forwarded-proto") || "http";
|
||||
const host = headersList.get("x-forwarded-host") || headersList.get("host") || "localhost:3000";
|
||||
return `${protocol}://${host}`;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: { alias: string } }): Promise<Metadata> {
|
||||
const t = await getTranslations();
|
||||
const metadata = await getShareMetadata(params.alias);
|
||||
const appInfo = await getAppInfo();
|
||||
|
||||
const title = metadata?.name || t("share.pageTitle");
|
||||
const description =
|
||||
metadata?.description ||
|
||||
(metadata?.totalFiles
|
||||
? t("share.metadata.filesShared", { count: metadata.totalFiles + (metadata.totalFolders || 0) })
|
||||
: appInfo.appDescription || t("share.metadata.defaultDescription"));
|
||||
|
||||
const baseUrl = await getBaseUrl();
|
||||
const shareUrl = `${baseUrl}/s/${params.alias}`;
|
||||
|
||||
return {
|
||||
title: `${t("share.pageTitle")}`,
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: shareUrl,
|
||||
siteName: appInfo.appName || "Palmr",
|
||||
type: "website",
|
||||
images: appInfo.appLogo
|
||||
? [
|
||||
{
|
||||
url: appInfo.appLogo,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: appInfo.appName || "Palmr",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title,
|
||||
description,
|
||||
images: appInfo.appLogo ? [appInfo.appLogo] : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
@@ -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,22 @@ export default function PublicSharePage() {
|
||||
<main className="flex-1 container mx-auto px-6 py-8">
|
||||
<div className="max-w-5xl mx-auto space-y-6">
|
||||
{!isPasswordModalOpen && !share && <ShareNotFound />}
|
||||
{share && <ShareDetails share={share} onDownload={handleDownload} onBulkDownload={handleBulkDownload} />}
|
||||
{share && (
|
||||
<ShareDetails
|
||||
share={share}
|
||||
password={password}
|
||||
onDownload={handleDownload}
|
||||
onBulkDownload={handleBulkDownload}
|
||||
onSelectedItemsBulkDownload={handleSelectedItemsBulkDownload}
|
||||
folders={folders}
|
||||
files={files}
|
||||
path={path}
|
||||
isBrowseLoading={isBrowseLoading}
|
||||
searchQuery={searchQuery}
|
||||
navigateToFolder={navigateToFolder}
|
||||
handleSearch={handleSearch}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
@@ -8,11 +8,24 @@ export interface ShareFile {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ShareFilesTableProps {
|
||||
files: ShareFile[];
|
||||
onDownload: (objectName: string, fileName: string) => Promise<void>;
|
||||
export interface ShareFolder {
|
||||
id: string;
|
||||
name: string;
|
||||
totalSize: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ShareFilesTableProps {
|
||||
files?: ShareFile[];
|
||||
folders?: ShareFolder[];
|
||||
onDownload: (objectName: string, fileName: string) => Promise<void>;
|
||||
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
|
||||
onNavigateToFolder?: (folderId: string) => void;
|
||||
enableNavigation?: boolean;
|
||||
}
|
||||
|
||||
export type ShareContentTableProps = ShareFilesTableProps;
|
||||
|
||||
export interface PasswordModalProps {
|
||||
isOpen: boolean;
|
||||
password: string;
|
||||
@@ -23,6 +36,7 @@ export interface PasswordModalProps {
|
||||
|
||||
export interface ShareDetailsProps {
|
||||
share: Share;
|
||||
password?: string;
|
||||
onDownload: (objectName: string, fileName: string) => Promise<void>;
|
||||
onBulkDownload?: () => Promise<void>;
|
||||
}
|
||||
|
@@ -8,8 +8,10 @@ import { QrCodeModal } from "@/components/modals/qr-code-modal";
|
||||
import { ShareActionsModals } from "@/components/modals/share-actions-modals";
|
||||
import { ShareDetailsModal } from "@/components/modals/share-details-modal";
|
||||
import { ShareExpirationModal } from "@/components/modals/share-expiration-modal";
|
||||
import { ShareMultipleFilesModal } from "@/components/modals/share-multiple-files-modal";
|
||||
import { ShareMultipleItemsModal } from "@/components/modals/share-multiple-items-modal";
|
||||
import { ShareSecurityModal } from "@/components/modals/share-security-modal";
|
||||
import { listFiles } from "@/http/endpoints";
|
||||
import { listFolders } from "@/http/endpoints/folders";
|
||||
import { SharesModalsProps } from "../types";
|
||||
|
||||
export function SharesModals({
|
||||
@@ -38,7 +40,18 @@ export function SharesModals({
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateShareModal isOpen={isCreateModalOpen} onClose={onCloseCreateModal} onSuccess={handleShareSuccess} />
|
||||
<CreateShareModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={onCloseCreateModal}
|
||||
onSuccess={onSuccess}
|
||||
getAllFilesAndFolders={async () => {
|
||||
const [filesResponse, foldersResponse] = await Promise.all([listFiles(), listFolders()]);
|
||||
return {
|
||||
files: filesResponse.data.files || [],
|
||||
folders: foldersResponse.data.folders || [],
|
||||
};
|
||||
}}
|
||||
/>
|
||||
|
||||
<ShareActionsModals
|
||||
shareToDelete={shareManager.shareToDelete}
|
||||
@@ -55,6 +68,7 @@ export function SharesModals({
|
||||
onManageRecipients={shareManager.handleManageRecipients}
|
||||
onSuccess={handleShareSuccess}
|
||||
onEditFile={fileManager.handleRename}
|
||||
onEditFolder={shareManager.handleEditFolder}
|
||||
/>
|
||||
|
||||
<QrCodeModal
|
||||
@@ -109,8 +123,9 @@ export function SharesModals({
|
||||
onSuccess={handleShareSuccess}
|
||||
/>
|
||||
|
||||
<ShareMultipleFilesModal
|
||||
<ShareMultipleItemsModal
|
||||
files={fileManager.filesToShare}
|
||||
folders={null}
|
||||
isOpen={!!fileManager.filesToShare}
|
||||
onClose={() => fileManager.setFilesToShare(null)}
|
||||
onSuccess={() => {
|
||||
|
@@ -16,9 +16,9 @@ export function SharesSearch({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
|
||||
<h2 className="text-xl font-semibold">{t("shares.search.title")}</h2>
|
||||
<Button onClick={onCreateShare}>
|
||||
<Button onClick={onCreateShare} className="w-full sm:w-auto">
|
||||
<IconPlus className="h-4 w-4" />
|
||||
{t("shares.search.createButton")}
|
||||
</Button>
|
||||
|
36
apps/web/src/app/api/(proxy)/files/[id]/move/route.ts
Normal file
36
apps/web/src/app/api/(proxy)/files/[id]/move/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
|
||||
|
||||
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const body = await req.text();
|
||||
const cookieHeader = req.headers.get("cookie");
|
||||
const url = `${API_BASE_URL}/files/${id}/move`;
|
||||
|
||||
const apiRes = await fetch(url, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
cookie: cookieHeader || "",
|
||||
},
|
||||
body,
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
const resBody = await apiRes.text();
|
||||
|
||||
const res = new NextResponse(resBody, {
|
||||
status: apiRes.status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const setCookie = apiRes.headers.getSetCookie?.() || [];
|
||||
if (setCookie.length > 0) {
|
||||
res.headers.set("Set-Cookie", setCookie.join(","));
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
38
apps/web/src/app/api/(proxy)/files/download-url/route.ts
Normal file
38
apps/web/src/app/api/(proxy)/files/download-url/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
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 = req.nextUrl.searchParams;
|
||||
const objectName = searchParams.get("objectName");
|
||||
|
||||
if (!objectName) {
|
||||
return new NextResponse(JSON.stringify({ error: "objectName parameter is required" }), {
|
||||
status: 400,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Forward all query params to backend
|
||||
const queryString = searchParams.toString();
|
||||
const url = `${API_BASE_URL}/files/download-url?${queryString}`;
|
||||
|
||||
const apiRes = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: cookieHeader || "",
|
||||
},
|
||||
});
|
||||
|
||||
const data = await apiRes.json();
|
||||
|
||||
return new NextResponse(JSON.stringify(data), {
|
||||
status: apiRes.status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
66
apps/web/src/app/api/(proxy)/files/download/route.ts
Normal file
66
apps/web/src/app/api/(proxy)/files/download/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { detectMimeTypeWithFallback } from "@/utils/mime-types";
|
||||
|
||||
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 = req.nextUrl.searchParams;
|
||||
const objectName = searchParams.get("objectName");
|
||||
|
||||
if (!objectName) {
|
||||
return new NextResponse(JSON.stringify({ error: "objectName parameter is required" }), {
|
||||
status: 400,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
const url = `${API_BASE_URL}/files/download?${queryString}`;
|
||||
|
||||
const apiRes = await fetch(url, {
|
||||
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 serverContentType = apiRes.headers.get("Content-Type");
|
||||
const contentDisposition = apiRes.headers.get("Content-Disposition");
|
||||
const contentLength = apiRes.headers.get("Content-Length");
|
||||
const acceptRanges = apiRes.headers.get("Accept-Ranges");
|
||||
const contentRange = apiRes.headers.get("Content-Range");
|
||||
const contentType = detectMimeTypeWithFallback(serverContentType, contentDisposition, objectName);
|
||||
|
||||
const res = new NextResponse(apiRes.body, {
|
||||
status: apiRes.status,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
...(contentLength && { "Content-Length": contentLength }),
|
||||
...(acceptRanges && { "Accept-Ranges": acceptRanges }),
|
||||
...(contentRange && { "Content-Range": contentRange }),
|
||||
...(contentDisposition && { "Content-Disposition": contentDisposition }),
|
||||
},
|
||||
});
|
||||
|
||||
const setCookie = apiRes.headers.getSetCookie?.() || [];
|
||||
if (setCookie.length > 0) {
|
||||
res.headers.set("Set-Cookie", setCookie.join(","));
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
@@ -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",
|
||||
|
@@ -1,5 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { detectMimeTypeWithFallback } from "@/utils/mime-types";
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ token: string }> }) {
|
||||
@@ -14,9 +16,10 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ toke
|
||||
},
|
||||
});
|
||||
|
||||
const contentType = apiRes.headers.get("Content-Type") || "application/octet-stream";
|
||||
const serverContentType = apiRes.headers.get("Content-Type");
|
||||
const contentDisposition = apiRes.headers.get("Content-Disposition");
|
||||
const contentLength = apiRes.headers.get("Content-Length");
|
||||
const contentType = detectMimeTypeWithFallback(serverContentType, contentDisposition);
|
||||
|
||||
const res = new NextResponse(apiRes.body, {
|
||||
status: apiRes.status,
|
||||
|
32
apps/web/src/app/api/(proxy)/folders/[id]/contents/route.ts
Normal file
32
apps/web/src/app/api/(proxy)/folders/[id]/contents/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const cookieHeader = req.headers.get("cookie");
|
||||
const url = `${API_BASE_URL}/folders/${id}/contents`;
|
||||
|
||||
const apiRes = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: cookieHeader || "",
|
||||
},
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
const resBody = await apiRes.text();
|
||||
const res = new NextResponse(resBody, {
|
||||
status: apiRes.status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const setCookie = apiRes.headers.getSetCookie?.() || [];
|
||||
if (setCookie.length > 0) {
|
||||
res.headers.set("Set-Cookie", setCookie.join(","));
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
32
apps/web/src/app/api/(proxy)/folders/[id]/files/route.ts
Normal file
32
apps/web/src/app/api/(proxy)/folders/[id]/files/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const cookieHeader = req.headers.get("cookie");
|
||||
const url = `${API_BASE_URL}/folders/${id}/files`;
|
||||
|
||||
const apiRes = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: cookieHeader || "",
|
||||
},
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
const resBody = await apiRes.text();
|
||||
const res = new NextResponse(resBody, {
|
||||
status: apiRes.status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const setCookie = apiRes.headers.getSetCookie?.() || [];
|
||||
if (setCookie.length > 0) {
|
||||
res.headers.set("Set-Cookie", setCookie.join(","));
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
36
apps/web/src/app/api/(proxy)/folders/[id]/move/route.ts
Normal file
36
apps/web/src/app/api/(proxy)/folders/[id]/move/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
|
||||
|
||||
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const body = await req.text();
|
||||
const cookieHeader = req.headers.get("cookie");
|
||||
const url = `${API_BASE_URL}/folders/${id}/move`;
|
||||
|
||||
const apiRes = await fetch(url, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
cookie: cookieHeader || "",
|
||||
},
|
||||
body,
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
const resBody = await apiRes.text();
|
||||
|
||||
const res = new NextResponse(resBody, {
|
||||
status: apiRes.status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const setCookie = apiRes.headers.getSetCookie?.() || [];
|
||||
if (setCookie.length > 0) {
|
||||
res.headers.set("Set-Cookie", setCookie.join(","));
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
95
apps/web/src/app/api/(proxy)/folders/[id]/route.ts
Normal file
95
apps/web/src/app/api/(proxy)/folders/[id]/route.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const cookieHeader = req.headers.get("cookie");
|
||||
const url = `${API_BASE_URL}/folders/${id}`;
|
||||
|
||||
const apiRes = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: cookieHeader || "",
|
||||
},
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
const resBody = await apiRes.text();
|
||||
const res = new NextResponse(resBody, {
|
||||
status: apiRes.status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const setCookie = apiRes.headers.getSetCookie?.() || [];
|
||||
if (setCookie.length > 0) {
|
||||
res.headers.set("Set-Cookie", setCookie.join(","));
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const body = await req.text();
|
||||
const cookieHeader = req.headers.get("cookie");
|
||||
const url = `${API_BASE_URL}/folders/${id}`;
|
||||
|
||||
const apiRes = await fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
cookie: cookieHeader || "",
|
||||
},
|
||||
body,
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
const resBody = await apiRes.text();
|
||||
|
||||
const res = new NextResponse(resBody, {
|
||||
status: apiRes.status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const setCookie = apiRes.headers.getSetCookie?.() || [];
|
||||
if (setCookie.length > 0) {
|
||||
res.headers.set("Set-Cookie", setCookie.join(","));
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const cookieHeader = req.headers.get("cookie");
|
||||
const url = `${API_BASE_URL}/folders/${id}`;
|
||||
|
||||
const apiRes = await fetch(url, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
cookie: cookieHeader || "",
|
||||
},
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
const resBody = await apiRes.text();
|
||||
|
||||
const res = new NextResponse(resBody, {
|
||||
status: apiRes.status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const setCookie = apiRes.headers.getSetCookie?.() || [];
|
||||
if (setCookie.length > 0) {
|
||||
res.headers.set("Set-Cookie", setCookie.join(","));
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
65
apps/web/src/app/api/(proxy)/folders/route.ts
Normal file
65
apps/web/src/app/api/(proxy)/folders/route.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const cookieHeader = req.headers.get("cookie");
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const queryString = searchParams.toString();
|
||||
const url = `${API_BASE_URL}/folders${queryString ? `?${queryString}` : ""}`;
|
||||
|
||||
const apiRes = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: cookieHeader || "",
|
||||
},
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
const resBody = await apiRes.text();
|
||||
const res = new NextResponse(resBody, {
|
||||
status: apiRes.status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const setCookie = apiRes.headers.getSetCookie?.() || [];
|
||||
if (setCookie.length > 0) {
|
||||
res.headers.set("Set-Cookie", setCookie.join(","));
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.text();
|
||||
const cookieHeader = req.headers.get("cookie");
|
||||
|
||||
const apiRes = await fetch(`${API_BASE_URL}/folders`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
cookie: cookieHeader || "",
|
||||
},
|
||||
body,
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
const resBody = await apiRes.text();
|
||||
|
||||
const res = new NextResponse(resBody, {
|
||||
status: apiRes.status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const setCookie = apiRes.headers.getSetCookie?.() || [];
|
||||
if (setCookie.length > 0) {
|
||||
res.headers.set("Set-Cookie", setCookie.join(","));
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
@@ -1,5 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { detectMimeTypeWithFallback } from "@/utils/mime-types";
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ fileId: string }> }) {
|
||||
@@ -25,13 +27,21 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ file
|
||||
});
|
||||
}
|
||||
|
||||
const serverContentType = apiRes.headers.get("Content-Type");
|
||||
const contentDisposition = apiRes.headers.get("Content-Disposition");
|
||||
const contentLength = apiRes.headers.get("Content-Length");
|
||||
const acceptRanges = apiRes.headers.get("Accept-Ranges");
|
||||
const contentRange = apiRes.headers.get("Content-Range");
|
||||
const contentType = detectMimeTypeWithFallback(serverContentType, contentDisposition);
|
||||
|
||||
const res = new NextResponse(apiRes.body, {
|
||||
status: apiRes.status,
|
||||
headers: {
|
||||
"Content-Type": apiRes.headers.get("Content-Type") || "application/octet-stream",
|
||||
"Content-Length": apiRes.headers.get("Content-Length") || "",
|
||||
"Accept-Ranges": apiRes.headers.get("Accept-Ranges") || "",
|
||||
"Content-Range": apiRes.headers.get("Content-Range") || "",
|
||||
"Content-Type": contentType,
|
||||
...(contentLength && { "Content-Length": contentLength }),
|
||||
...(acceptRanges && { "Accept-Ranges": acceptRanges }),
|
||||
...(contentRange && { "Content-Range": contentRange }),
|
||||
...(contentDisposition && { "Content-Disposition": contentDisposition }),
|
||||
},
|
||||
});
|
||||
|
||||
|
@@ -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;
|
||||
}
|
@@ -2,13 +2,14 @@ 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<{ objectPath: string[] }> }) {
|
||||
const { objectPath } = await params;
|
||||
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 objectName = objectPath.join("/");
|
||||
const url = `${API_BASE_URL}/files/${encodeURIComponent(objectName)}/download`;
|
||||
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(url, {
|
||||
const apiRes = await fetch(fetchUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: cookieHeader || "",
|
||||
@@ -29,8 +30,9 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ obje
|
||||
const res = new NextResponse(apiRes.body, {
|
||||
status: apiRes.status,
|
||||
headers: {
|
||||
"Content-Type": apiRes.headers.get("Content-Type") || "application/octet-stream",
|
||||
"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") || "",
|
||||
},
|
@@ -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",
|
||||
});
|
||||
|
||||
|
@@ -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",
|
||||
});
|
||||
|
||||
|
@@ -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;
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user