Compare commits
117 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5140a64c40 | ||
|
|
9ddb7d545d | ||
|
|
2a73551ca4 | ||
|
|
7be56455b0 | ||
|
|
1085bf079c | ||
|
|
b13986e1e3 | ||
|
|
d4462f942b | ||
|
|
2f2ad90fd3 | ||
|
|
2bbb68aa17 | ||
|
|
2b2827cdb3 | ||
|
|
4b4621e4d0 | ||
|
|
fd0f79feb0 | ||
|
|
b9c2448805 | ||
|
|
542225fabc | ||
|
|
e4af2653ea | ||
|
|
4dd15527c0 | ||
|
|
ae0f69043d | ||
|
|
79eafdb3ee | ||
|
|
979df5dad8 | ||
|
|
76c50dce6c | ||
|
|
8acd7de79e | ||
|
|
a3bd2024c6 | ||
|
|
25c26e8dc0 | ||
|
|
07563dce5d | ||
|
|
a0797beb14 | ||
|
|
0701a84973 | ||
|
|
0789ad3e9a | ||
|
|
cb3c9c3194 | ||
|
|
f9b02c4439 | ||
|
|
9afca3fd84 | ||
|
|
faca409604 | ||
|
|
fc973d20fe | ||
|
|
400541f0ce | ||
|
|
f98b810bd4 | ||
|
|
091bd26fbc | ||
|
|
6541a83e72 | ||
|
|
77cf75a08b | ||
|
|
c0785e5e7a | ||
|
|
14489457b2 | ||
|
|
feb8378227 | ||
|
|
8622751c22 | ||
|
|
b17f93b5e3 | ||
|
|
51109c39f8 | ||
|
|
3a1410f554 | ||
|
|
180a9c234f | ||
|
|
25379b5be5 | ||
|
|
300e8918d6 | ||
|
|
6f5ea9f9de | ||
|
|
7b6c37fd4c | ||
|
|
0f9f7831c9 | ||
|
|
51228dc157 | ||
|
|
0786b81e75 | ||
|
|
e92456bc6b | ||
|
|
3e7b4ea2db | ||
|
|
2fd681b8a4 | ||
|
|
73788ceb58 | ||
|
|
24b80eb785 | ||
|
|
ae69eb2b33 | ||
|
|
f78d42ca25 | ||
|
|
4be13d0742 | ||
|
|
416b9d50b6 | ||
|
|
84d2af5df5 | ||
|
|
0d1be0d3a5 | ||
|
|
c1f8507891 | ||
|
|
5422d3e2f6 | ||
|
|
b16b557733 | ||
|
|
e981785a03 | ||
|
|
9395df746e | ||
|
|
998dd2c667 | ||
|
|
b99d72d23b | ||
|
|
b2d1848226 | ||
|
|
d51f19d5e3 | ||
|
|
b0d9cfc67a | ||
|
|
e77b49832f | ||
|
|
947c09eff9 | ||
|
|
85cbd547f5 | ||
|
|
2484932f96 | ||
|
|
97d835f240 | ||
|
|
72414d8122 | ||
|
|
7f4fb3b0e7 | ||
|
|
918ae55ebc | ||
|
|
6a0dd40e1e | ||
|
|
e5d95e3ffe | ||
|
|
08886fd754 | ||
|
|
5a8d30a34f | ||
|
|
ee81d2e6c1 | ||
|
|
d84ad00e95 | ||
|
|
7c1aecd5fa | ||
|
|
e412507f30 | ||
|
|
5521d67a68 | ||
|
|
4962215093 | ||
|
|
274fb7d72e | ||
|
|
a491987c1b | ||
|
|
f3466e4bfd | ||
|
|
c2dc8bfdfb | ||
|
|
510e8622b5 | ||
|
|
7860ea49a0 | ||
|
|
b319a86934 | ||
|
|
b15bc2a087 | ||
|
|
0c811e3fc4 | ||
|
|
8b3372a2bd | ||
|
|
753a07a008 | ||
|
|
c4943f8de7 | ||
|
|
538b490583 | ||
|
|
79542bab7b | ||
|
|
5cae1fdf7e | ||
|
|
9452c4be92 | ||
|
|
cd5b609427 | ||
|
|
d0a9842e7d | ||
|
|
82ecba25e0 | ||
|
|
904f2c091a | ||
|
|
77eb6dc476 | ||
|
|
1fc6182a09 | ||
|
|
3e1bae897e | ||
|
|
a049479fb5 | ||
|
|
c8cae4842e | ||
|
|
181e59ac87 |
@@ -4,7 +4,8 @@ node_modules
|
||||
*.log
|
||||
dist
|
||||
*.local
|
||||
.env
|
||||
.git
|
||||
db.sqlite
|
||||
local-documents
|
||||
local-documents
|
||||
.env
|
||||
**/.env
|
||||
4
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
github:
|
||||
- CorentinTh
|
||||
|
||||
buy_me_a_coffee: cthmsst
|
||||
48
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: 🐞 Bug Report
|
||||
description: File a bug report.
|
||||
labels: ['bug', 'triage']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report!
|
||||
|
||||
- type: textarea
|
||||
id: bug-description
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: A clear and concise description of what the bug is. If you intend to submit a PR for this issue, tell us in the description. Thanks!
|
||||
placeholder: Bug description
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: Also tell us, what did you expect to happen? If you have a screenshot, you can paste it here.
|
||||
placeholder: Tell us what you see!
|
||||
value: 'A bug happened!'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: version
|
||||
attributes:
|
||||
label: System information
|
||||
description: What is you environment? You can use the `npx envinfo --system --browsers` command to get this information.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: app-type
|
||||
attributes:
|
||||
label: Where did you encounter the bug?
|
||||
options:
|
||||
- Demo app (demo.papra.app)
|
||||
- Public app (dashboard.papra.app
|
||||
- Documentation website (docs.papra.ap)
|
||||
- A self hosted instance
|
||||
- Other (installations, docker, etc.)
|
||||
validations:
|
||||
required: true
|
||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: 💬 Discord Community
|
||||
url: https://papra.app/discord
|
||||
about: Join the Papra Discord community to get help, share your feedback, and stay updated on the project.
|
||||
52
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
name: 🚀 New feature proposal
|
||||
description: Propose a new feature/enhancement
|
||||
labels: ['enhancement', 'triage']
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for your interest in the project and taking the time to fill out this feature report!
|
||||
|
||||
- type: dropdown
|
||||
id: request-type
|
||||
attributes:
|
||||
label: What type of request is this?
|
||||
options:
|
||||
- New feature idea
|
||||
- Enhancement of an existing feature
|
||||
- Deployment or CI/CD improvement
|
||||
- Hosting or Self-hosting improvement
|
||||
- Related to documentation
|
||||
- Related to the community
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: feature-description
|
||||
attributes:
|
||||
label: Clear and concise description of the feature you are proposing
|
||||
description: A clear and concise description of what the feature is.
|
||||
placeholder: 'Example: I would like to see...'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Any other context or screenshots about the feature request here.
|
||||
|
||||
- type: checkboxes
|
||||
id: checkboxes
|
||||
attributes:
|
||||
label: Validations
|
||||
description: Before submitting the issue, please make sure you do the following
|
||||
options:
|
||||
- label: Check the feature is not already implemented in the project.
|
||||
required: true
|
||||
- label: Check that there isn't already an issue that request the same feature to avoid creating a duplicate.
|
||||
required: true
|
||||
- label: Check that the feature is technically feasible and aligns with the project's goals.
|
||||
required: true
|
||||
BIN
.github/papra-screenshot.png
vendored
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 151 KiB |
6
.github/workflows/ci-apps-docs.yaml
vendored
@@ -16,11 +16,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
- run: corepack enable
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
corepack: true
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
14
.github/workflows/ci-apps-papra-client.yaml
vendored
@@ -16,11 +16,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
- run: corepack enable
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
corepack: true
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -37,4 +39,10 @@ jobs:
|
||||
run: pnpm test
|
||||
|
||||
- name: Build the app
|
||||
run: pnpm build
|
||||
run: pnpm build
|
||||
|
||||
# Ensure locales types are up to date
|
||||
- name: Check locales types
|
||||
run: |
|
||||
pnpm script:generate-i18n-types
|
||||
git diff --exit-code -- src/modules/i18n/locales.types.ts > /dev/null || (echo "Locales types are outdated, please run 'pnpm script:generate-i18n-types' and commit the changes." && exit 1)
|
||||
6
.github/workflows/ci-apps-papra-server.yaml
vendored
@@ -16,11 +16,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
- run: corepack enable
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
corepack: true
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
2
.github/workflows/release-docker.yaml
vendored
@@ -65,7 +65,9 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
push: true
|
||||
tags: |
|
||||
corentinth/papra:latest
|
||||
corentinth/papra:latest-rootless
|
||||
corentinth/papra:${{ env.RELEASE_VERSION }}-rootless
|
||||
ghcr.io/papra-hq/papra:latest
|
||||
ghcr.io/papra-hq/papra:latest-rootless
|
||||
ghcr.io/papra-hq/papra:${{ env.RELEASE_VERSION }}-rootless
|
||||
4
.gitignore
vendored
@@ -37,4 +37,6 @@ cache
|
||||
*.sqlite
|
||||
|
||||
local-documents
|
||||
.cursorrules
|
||||
ingestion
|
||||
.cursorrules
|
||||
*.traineddata
|
||||
128
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
<corentinth@proton.me>.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
<https://www.contributor-covenant.org/version/2/0/code_of_conduct.html>.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
<https://www.contributor-covenant.org/faq>. Translations are available at
|
||||
<https://www.contributor-covenant.org/translations>.
|
||||
154
CONTRIBUTING.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Contributing to Papra
|
||||
|
||||
First off, thanks for taking the time to contribute to Papra! We welcome contributions of all types and encourage you to help make this project better for everyone.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
This project adheres to the [Contributor Covenant](https://www.contributor-covenant.org/). By participating, you are expected to uphold this code. Please report unacceptable behavior to <corentinth@proton.me>
|
||||
|
||||
## How Can I Contribute?
|
||||
|
||||
### Reporting Issues
|
||||
|
||||
If you find a bug, have a feature request, or need help, feel free to open an issue in the [GitHub Issue Tracker](https://github.com/papra-hq/papra/issues). You're also welcome to comment on existing issues.
|
||||
|
||||
### Submitting Pull Requests
|
||||
|
||||
Please refrain from submitting pull requests that implement new features or fix bugs without first opening an issue. This will help us avoid duplicate work and ensure that your contribution is in line with the project's goals and prevents wasted effort on your part.
|
||||
|
||||
We follow a **GitHub Flow** model where all PRs should target the `main` branch, which is continuously deployed to production.
|
||||
|
||||
**Guidelines for submitting PRs:**
|
||||
|
||||
- Each PR should be small and atomic. Please avoid solving multiple unrelated issues in a single PR.
|
||||
- Ensure that the **CI is green** before submitting. Some of the following checks are automatically run for each package: linting, type checking, testing, and building.
|
||||
- PRs without a corresponding issue are welcome.
|
||||
- If your PR fixes an issue, please reference the issue number in the PR description.
|
||||
- If your PR adds a new feature, please include tests and update the documentation if necessary.
|
||||
- Be prepared to address feedback and iterate on your PR.
|
||||
- Resolving merge conflicts is part of the PR author's responsibility.
|
||||
|
||||
### Branching
|
||||
|
||||
- **Main branch**: This is the production branch. All pull requests must target this branch.
|
||||
- **Feature branches**: Create a new branch for your feature (e.g., `my-new-feature`), make your changes, and then open a PR targeting `main`.
|
||||
|
||||
### Commit Guidelines
|
||||
|
||||
We use **[Conventional Commits](https://www.conventionalcommits.org/)** to keep commit messages consistent and meaningful. Please follow these guidelines when writing commit messages. While you can structure commits however you like, PRs will be squashed on merge.
|
||||
|
||||
## i18n
|
||||
|
||||
We welcome contributions to improve and expand the app's internationalization (i18n) support. Below are the guidelines for adding a new language or updating an existing translation.
|
||||
|
||||
### Adding a New Language
|
||||
|
||||
1. **Create a Language File**: To add a new language, create a YAML file named with the appropriate [ISO language code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) (e.g., `fr.yml` for French) in the [`apps/papra-client/src/locales`](./apps/papra-client/src/locales) directory.
|
||||
|
||||
2. **Use the Reference File**: Refer to the [`en.yml`](./apps/papra-client/src/locales/en.yml) file, which contains all keys used in the app. Use it as a base to ensure consistency when creating your new language file. And act as a fallback if a key is missing in the new language file.
|
||||
|
||||
3. **Update the Locale List**: After adding the new language file, include the language code in the `locales` array found in the [`apps/papra-client/src/modules/i18n/i18n.constants.ts`](./apps/papra-client/src/modules/i18n/i18n.constants.ts) file.
|
||||
|
||||
4. **Submit a Pull Request**: Once you've added the file and updated `i18n.constants.ts`, create a pull request (PR) with your changes. Ensure that your PR is clearly titled with the language being added (e.g., "Add French translations").
|
||||
|
||||
### Updating an Existing Language
|
||||
|
||||
If you want to update an existing language file, you can do so directly in the corresponding JSON file in the [`apps/papra-client/src/locales`](./apps/papra-client/src/locales) directory. If you're adding or removing keys in the default language file ([`en.yml`](./apps/papra-client/src/locales/en.yml)), please run the following command to update the types (used for type checking the translations keys in the app):
|
||||
|
||||
```bash
|
||||
pnpm script:generate-i18n-types
|
||||
```
|
||||
|
||||
- This command will update the file [`locales.types.ts`](./apps/papra-client/src/modules/i18n/locale.types.ts) with the new/removed keys.
|
||||
- Will developing you can use `pnpm script:generate-i18n-types:watch` to automatically update the types when you touch the [`en.yml`](./apps/papra-client/src/locales/en.yml) file.
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Local Environment Setup
|
||||
|
||||
We recommend running the app locally for development. Follow these steps:
|
||||
|
||||
1. Clone the repository and navigate inside the project directory.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/papra-hq/papra.git
|
||||
cd papra
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. Start the development server for the backend:
|
||||
|
||||
```bash
|
||||
cd apps/papra-server
|
||||
# Run the migration script to create the database schema
|
||||
pnpm migrate:up
|
||||
# Start the server
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
4. Start the frontend:
|
||||
|
||||
```bash
|
||||
cd apps/papra-client
|
||||
# Start the client
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
5. Open your browser and navigate to `http://localhost:3000`.
|
||||
|
||||
### Testing
|
||||
|
||||
We use **Vitest** for testing. Each package comes with its own testing commands.
|
||||
|
||||
- To run the tests for any package:
|
||||
|
||||
```bash
|
||||
pnpm test
|
||||
```
|
||||
|
||||
- To run tests in watch mode:
|
||||
|
||||
```bash
|
||||
pnpm test:watch
|
||||
```
|
||||
|
||||
All new features must be covered by unit or integration tests. Be sure to use business-oriented test names (avoid vague descriptions like `it('should return true')`).
|
||||
|
||||
## Writing Documentation
|
||||
|
||||
If your code changes affect the documentation, you must update the docs. The documentation is powered by [**Astro Starlight**](https://starlight.astro.build/).
|
||||
|
||||
To start the documentation server for local development:
|
||||
|
||||
1. Navigate to the `packages/docs` directory:
|
||||
|
||||
```bash
|
||||
cd apps/docs
|
||||
```
|
||||
|
||||
2. Start the documentation server:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
3. Open your browser and navigate to `http://localhost:4321`.
|
||||
|
||||
## Coding Style
|
||||
|
||||
- Use functional programming where possible.
|
||||
- Focus on clarity and maintainability over performance.
|
||||
- Choose meaningful, relevant names for variables, functions, and components.
|
||||
|
||||
## Issue Labels
|
||||
|
||||
Look out for issues tagged as [**good first issue**](https://github.com/papra-hq/papra/issues?q=sort%3Aupdated-desc%20is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22) or [**PR welcome**](https://github.com/papra-hq/papra/issues?q=sort%3Aupdated-desc+is%3Aissue+state%3Aopen+label%3A%22PR+welcome%22) for tasks that are well-suited for new contributors. Feel free to comment on existing issues or create new ones.
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the [AGPL3](./LICENSE), the same as the project itself.
|
||||
71
README.md
@@ -17,27 +17,35 @@
|
||||
<a href="https://demo.papra.app">Demo</a>
|
||||
<span> • </span>
|
||||
<a href="https://docs.papra.app">Docs</a>
|
||||
<!-- <span> • </span>
|
||||
<a href="https://docs.papra.app/self-hosting/docker">Self-hosting</a> -->
|
||||
<span> • </span>
|
||||
<a href="https://docs.papra.app/self-hosting/using-docker">Self-hosting</a>
|
||||
<span> • </span>
|
||||
<a href="https://github.com/orgs/papra-hq/projects/2">Roadmap</a>
|
||||
<span> • </span>
|
||||
<a href="https://dashboard.papra.app">Managed instance</a>
|
||||
<a href="https://papra.app/discord">Discord</a>
|
||||
<!-- <span> • </span>
|
||||
<a href="https://dashboard.papra.app">Managed instance</a> -->
|
||||
</p>
|
||||
|
||||
## Introduction
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Papra** is currently in active development and is not yet ready for production use or self-hosting.
|
||||
|
||||
**Papra** is a minimalistic document management and archiving platform. It is designed to be simple to use and accessible to everyone. Papra is a plateform for long-term document storage and management, like a digital archive for your documents.
|
||||
**Papra** is a minimalistic document management and archiving platform. It is designed to be simple to use and accessible to everyone. Papra is a platform for long-term document storage and management, like a digital archive for your documents.
|
||||
|
||||
Forget about that receipt of that gift you bought for your friend last year, or that warranty for your new phone. With Papra, you can easily store, forget, and retrieve your documents whenever you need them.
|
||||
|
||||
A live demo of the platform is available at [demo.papra.cc](https://demo.papra.cc) (no backend, client-side local storage only).
|
||||
A live demo of the platform is available at [demo.papra.app](https://demo.papra.app) (no backend, client-side local storage only).
|
||||
|
||||
[](https://demo.papra.app)
|
||||
|
||||
## Project Status
|
||||
|
||||
Papra is currently in **beta**. The core functionality is stable and usable, but you may encounter occasional bugs or limitations. The project is actively developed, with new features being added regularly.
|
||||
|
||||
- ✅ Core document management features are stable
|
||||
- ✅ Self-hosting is fully supported
|
||||
- 🚧 Some advanced features are still in development
|
||||
- 📝 Feedback and bug reports are highly appreciated
|
||||
|
||||
## Features
|
||||
|
||||
- **Document management**: Upload, store, and manage your documents in one place.
|
||||
@@ -47,28 +55,45 @@ A live demo of the platform is available at [demo.papra.cc](https://demo.papra.c
|
||||
- **Dark Mode**: A dark theme for those late-night document management sessions.
|
||||
- **Responsive Design**: Works on all devices, from desktops to mobile phones.
|
||||
- **Open Source**: The project is open-source and free to use.
|
||||
- *Coming soon:* **Self-hosting**: Host your own instance of Papra using Docker or other methods.
|
||||
- *Coming soon:* **Tags**: Organize your documents with tags.
|
||||
- **Self-hosting**: Host your own instance of Papra using Docker or other methods.
|
||||
- **Tags**: Organize your documents with tags.
|
||||
- **Email ingestion**: Send/forward emails to a generated address to automatically import documents.
|
||||
- **Content extraction**: Automatically extract text from images or scanned documents for search.
|
||||
- *In progress:* **i18n**: Support for multiple languages.
|
||||
- *Coming soon:* **Tagging Rules**: Automatically tag documents based on custom rules.
|
||||
- *Coming soon:* **OCR**: Automatically extract text from images or scanned documents for search.
|
||||
- *Coming soon:* **i18n**: Support for multiple languages.
|
||||
- *Coming soon:* **Email ingestion**: Forward emails to automatically import documents.
|
||||
- *Coming soon:* **Folder ingestion**: Automatically import documents from a folder.
|
||||
- *Coming soon:* **SDK and API**: Build your own applications on top of Papra.
|
||||
- *Coming soon:* **CLI**: Manage your documents from the command line.
|
||||
- *Coming soon:* **Document sharing**: Share documents with others.
|
||||
- *Coming soon:* **Folder ingestion**: Automatically import documents from a folder.
|
||||
- *Coming soon:* **Document requests**: Generate upload links for people to add documents.
|
||||
- *Coming maybe one day:* **Mobile app**: Access and upload documents on the go.
|
||||
- *Coming maybe one day:* **Desktop app**: Access and upload documents from your computer.
|
||||
|
||||
## Self-hosting
|
||||
|
||||
Papra is dedicated to providing a simple yet highly configurable self-hosting experience. Our lightweight Docker image (<200MB) is compatible with multiple architectures including x86, ARM64, and ARMv7.
|
||||
|
||||
For a quick start, simply run the following command:
|
||||
|
||||
```bash
|
||||
docker run -d --name papra -p 1221:1221 ghcr.io/papra-hq/papra:latest
|
||||
```
|
||||
|
||||
Please refer to the [self-hosting documentation](https://docs.papra.app/self-hosting/using-docker) for more information and configuration options.
|
||||
|
||||
## Contributing
|
||||
|
||||
*Coming soon*
|
||||
Currently, the project is in heavy development and is not yet ready for contributions as changes are frequent and the architecture is not yet finalized. However, you can star the project to follow its progress.
|
||||
Contributions are welcome! Please refer to the [`CONTRIBUTING.md`](./CONTRIBUTING.md) file for guidelines on how to get started, report issues, and submit pull requests.
|
||||
You can find easy-to-pick-up tasks with the [`good first issue`](https://github.com/papra-hq/papra/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20label%3A%22good%20first%20issue%22) or [`PR welcome`](https://github.com/papra-hq/papra/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20label%3A%22good%20first%20issue%22) labels.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the AGPL-3.0 License - see the [LICENSE](./LICENSE) file for details.
|
||||
|
||||
## Community
|
||||
|
||||
Join the community on [Papra's Discord server](https://papra.app/discord) to discuss the project, ask questions, or get help.
|
||||
|
||||
## Credits
|
||||
|
||||
This project is crafted with ❤️ by [Corentin Thomasset](https://corentin.tech).
|
||||
@@ -78,7 +103,7 @@ If you find this project helpful, please consider [supporting my work](https://b
|
||||
|
||||
### Stack
|
||||
|
||||
Enclosed would not have been possible without the following open-source projects:
|
||||
Papra would not have been possible without the following open-source projects:
|
||||
|
||||
- **Frontend**
|
||||
- **[SolidJS](https://www.solidjs.com)**: A declarative JavaScript library for building user interfaces.
|
||||
@@ -89,7 +114,19 @@ Enclosed would not have been possible without the following open-source projects
|
||||
- **Backend**
|
||||
- **[HonoJS](https://hono.dev/)**: A small, fast, and lightweight web framework for building APIs.
|
||||
- **[Drizzle](https://orm.drizzle.team/)**: A simple and lightweight ORM for Node.js.
|
||||
- **[Better Auth](https://better-auth.com/)**: A simple and lightweight authentication library for Node.js.
|
||||
- And other dependencies listed in the **[server package.json](./apps/papra-server/package.json)**
|
||||
- **Documentation**
|
||||
- **[Astro](https://astro.build)**: A great static site generator.
|
||||
- **[Starlight](https://starlight.astro.build)**: A module for Astro that provides a starting point for building documentation websites.
|
||||
- **[HiDeoo/starlight-theme-rapide](https://github.com/HiDeoo/starlight-theme-rapide)**: A theme for Starlight.
|
||||
- **Project**
|
||||
- **[PNPM Workspaces](https://pnpm.io/workspaces)**: A monorepo management tool.
|
||||
- **[Github Actions](https://github.com/features/actions)**: For CI/CD.
|
||||
- **Infrastructure**
|
||||
- **[Cloudflare Pages](https://pages.cloudflare.com/)**: For static site hosting.
|
||||
- **[Render](https://render.com/)**: For backend hosting.
|
||||
- **[Turso](https://turso.tech/)**: For production database.
|
||||
|
||||
### Inspiration
|
||||
|
||||
|
||||
35
SECURITY.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Security Policy
|
||||
|
||||
Security is critically important to Papra. We actively welcome responsible disclosure of any vulnerabilities found in our platform.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover a security issue within Papra, please email us directly at **security@papra.app** with the following details:
|
||||
|
||||
- Clear description of the vulnerability.
|
||||
- Steps or proof-of-concept to reproduce the vulnerability.
|
||||
- Potential impact or implications of the vulnerability.
|
||||
|
||||
We ask you **not to publicly disclose the vulnerability** until we have had a reasonable opportunity to address it.
|
||||
|
||||
## Response and Communication
|
||||
|
||||
We will:
|
||||
|
||||
- Acknowledge receipt of your report within **48 hours**.
|
||||
- Investigate and provide initial feedback within **5 business days**.
|
||||
- Work diligently to fix validated vulnerabilities.
|
||||
- Keep you updated throughout the process until the issue is resolved.
|
||||
|
||||
## Security Practices at Papra
|
||||
|
||||
Papra follows industry-standard security practices:
|
||||
|
||||
- Secure hosting infrastructure provided by trusted services (Render, Cloudflare, Turso).
|
||||
- Regular security and dependency updates.
|
||||
- Strict access controls to production environments.
|
||||
- Encryption of data in transit and at rest.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
We greatly appreciate and acknowledge all researchers who responsibly report vulnerabilities, helping us keep Papra secure.
|
||||
@@ -1,54 +1,22 @@
|
||||
# Starlight Starter Kit: Basics
|
||||
# Papra - Docs website
|
||||
|
||||
[](https://starlight.astro.build)
|
||||
This is the documentation website for [Papra](https://papra.app).
|
||||
|
||||
```
|
||||
npm create astro@latest -- --template starlight
|
||||
This website is built using [Astro](https://astro.build), and based on the [Starlight](https://starlight.astro.build) module styled with [HiDeoo/starlight-theme-rapide](https://github.com/HiDeoo/starlight-theme-rapide) theme.
|
||||
|
||||
## Development
|
||||
|
||||
To start the development server, run:
|
||||
|
||||
```bash
|
||||
# Navigate to the docs directory
|
||||
cd apps/docs
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Start the development server
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
[](https://stackblitz.com/github/withastro/starlight/tree/main/examples/basics)
|
||||
[](https://codesandbox.io/p/sandbox/github/withastro/starlight/tree/main/examples/basics)
|
||||
[](https://app.netlify.com/start/deploy?repository=https://github.com/withastro/starlight&create_from_path=examples/basics)
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fwithastro%2Fstarlight%2Ftree%2Fmain%2Fexamples%2Fbasics&project-name=my-starlight-docs&repository-name=my-starlight-docs)
|
||||
|
||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||
|
||||
## 🚀 Project Structure
|
||||
|
||||
Inside of your Astro + Starlight project, you'll see the following folders and files:
|
||||
|
||||
```
|
||||
.
|
||||
├── public/
|
||||
├── src/
|
||||
│ ├── assets/
|
||||
│ ├── content/
|
||||
│ │ ├── docs/
|
||||
│ └── content.config.ts
|
||||
├── astro.config.mjs
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
Starlight looks for `.md` or `.mdx` files in the `src/content/docs/` directory. Each file is exposed as a route based on its file name.
|
||||
|
||||
Images can be added to `src/assets/` and embedded in Markdown with a relative link.
|
||||
|
||||
Static assets, like favicons, can be placed in the `public/` directory.
|
||||
|
||||
## 🧞 Commands
|
||||
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
|
||||
| Command | Action |
|
||||
| :------------------------ | :----------------------------------------------- |
|
||||
| `npm install` | Installs dependencies |
|
||||
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||
| `npm run build` | Build your production site to `./dist/` |
|
||||
| `npm run preview` | Preview your build locally, before deploying |
|
||||
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||
|
||||
## 👀 Want to learn more?
|
||||
|
||||
Check out [Starlight’s docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat).
|
||||
The development server will start at [http://localhost:4321](http://localhost:4321).
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { env } from 'node:process';
|
||||
import starlight from '@astrojs/starlight';
|
||||
import { defineConfig } from 'astro/config';
|
||||
import starlightThemeRapide from 'starlight-theme-rapide';
|
||||
import { sidebar } from './src/content/navigation';
|
||||
import posthogRawScript from './src/scripts/posthog.script.js?raw';
|
||||
|
||||
const posthogApiKey = env.POSTHOG_API_KEY;
|
||||
const posthogApiHost = env.POSTHOG_API_HOST ?? 'https://eu.i.posthog.com';
|
||||
const isPosthogEnabled = Boolean(posthogApiKey);
|
||||
|
||||
const posthogScript = posthogRawScript.replace('[POSTHOG-API-KEY]', posthogApiKey ?? '').replace('[POSTHOG-API-HOST]', posthogApiHost);
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
@@ -15,33 +24,17 @@ export default defineConfig({
|
||||
alt: 'Papra Logo',
|
||||
},
|
||||
social: {
|
||||
blueSky: 'https://bsky.app/profile/papra.app',
|
||||
github: 'https://github.com/papra-hq/papra',
|
||||
blueSky: 'https://bsky.app/profile/papra.app',
|
||||
discord: 'https://papra.app/discord',
|
||||
},
|
||||
expressiveCode: {
|
||||
themes: ['vitesse-black', 'vitesse-light'],
|
||||
},
|
||||
editLink: {
|
||||
baseUrl: 'https://github.com/papra-hq/papra/edit/main/apps/docs/',
|
||||
},
|
||||
sidebar: [
|
||||
{
|
||||
label: 'Getting Started',
|
||||
items: [
|
||||
{ label: 'Introduction', slug: '' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Self Hosting',
|
||||
items: [
|
||||
{ label: 'Using Docker', slug: 'self-hosting/using-docker' },
|
||||
{ label: 'Using Docker Compose', slug: 'self-hosting/using-docker-compose' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Configuration',
|
||||
items: [
|
||||
{ label: 'Environment variables', slug: 'configuration/environment-variables' },
|
||||
],
|
||||
},
|
||||
],
|
||||
sidebar,
|
||||
favicon: '/favicon.svg',
|
||||
head: [
|
||||
// Add ICO favicon fallback for Safari.
|
||||
@@ -53,6 +46,14 @@ export default defineConfig({
|
||||
sizes: '32x32',
|
||||
},
|
||||
},
|
||||
...(isPosthogEnabled
|
||||
? [
|
||||
{
|
||||
tag: 'script',
|
||||
content: posthogScript,
|
||||
} as const,
|
||||
]
|
||||
: []),
|
||||
],
|
||||
customCss: ['./src/assets/app.css'],
|
||||
}),
|
||||
|
||||
@@ -7,6 +7,10 @@ export default antfu({
|
||||
semi: true,
|
||||
},
|
||||
|
||||
ignores: [
|
||||
'src/scripts/posthog.script.js',
|
||||
],
|
||||
|
||||
rules: {
|
||||
// To allow export on top of files
|
||||
'ts/no-use-before-define': ['error', { allowNamedExports: true, functions: false }],
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"name": "docs",
|
||||
"name": "@papra/docs",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"version": "0.3.0",
|
||||
"packageManager": "pnpm@9.15.4",
|
||||
"description": "Papra documentation website",
|
||||
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
@@ -16,14 +20,19 @@
|
||||
"@astrojs/starlight": "^0.31.0",
|
||||
"astro": "^5.1.5",
|
||||
"sharp": "^0.32.5",
|
||||
"starlight-theme-rapide": "^0.3.0"
|
||||
"starlight-theme-rapide": "^0.3.0",
|
||||
"zod-to-json-schema": "^3.24.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^3.13.0",
|
||||
"@iconify-json/tabler": "^1.1.120",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@unocss/reset": "^0.64.0",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-astro": "^1.3.1",
|
||||
"figue": "^2.2.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
"marked": "^15.0.6",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,58 @@
|
||||
:root[data-theme='dark'] {
|
||||
--background-color: #0c0d0f!important;
|
||||
--accent-color: #fff!important;
|
||||
--foreground-color: #9ea3a2!important;
|
||||
--sl-color-white: #f3f7f6!important;
|
||||
|
||||
--surface: #0a0b0d!important;
|
||||
|
||||
--sl-color-text: var(--foreground-color)!important;
|
||||
|
||||
--sl-color-text-accent: var(--accent-color)!important;
|
||||
--sl-color-accent-high: var(--accent-color)!important;
|
||||
|
||||
--sl-color-bg: var(--background-color)!important;
|
||||
--sl-rapide-ui-header-bg-color: var(--background-color)!important;
|
||||
--sl-color-bg-sidebar: var(--background-color)!important;
|
||||
}
|
||||
|
||||
.sl-link-card {
|
||||
background-color: var(--surface)!important;
|
||||
}
|
||||
|
||||
.hero .sl-link-button {
|
||||
background-color: var(--sl-color-text)!important;
|
||||
border-color: transparent!important;
|
||||
color: var(--sl-color-bg)!important;
|
||||
border-radius: 0.8rem!important;
|
||||
transition: opacity 0.2s ease-in-out!important;
|
||||
font-weight: 500!important;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .hero .sl-link-button {
|
||||
background-color: var(--accent-color)!important;
|
||||
color: var(--background-color)!important;
|
||||
}
|
||||
|
||||
.hero .sl-link-button:hover {
|
||||
opacity: 0.8!important;
|
||||
}
|
||||
|
||||
#_top {
|
||||
padding-top: 10px!important;
|
||||
padding-bottom: 30px!important;
|
||||
}
|
||||
|
||||
.site-title {
|
||||
color:inherit !important;
|
||||
gap: 0.5rem !important;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .site-title {
|
||||
color:#fff !important;
|
||||
}
|
||||
|
||||
|
||||
.site-title img {
|
||||
width: 1.8rem !important;
|
||||
}
|
||||
}
|
||||
BIN
apps/docs/src/assets/cf-catchall-config.png
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
apps/docs/src/assets/cf-intake-email.dark.png
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
apps/docs/src/assets/cf-intake-email.light.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><g fill="none" stroke="#c4bdbd" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2zM9 9h1m-1 4h6m-6 4h6"/></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><g fill="none" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2zM9 9h1m-1 4h6m-6 4h6"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 318 B After Width: | Height: | Size: 318 B |
BIN
apps/docs/src/assets/owlrelay-api-keys.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
apps/docs/src/assets/owlrelay-intake-email.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
85
apps/docs/src/config.data.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { ConfigDefinition, ConfigDefinitionElement } from 'figue';
|
||||
import { isArray, isEmpty, isNil } from 'lodash-es';
|
||||
import { configDefinition } from '../../papra-server/src/modules/config/config';
|
||||
|
||||
function walk(configDefinition: ConfigDefinition, path: string[] = []): (ConfigDefinitionElement & { path: string[] })[] {
|
||||
return Object
|
||||
.entries(configDefinition)
|
||||
.flatMap(([key, value]) => {
|
||||
if ('schema' in value) {
|
||||
return [{ ...value, path: [...path, key] }] as (ConfigDefinitionElement & { path: string[] })[];
|
||||
}
|
||||
|
||||
return walk(value, [...path, key]);
|
||||
});
|
||||
}
|
||||
|
||||
const configDetails = walk(configDefinition);
|
||||
|
||||
function formatDoc(doc: string | undefined): string {
|
||||
const coerced = (doc ?? '').trim();
|
||||
|
||||
if (coerced.endsWith('.')) {
|
||||
return coerced;
|
||||
}
|
||||
|
||||
return `${coerced}.`;
|
||||
}
|
||||
|
||||
const rows = configDetails
|
||||
.filter(({ path }) => path[0] !== 'env')
|
||||
.map(({ doc, default: defaultValue, env, path }) => {
|
||||
const isEmptyDefaultValue = isNil(defaultValue) || (isArray(defaultValue) && isEmpty(defaultValue)) || defaultValue === '';
|
||||
|
||||
const rawDocumentation = formatDoc(doc);
|
||||
|
||||
return {
|
||||
path,
|
||||
env,
|
||||
documentation: rawDocumentation,
|
||||
defaultValue: isEmptyDefaultValue ? undefined : defaultValue,
|
||||
};
|
||||
});
|
||||
|
||||
const mdSections = rows.map(({ documentation, env, path, defaultValue }) => `
|
||||
### ${env}
|
||||
${documentation}
|
||||
|
||||
- Path: \`${path.join('.')}\`
|
||||
- Environment variable: \`${env}\`
|
||||
- Default value: \`${defaultValue}\`
|
||||
|
||||
|
||||
`.trim()).join('\n\n---\n\n');
|
||||
|
||||
function wrapText(text: string, maxLength = 75) {
|
||||
const words = text.split(' ');
|
||||
const lines: string[] = [];
|
||||
let currentLine = '';
|
||||
|
||||
words.forEach((word) => {
|
||||
if ((currentLine + word).length + 1 <= maxLength) {
|
||||
currentLine += (currentLine ? ' ' : '') + word;
|
||||
} else {
|
||||
lines.push(currentLine);
|
||||
currentLine = word;
|
||||
}
|
||||
});
|
||||
|
||||
if (currentLine) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
|
||||
return lines.map(line => `# ${line}`);
|
||||
}
|
||||
|
||||
const fullDotEnv = rows.map(({ env, defaultValue, documentation }) => {
|
||||
const isEmptyDefaultValue = isNil(defaultValue) || (isArray(defaultValue) && isEmpty(defaultValue)) || defaultValue === '';
|
||||
|
||||
return [
|
||||
...wrapText(documentation),
|
||||
`# ${env}=${isEmptyDefaultValue ? '' : defaultValue}`,
|
||||
].join('\n');
|
||||
}).join('\n\n');
|
||||
|
||||
export { fullDotEnv, mdSections };
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
title: Installing Papra using Docker
|
||||
description: Self-host Papra using Docker.
|
||||
slug: self-hosting/using-docker
|
||||
---
|
||||
|
||||
Papra can be easily installed and run using Docker. This method is recommended for users who want a quick and straightforward way to deploy their own instance of Papra with minimal setup.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure that you have Docker installed on your system. You can download and install Docker from the official Docker website.
|
||||
|
||||
## Root and Rootless installation
|
||||
|
||||
Papra can be installed in two different ways:
|
||||
|
||||
- **Root**: This is the default installation method. It requires root privileges to run. The images are suffixed with `-root` like `corentinth/papra:latest-root` or `corentinth/papra:1.0.0-root`.
|
||||
- **Rootless**: This method does not require root privileges to run. The images are suffixed with `-rootless` like `corentinth/papra:latest-rootless` or `corentinth/papra:1.0.0-rootless`.
|
||||
|
||||
## Image Sources
|
||||
|
||||
Papra Docker images are available on both **Docker Hub** and **GitHub Container Registry** (GHCR). You can choose the source that best suits your needs.
|
||||
|
||||
```bash frame="none"
|
||||
# Using Docker Hub
|
||||
docker pull corentinth/papra:latest-root
|
||||
docker pull corentinth/papra:latest-rootless
|
||||
|
||||
# Using GitHub Container Registry
|
||||
docker pull ghcr.io/papra-hq/papra:latest-root
|
||||
docker pull ghcr.io/papra-hq/papra:latest-rootless
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```bash frame="none"
|
||||
docker run -d --name papra --restart unless-stopped -p 1221:1221 ghcr.io/papra-hq/papra:latest-root
|
||||
```
|
||||
111
apps/docs/src/content/docs/02-self-hosting/01-using-docker.mdx
Normal file
@@ -0,0 +1,111 @@
|
||||
---
|
||||
title: Installing Papra using Docker
|
||||
description: Self-host Papra using Docker.
|
||||
slug: self-hosting/using-docker
|
||||
---
|
||||
import { Steps } from '@astrojs/starlight/components';
|
||||
|
||||
Papra provides optimized Docker images for streamlined deployment. This method is recommended for users seeking a production-ready setup with minimal maintenance overhead.
|
||||
|
||||
- **Simplified management**: Single container handles all components
|
||||
- **Lightweight**: Optimized image sizes across architectures
|
||||
- **Cross-platform support**: Compatible with `arm64`, `arm/v7`, and `x86_64` systems
|
||||
- **Security options**: Supports both rootless (recommended) and rootful configurations
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Ensure Docker is installed on your host system. Official installation guides are available at:
|
||||
[docker.com/get-started](https://www.docker.com/get-started)
|
||||
|
||||
Verify Docker installation with:
|
||||
|
||||
```bash
|
||||
docker --version
|
||||
```
|
||||
|
||||
## Quick Deployment
|
||||
|
||||
Launch Papra with default configuration using:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name papra \
|
||||
--restart unless-stopped \
|
||||
-p 1221:1221 \
|
||||
ghcr.io/papra-hq/papra:latest
|
||||
```
|
||||
|
||||
This command will:
|
||||
1. Pull the latest rootless image from GitHub Container Registry
|
||||
2. Expose the web interface on [http://localhost:1221](http://localhost:1221)
|
||||
3. Configure automatic restarts for service continuity
|
||||
|
||||
## Image Variants
|
||||
|
||||
Choose between two security models based on your requirements:
|
||||
|
||||
- **Rootless**: Tagged as `latest`, `latest-rootless` or `<version>-rootless` (like `0.2.1-rootless`). Recommended for most users.
|
||||
- **Root**: Tagged as `latest-root` or `<version>-root` (like `0.2.1-root`). Only use if you need to run Papra as the root user.
|
||||
|
||||
The `:latest` tag always references the latest rootless build.
|
||||
|
||||
## Persistent Data Configuration
|
||||
|
||||
For production deployments, mount host directories to preserve application data between container updates.
|
||||
|
||||
<Steps>
|
||||
|
||||
1. Create Storage Directories
|
||||
|
||||
Create a directory for Papra data `./papra-data`, with `./papra-data/db` and `./papra-data/documents` subdirectories:
|
||||
|
||||
```bash
|
||||
mkdir -p ./papra-data/{db,documents}
|
||||
```
|
||||
|
||||
2. Launch Container with Volume Binding
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name papra \
|
||||
--restart unless-stopped \
|
||||
-p 1221:1221 \
|
||||
-v $(pwd)/papra-data:/app/app-data \
|
||||
--user $(id -u):$(id -g) \
|
||||
ghcr.io/papra-hq/papra:latest
|
||||
```
|
||||
|
||||
This configuration:
|
||||
- Maintains data integrity across container lifecycle events
|
||||
- Enforces proper file ownership without manual permission adjustments
|
||||
- Stores both database files and document assets persistently
|
||||
|
||||
</Steps>
|
||||
|
||||
## Image Registries
|
||||
|
||||
Papra images are distributed through multiple channels:
|
||||
|
||||
**Primary Source (GHCR):**
|
||||
```bash
|
||||
docker pull ghcr.io/papra-hq/papra:latest
|
||||
docker pull ghcr.io/papra-hq/papra:latest-rootless
|
||||
docker pull ghcr.io/papra-hq/papra:latest-root
|
||||
```
|
||||
|
||||
**Community Mirror (Docker Hub):**
|
||||
```bash
|
||||
docker pull corentinth/papra:latest
|
||||
docker pull corentinth/papra:latest-rootless
|
||||
docker pull corentinth/papra:latest-root
|
||||
```
|
||||
|
||||
## Updating Papra
|
||||
|
||||
Regularly pull updated images and recreate containers to receive security patches and feature updates.
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/papra-hq/papra:latest
|
||||
# Or
|
||||
docker pull corentinth/papra:latest
|
||||
```
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
title: Using Docker Compose
|
||||
slug: self-hosting/using-docker-compose
|
||||
---
|
||||
|
||||
Coming soon.
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
title: Using Docker Compose
|
||||
slug: self-hosting/using-docker-compose
|
||||
---
|
||||
|
||||
import { Steps } from '@astrojs/starlight/components';
|
||||
|
||||
This guide covers how to deploy Papra using Docker Compose, ideal for users who prefer declarative configurations or plan to integrate Papra into a broader service stack.
|
||||
|
||||
Using Docker Compose provides:
|
||||
- A single, versioned configuration file
|
||||
- Easy integration with volumes, networks, and service dependencies
|
||||
- Simplified updates and re-deployments
|
||||
|
||||
This method supports both `rootless` and `rootful` Papra images, please refer to the [Docker](/self-hosting/using-docker) guide for more information about the difference between the two. The following example uses the recommended `rootless` setup.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Ensure Docker and Docker Compose are installed on your host system. Official installation guides are available at: [docker.com/get-started](https://www.docker.com/get-started)
|
||||
|
||||
Verify Docker installation with:
|
||||
|
||||
```bash
|
||||
docker --version
|
||||
docker compose version
|
||||
```
|
||||
|
||||
|
||||
<Steps>
|
||||
|
||||
1. Initialize Project Structure
|
||||
|
||||
Create working directory and persistent storage subdirectories:
|
||||
|
||||
```bash
|
||||
mkdir -p papra/app-data/{db,documents} && cd papra
|
||||
```
|
||||
|
||||
2. Create Docker Compose file
|
||||
|
||||
Create a file named `docker-compose.yml` with the following content:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
papra:
|
||||
container_name: papra
|
||||
image: ghcr.io/papra-hq/papra:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "1221:1221"
|
||||
volumes:
|
||||
- ./app-data:/app/app-data
|
||||
user: "${UID}:${GID}"
|
||||
```
|
||||
|
||||
3. Start Papra
|
||||
|
||||
From the directory containing your `docker-compose.yml` file, run:
|
||||
|
||||
```bash
|
||||
UID=$(id -u) GID=$(id -g) docker compose up -d
|
||||
```
|
||||
|
||||
This command downloads the latest Papra image, sets up the container, and starts the Papra service. The `UID` and `GID` variables are used to set the user and group for the container, ensuring proper file ownership. If you don't want to use the `UID` and `GID` variables, you can replace the image with the rootful variant.
|
||||
|
||||
4. Access Papra
|
||||
|
||||
Once your container is running, access Papra via your browser at:
|
||||
|
||||
```
|
||||
http://localhost:1221
|
||||
```
|
||||
|
||||
Your Papra instance is now ready for use!
|
||||
|
||||
5. To go further
|
||||
|
||||
Check the [configuration](/self-hosting/configuration) page for more information on how to configure your Papra instance.
|
||||
|
||||
</Steps>
|
||||
|
||||
## Maintenance
|
||||
|
||||
Check logs
|
||||
|
||||
```bash
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
Stop the service
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Update Papra
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
You're all set! Enjoy managing your documents with Papra.
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
title: Configuration
|
||||
slug: self-hosting/configuration
|
||||
|
||||
---
|
||||
|
||||
import { mdSections, fullDotEnv } from '../../../config.data.ts';
|
||||
import { marked } from 'marked';
|
||||
import { Tabs, TabItem } from '@astrojs/starlight/components';
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
import { Code } from '@astrojs/starlight/components';
|
||||
|
||||
Configuring your self hosted Papra allows you to customize the application to better suit your environment and requirements. This guide covers the key environment variables you can set to control various aspects of the application, including port settings, security options, and storage configurations.
|
||||
|
||||
## Configuration files
|
||||
|
||||
You can configure Papra using standard environment variables or use some configuration files.
|
||||
Papra uses [c12](https://github.com/unjs/c12) to load configuration files and [figue](https://github.com/CorentinTh/figue) to validate and merge environment variables and configuration files.
|
||||
|
||||
The [c12](https://github.com/unjs/c12) allows you to use the file format you want. The configuration file should be named `papra.config.[ext]` and should be located in the root of the project or in `/app/app-data/` directory in docker container (it can be changed using `PAPRA_CONFIG_DIR` environment variable).
|
||||
|
||||
The supported formats are: `json`, `jsonc`, `json5`, `yaml`, `yml`, `toml`, `js`, `ts`, `cjs`, `mjs`.
|
||||
|
||||
Example of configuration files:
|
||||
<Tabs>
|
||||
<TabItem label="papra.config.yaml">
|
||||
```yaml
|
||||
server:
|
||||
baseUrl: https://papra.example.com
|
||||
corsOrigins: *
|
||||
|
||||
client:
|
||||
baseUrl: https://papra.example.com
|
||||
|
||||
auth:
|
||||
secret: your-secret-key
|
||||
isRegistrationEnabled: true
|
||||
# ...
|
||||
```
|
||||
</TabItem>
|
||||
|
||||
<TabItem label="papra.config.json">
|
||||
```json
|
||||
{
|
||||
"$schema": "https://docs.papra.com/papra-config-schema.json",
|
||||
"server": {
|
||||
"baseUrl": "https://papra.example.com"
|
||||
},
|
||||
"client": {
|
||||
"baseUrl": "https://papra.example.com"
|
||||
},
|
||||
"auth": {
|
||||
"secret": "your-secret-key",
|
||||
"isRegistrationEnabled": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<Aside type="tip">
|
||||
When using an IDE, you can use the [papra-config-schema.json](/papra-config-schema.json) file to get autocompletion for the configuration file. Just add a `$schema` property to your configuration file and point it to the schema file.
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://docs.papra.com/papra-config-schema.json",
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
</Aside>
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
|
||||
You'll find the complete list of configuration variables with their environment variables equivalents and path for files in the next section.
|
||||
|
||||
## Complete .env
|
||||
|
||||
Here is the full configuration file that you can use to configure Papra. The variables values are the default values.
|
||||
|
||||
<Code code={fullDotEnv} language="env" title=".env" />
|
||||
|
||||
## Configuration variables
|
||||
|
||||
Here is the complete list of configuration variables that you can use to configure Papra. You can set these variables in the `.env` file or pass them as environment variables when running the Docker container.
|
||||
|
||||
<Fragment set:html={marked.parse(mdSections)} />
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
title: Environment variables
|
||||
slug: configuration/environment-variables
|
||||
---
|
||||
|
||||
Coming soon.
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
title: Setup Intake Emails with OwlRelay
|
||||
description: Step-by-step guide to setup OwlRelay to receive emails in your Papra instance.
|
||||
slug: guides/intake-emails-with-owlrelay
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
import { Steps } from '@astrojs/starlight/components';
|
||||
|
||||
This guide will show you how to setup Papra to receive emails using [OwlRelay](https://owlrelay.email).
|
||||
|
||||
[OwlRelay](https://owlrelay.email) is an open-source agnostic email-to-http service developed by the Papra team. It's a free service that allows you to receive emails and forward them to your self-hosted Papra instance.
|
||||
|
||||
|
||||
## Prerequisites
|
||||
|
||||
In order to follow this guide, your Papra instance needs to be running and accessible from the internet.
|
||||
|
||||
## How it works
|
||||
|
||||
By integrating Papra with OwlRelay, your instance will generate email addresses on OwlRelay through it's API (specifying a webhook on your Papra instance). And OwlRelay will forward the emails to your Papra instance through a webhook.
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
## Setup
|
||||
<Steps>
|
||||
|
||||
1. **Create an account on OwlRelay**
|
||||
|
||||
Go to [owlrelay.email](https://owlrelay.email) and create an account.
|
||||
|
||||
2. **Create an OwlRelay API key**
|
||||
|
||||
Once you have created your account, you can create an API key by going to the [API keys page](https://app.owlrelay.email/api-keys).
|
||||
|
||||

|
||||
|
||||
3. **Configure your Papra instance**
|
||||
|
||||
Once you have created your API key, you can configure your Papra instance to receive emails by setting the `OWLRELAY_API_KEY` and `OWLRELAY_WEBHOOK_SECRET` environment variables.
|
||||
|
||||
```bash
|
||||
# Enable intake emails
|
||||
INTAKE_EMAILS_IS_ENABLED=true
|
||||
|
||||
# Tell your Papra instance to use OwlRelay
|
||||
INTAKE_EMAILS_DRIVER=owlrelay
|
||||
|
||||
# This is your OwlRelay API key
|
||||
OWLRELAY_API_KEY=owrl_*****
|
||||
|
||||
# Set a random key that will be transmitted to OwlRelay to sign the requests,
|
||||
# this is to authenticate that the emails are coming from OwlRelay
|
||||
INTAKE_EMAILS_WEBHOOK_SECRET=a-random-key
|
||||
|
||||
# [Optional]
|
||||
# This is the URL that OwlRelay will send the emails to,
|
||||
# if not provided, the webhook will be inferred from the server URL.
|
||||
# Can be relevant if you have multiple urls pointing to your Papra instance
|
||||
# or when using tunnel services
|
||||
OWLRELAY_WEBHOOK_URL=https://your-instance.com/api/intake-emails/ingest
|
||||
```
|
||||
|
||||
4. **That's it!**
|
||||
|
||||
You can now generate intake emails in your Papra instance and send emails with attachments to the email address generated.
|
||||
|
||||
</Steps>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you encounter any issues, you can:
|
||||
- Check the logs of your Papra instance to see if there are any errors.
|
||||
- Connect to your OwlRelay account to see if the emails address are generated and emails are received.
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
---
|
||||
title: Setup Intake Emails with CF Email Workers
|
||||
description: Step-by-step guide to setup a Cloudflare Email worker to receive emails in your Papra instance.
|
||||
slug: guides/intake-emails-with-cloudflare-email-workers
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
import { Steps } from '@astrojs/starlight/components';
|
||||
|
||||
This guide will show you how to setup a Cloudflare Email worker to receive emails in your Papra instance.
|
||||
|
||||
<Aside type="note">
|
||||
Setting up a Papra Intake Emails with a Cloudflare Email worker requires bit of knowledge about how to setup a CF Email worker and how to configure your Papra instance to receive emails.
|
||||
|
||||
For a simpler solution, you can use the official [OwlRelay integration](/docs/guides/intake-emails-with-papra-email-intake) guide.
|
||||
</Aside>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
In order to follow this guide, you need:
|
||||
- a [Cloudflare](https://www.cloudflare.com/) account
|
||||
- a custom domain name available on Cloudflare
|
||||
- a publicly accessible Papra instance
|
||||
- basic development skills (git and node.js to setup the Email Worker)
|
||||
|
||||
If you prefer a simpler solution, you can use the official [OwlRelay integration](/docs/guides/intake-emails-with-papra-email-intake) guide.
|
||||
|
||||
## How it works
|
||||
|
||||
In order to receive emails in your Papra instance, we need to convert the email to an HTTP request. This is currently done by setting up a Cloudflare Email Worker that will forward the email to your Papra instance, basically acting as a bridge between the email and your Papra instance.
|
||||
|
||||
The code for the Email Worker proxy is available in the [papra-hq/email-proxy](https://github.com/papra-hq/email-proxy) repository.
|
||||
|
||||

|
||||
|
||||
## Setup
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Create the Email Worker**
|
||||
|
||||
There are two ways to create an Email Worker, either from the Cloudflare dashboard or by cloning and deploying the code from the [papra-hq/email-proxy](https://github.com/papra-hq/email-proxy) repository.
|
||||
|
||||
- **Option 1**: From the Cloudflare dashboard (easier).
|
||||
|
||||
- Go to the [Cloudflare dashboard](https://dash.cloudflare.com/).
|
||||
- Select your domain.
|
||||
- Go to the `Compute (Workers)` tab.
|
||||
- Click on the `Create Worker` button.
|
||||
- Name your worker (e.g. `email-proxy`).
|
||||
- Copy the code from the [index.js](https://github.com/papra-hq/email-proxy/releases/latest/download/index.js) file (from the [papra-hq/email-proxy](https://github.com/papra-hq/email-proxy) repository) and paste it in the editor.
|
||||
|
||||
- **Option 2**: Build and deploy the Email Worker
|
||||
|
||||
Clone the [papra-hq/email-proxy](https://github.com/papra-hq/email-proxy) repository and deploy the worker using Wrangler cli. You will need to have Node.js v22 and pnpm installed.
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/papra-hq/email-proxy.git
|
||||
|
||||
# Change directory
|
||||
cd email-proxy
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Build the worker
|
||||
pnpm build
|
||||
|
||||
# Deploy the worker (you will be prompted to login to Cloudflare through wrangler)
|
||||
pnpm deploy
|
||||
```
|
||||
|
||||
2. **Configure the Email Worker**
|
||||
|
||||
Add the following environment variables to the worker:
|
||||
- `WEBHOOK_URL`: The email intake endpoint in your Papra instance (basically. `https://<your-papra-instance.com>/api/intake-emails/ingest`).
|
||||
- `WEBHOOK_SECRET`: The secret key to authenticate the webhook requests, set the same as the `INTAKE_EMAILS_WEBHOOK_SECRET` environment variable in your Papra instance.
|
||||
|
||||
3. **Configure your Papra instance**
|
||||
|
||||
In your Papra instance, add the following environment variables:
|
||||
|
||||
```bash
|
||||
# Enable intake emails
|
||||
INTAKE_EMAILS_IS_ENABLED=true
|
||||
|
||||
# Tell your Papra instance that it can generate any email address from the
|
||||
# domain you setup in the Email Worker as it's a wildcard redirection
|
||||
INTAKE_EMAILS_DRIVER=random-username
|
||||
|
||||
# This is the domain from which the intake email will be generated
|
||||
# eg. `domain.com`
|
||||
INTAKE_EMAILS_EMAIL_GENERATION_DOMAIN=papra.email
|
||||
|
||||
# This is the secret key to authenticate the webhook requests
|
||||
# set the same as the `WEBHOOK_SECRET` variable in the Email Worker
|
||||
INTAKE_EMAILS_WEBHOOK_SECRET=a-random-key
|
||||
```
|
||||
|
||||
4. **Configure the Email Routing**
|
||||
|
||||
- Go to the `Email Routing` tab in the Cloudflare dashboard.
|
||||
- Follow the email onboarding process.
|
||||
- Add a catch-all rule to forward all emails to the Email Worker you created.
|
||||
|
||||

|
||||
|
||||
5. **Test the setup**
|
||||
|
||||
In your Papra instance, go to the `Integrations` page in your organization and generates an intake email URL, setup an allowed sender (basically your email address), and copy the generated email address. Send an email to the generated address with a file attached, and check if the file is uploaded to your Papra instance.
|
||||
</Steps>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Email Worker not receiving emails
|
||||
|
||||
If the Email Worker is not receiving emails, make sure that the email routing is correctly configured in the Cloudflare dashboard.
|
||||
Also, check the [logs in the Email Worker dashboard](https://developers.cloudflare.com/workers/observability/logs/real-time-logs/) for any errors.
|
||||
|
||||
### Papra instance returning 403 Forbidden
|
||||
|
||||
If your Papra instance is returning a `403 Forbidden` error, make sure that the `INTAKE_EMAILS_IS_ENABLED` environment variable is set to `true` in your Papra instance.
|
||||
|
||||
### Papra instance is returning 401 Unauthorized
|
||||
|
||||
If your Papra instance is returning a `401 Unauthorized` error, make sure that the `INTAKE_EMAILS_WEBHOOK_SECRET` environment variable is correctly set in your Papra instance.
|
||||
|
||||
### The worker is not forwarding the email to the Papra instance
|
||||
|
||||
Make sure that the `WEBHOOK_URL` and `WEBHOOK_SECRET` environment variables are correctly set in the Email Worker, and the `INTAKE_EMAILS_WEBHOOK_SECRET` environment variable is correctly set in your Papra instance.
|
||||
Also, check the [logs in the Email Worker dashboard](https://developers.cloudflare.com/workers/observability/logs/real-time-logs/) or the logs in your Papra instance for any errors.
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
title: Setup Ingestion Folder
|
||||
description: Step-by-step guide to setup an ingestion folder to automatically ingest documents into your Papra instance.
|
||||
slug: guides/setup-ingestion-folder
|
||||
---
|
||||
import { Steps } from '@astrojs/starlight/components';
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
import { FileTree } from '@astrojs/starlight/components';
|
||||
|
||||
The ingestion folder is a special folder that is watched by Papra for new files. When a new file is added to the ingestion folder, Papra will automatically import it.
|
||||
|
||||
## Multi-Organization Structure
|
||||
|
||||
Papra supports multiple organizations within a single instance, each requiring a dedicated ingestion folder. The ingestion system uses a hierarchical structure where:
|
||||
|
||||
<FileTree>
|
||||
- ingestion-folder
|
||||
- org_abc123
|
||||
- document.pdf
|
||||
- report.docx
|
||||
- org_def456
|
||||
- file.txt
|
||||
- foo.txt # Ignored as it's not in an organization
|
||||
</FileTree>
|
||||
|
||||
|
||||
This allows you to have a single instance of Papra watching multiple organizations' ingestion folders.
|
||||
|
||||
<Aside>
|
||||
Files and folders that are within the `ingestion-root-folder` but not within an organization folder are ignored.
|
||||
</Aside>
|
||||
|
||||
## Setup
|
||||
|
||||
Add the following to your `docker-compose.yml` file:
|
||||
|
||||
```yaml title="docker-compose.yml" ins={9,12}
|
||||
services:
|
||||
papra:
|
||||
container_name: papra
|
||||
image: ghcr.io/papra-hq/papra:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "1221:1221"
|
||||
environment:
|
||||
- INGESTION_FOLDER_IS_ENABLED=true
|
||||
volumes:
|
||||
- ./app-data:/app/app-data
|
||||
- <your-ingestion-folder>:/app/ingestion
|
||||
user: "${UID}:${GID}"
|
||||
```
|
||||
|
||||
Then add files to a folder named with the organization id (available in Papra URL, e.g. `https://papra.example.com/organizations/<organization-id>`, the format is `org_<random>`).
|
||||
|
||||
```bash
|
||||
mkdir -p <your-ingestion-folder>/<org_id>
|
||||
touch <your-ingestion-folder>/<org_id>/hello.txt
|
||||
```
|
||||
|
||||
## Post-processing
|
||||
|
||||
Once a file has been ingested in your Papra organization, you can configure what happens to it by setting the `INGESTION_FOLDER_POST_PROCESSING_STRATEGY` environment variable. There are two strategies:
|
||||
|
||||
- `delete`: The file is deleted from the ingestion folder (default strategy)
|
||||
- `move`: The file is moved to the `INGESTION_FOLDER_POST_PROCESSING_MOVE_FOLDER_PATH` folder (default: `./ingestion-done`)
|
||||
|
||||
Note that the `INGESTION_FOLDER_POST_PROCESSING_MOVE_FOLDER_PATH` path is relative to the organization ingestion folder.
|
||||
|
||||
So with `INGESTION_FOLDER_POST_PROCESSING_MOVE_FOLDER_PATH=ingestion-done`, the file `<ingestion-folder>/<org_id>/file.pdf` will be moved to `<ingestion-folder>/<org_id>/ingestion-done/file.pdf` once ingested.
|
||||
|
||||
## Safeguards
|
||||
|
||||
To avoid accidental data loss, if for some reason the ingestion fails, the file is moved to the `INGESTION_FOLDER_ERROR_FOLDER_PATH` folder (default: `./ingestion-error`).
|
||||
|
||||
<Aside>
|
||||
As for the `INGESTION_FOLDER_POST_PROCESSING_MOVE_FOLDER_PATH`, the `INGESTION_FOLDER_ERROR_FOLDER_PATH` path is relative to the organization ingestion folder.
|
||||
</Aside>
|
||||
|
||||
## Polling
|
||||
|
||||
By default, Papra uses native file watchers to detect changes in the ingestion folder. On some OS (like Windows), this can be flaky with Docker. To avoid this issue, you can enable polling by setting the `INGESTION_FOLDER_WATCHER_USE_POLLING` environment variable to `true`.
|
||||
|
||||
The default polling interval is 2 seconds, you can change it by setting the `INGESTION_FOLDER_WATCHER_POLLING_INTERVAL_MS` environment variable.
|
||||
|
||||
|
||||
```yaml title="docker-compose.yml" ins={2-3}
|
||||
environment:
|
||||
- INGESTION_FOLDER_WATCHER_USE_POLLING=true
|
||||
- INGESTION_FOLDER_WATCHER_POLLING_INTERVAL_MS=2000
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
You can find the list of all configuration options in the [configuration reference](/docs/configuration-reference), the related variables are prefixed with `INGESTION_FOLDER_`.
|
||||
|
||||
|
||||
## Edge cases and behaviors
|
||||
|
||||
- The ingestion folder is watched recursively.
|
||||
- Files in the ingestion folder `done` and `error` folders are ignored.
|
||||
- When a file from the ingestion folder is already present (and not in the trash) in the organization, no ingestion is done, but the file is post-processed (deleted or moved) as successfully ingested.
|
||||
- When a file is moved to "done" or "error" folder
|
||||
- If a file with the same name and same content is present in the destination folder, the original file is deleted
|
||||
- If a file with the same name but different content is present in the destination folder, the original file is moved and a timestamp is added to the filename
|
||||
- Some files are ignored by default (`.DS_Store`, `Thumbs.db`, `desktop.ini`, etc.) see [ingestion-folders.constants.ts](https://github.com/papra-hq/papra/blob/main/apps/papra-server/src/modules/ingestion-folders/ingestion-folders.constants.ts) for the list of ignored files and patterns. You can change this by setting the `INGESTION_FOLDER_IGNORED_PATTERNS` environment variable.
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
title: Papra docs
|
||||
description: Papra documentation.
|
||||
---
|
||||
|
||||
**WIP**: This is still a work in progress. The documentation is not yet complete.
|
||||
|
||||
Papra is a minimalistic document management and archiving platform. It is designed to be simple to use and accessible to everyone. Papra is a plateform for long-term document storage and management, like a digital archive for your documents.
|
||||
74
apps/docs/src/content/docs/index.mdx
Normal file
@@ -0,0 +1,74 @@
|
||||
---
|
||||
title: Papra documentation
|
||||
description: Papra documentation.
|
||||
hero:
|
||||
title: Papra Docs
|
||||
tagline: Documentation for Papra, the minimalistic document archiving platform.
|
||||
image:
|
||||
alt: A glittering, brightly colored logo
|
||||
dark: ../../assets/logo-dark.svg
|
||||
light: ../../assets/logo-light.svg
|
||||
actions:
|
||||
- text: Self-hosting guide
|
||||
link: /self-hosting/using-docker
|
||||
icon: right-arrow
|
||||
variant: primary
|
||||
|
||||
---
|
||||
|
||||
import { LinkCard } from '@astrojs/starlight/components';
|
||||
|
||||
|
||||
Welcome to the official documentation of Papra, an intuitive open-source document management and archiving platform. It is designed to be simple to use and accessible to everyone. Papra is a plateform for long-term document storage and management, like a digital archive for your documents.
|
||||
|
||||
Papra can be used in two different ways:
|
||||
|
||||
- As a **self-hosted solution**, using the fully packaged lightweight [Docker image](/self-hosting/using-docker).
|
||||
- As a **fully managed solution**, using our cloud service available on [papra.app](https://papra.app).
|
||||
|
||||
<div style="margin-top: 40px">
|
||||
<LinkCard
|
||||
title="Get started"
|
||||
description="Learn how to self-host Papra using Docker."
|
||||
href="/self-hosting/using-docker"
|
||||
/>
|
||||
</div>
|
||||
|
||||
## Why Papra?
|
||||
|
||||
In today's digital world, managing countless important documents efficiently and securely has become crucial. Papra helps you effortlessly keep track of everything from personal files to critical business records, providing peace of mind and enhancing productivity through a robust yet user-friendly system.
|
||||
|
||||
## Features
|
||||
|
||||
- **Document management**: Upload, store, and manage your documents in one place.
|
||||
- **Organizations**: Create organizations to manage documents with family, friends, or colleagues.
|
||||
- **Search**: Quickly search for documents with full-text search.
|
||||
- **Authentication**: User accounts and authentication.
|
||||
- **Dark Mode**: A dark theme for those late-night document management sessions.
|
||||
- **Responsive Design**: Works on all devices, from desktops to mobile phones.
|
||||
- **Open Source**: The project is open-source and free to use.
|
||||
- **Self-hosting**: Host your own instance of Papra using Docker or other methods.
|
||||
- **Tags**: Organize your documents with tags.
|
||||
- **Email ingestion**: Send/forward emails to a generated address to automatically import documents.
|
||||
- **Content extraction**: Automatically extract text from images or scanned documents for search.
|
||||
- *In progress:* **i18n**: Support for multiple languages.
|
||||
- *Coming soon:* **Tagging Rules**: Automatically tag documents based on custom rules.
|
||||
- *Coming soon:* **Folder ingestion**: Automatically import documents from a folder.
|
||||
- *Coming soon:* **SDK and API**: Build your own applications on top of Papra.
|
||||
- *Coming soon:* **CLI**: Manage your documents from the command line.
|
||||
- *Coming soon:* **Document sharing**: Share documents with others.
|
||||
- *Coming soon:* **Document requests**: Generate upload links for people to add documents.
|
||||
|
||||
## Community & Open Source
|
||||
|
||||
Papra is proudly open-source under the [AGPL3](https://github.com/papra-hq/papra/blob/main/LICENSE) license. You can contribute to the project by reporting issues, suggesting features, or even contributing code.
|
||||
|
||||
|
||||
|
||||
<div style="margin-top: 40px">
|
||||
<LinkCard
|
||||
title="Get started"
|
||||
description="Learn how to self-host Papra using Docker."
|
||||
href="/self-hosting/using-docker"
|
||||
/>
|
||||
</div>
|
||||
47
apps/docs/src/content/navigation.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { StarlightUserConfig } from '@astrojs/starlight/types';
|
||||
|
||||
export const sidebar: StarlightUserConfig['sidebar'] = [
|
||||
{
|
||||
label: 'Getting Started',
|
||||
items: [
|
||||
{ label: 'Introduction', slug: '' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Self Hosting',
|
||||
items: [
|
||||
{ label: 'Using Docker', slug: 'self-hosting/using-docker' },
|
||||
{ label: 'Using Docker Compose', slug: 'self-hosting/using-docker-compose' },
|
||||
{ label: 'Configuration', slug: 'self-hosting/configuration' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Guides',
|
||||
items: [
|
||||
{
|
||||
label: 'Setup intake emails with OwlRelay',
|
||||
slug: 'guides/intake-emails-with-owlrelay',
|
||||
},
|
||||
{
|
||||
label: 'Setup intake emails with CF Email Workers',
|
||||
slug: 'guides/intake-emails-with-cloudflare-email-workers',
|
||||
},
|
||||
{
|
||||
label: 'Setup Ingestion Folder',
|
||||
slug: 'guides/setup-ingestion-folder',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Resources',
|
||||
items: [
|
||||
{
|
||||
label: 'Security Policy',
|
||||
link: 'https://github.com/papra-hq/papra/blob/main/SECURITY.md',
|
||||
attrs: {
|
||||
target: '_blank',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
49
apps/docs/src/pages/papra-config-schema.json.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import type { ConfigDefinition } from 'figue';
|
||||
import { z } from 'astro:content';
|
||||
import { mapValues } from 'lodash-es';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import { configDefinition } from '../../../papra-server/src/modules/config/config';
|
||||
|
||||
function buildConfigSchema({ configDefinition }: { configDefinition: ConfigDefinition }) {
|
||||
const schema: any = mapValues(configDefinition, (config) => {
|
||||
if (typeof config === 'object' && config !== null && 'schema' in config && 'doc' in config) {
|
||||
return config.schema;
|
||||
} else {
|
||||
return buildConfigSchema({
|
||||
configDefinition: config as ConfigDefinition,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return z.object(schema);
|
||||
}
|
||||
|
||||
function stripRequired(schema: any) {
|
||||
if (schema.type === 'object') {
|
||||
schema.required = [];
|
||||
for (const key in schema.properties) {
|
||||
stripRequired(schema.properties[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addSchema(schema: any) {
|
||||
schema.properties.$schema = {
|
||||
type: 'string',
|
||||
description: 'The schema of the configuration file, to be used by IDEs to provide autocompletion and validation',
|
||||
};
|
||||
}
|
||||
|
||||
function getConfigSchema() {
|
||||
const schema = buildConfigSchema({ configDefinition });
|
||||
const jsonSchema = zodToJsonSchema(schema, { pipeStrategy: 'output' });
|
||||
|
||||
stripRequired(jsonSchema);
|
||||
addSchema(jsonSchema);
|
||||
return jsonSchema;
|
||||
}
|
||||
|
||||
export const GET: APIRoute = () => {
|
||||
return new Response(JSON.stringify(getConfigSchema()));
|
||||
};
|
||||
15
apps/docs/src/pages/robots.txt.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
function getRobotsTxt(sitemapURL: URL) {
|
||||
return `
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: ${sitemapURL.href}
|
||||
`.trim();
|
||||
}
|
||||
|
||||
export const GET: APIRoute = ({ site }) => {
|
||||
const sitemapURL = new URL('sitemap-index.xml', site);
|
||||
return new Response(getRobotsTxt(sitemapURL));
|
||||
};
|
||||
5
apps/docs/src/scripts/posthog.script.js
Normal file
@@ -0,0 +1,5 @@
|
||||
!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="init capture register register_once register_for_session unregister unregister_for_session getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty createPersonProfile opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing debug getPageViewId captureTraceFeedback captureTraceMetric".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
|
||||
posthog.init('[POSTHOG-API-KEY]', {
|
||||
api_host: '[POSTHOG-API-HOST]',
|
||||
person_profiles: 'identified_only', // or 'always' to create profiles for anonymous users as well
|
||||
})
|
||||
@@ -1 +0,0 @@
|
||||
VITE_BASE_API_URL=http://localhost:1221
|
||||
@@ -1,3 +1,23 @@
|
||||
# Papra app client
|
||||
# Papra - App Client
|
||||
|
||||
This is the client app for Papra. It is a SolidJS SPA.
|
||||
This is the server for [Papra](https://papra.app).
|
||||
|
||||
## Development
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Unless you are developing for the demo mode (`VITE_IS_DEMO_MODE=true` in adjacent `.env` file), you will need to have the server running locally. See the [server README](../papra-server/README.md) for instructions on how to start the server.
|
||||
|
||||
To start the development server, run:
|
||||
|
||||
```bash
|
||||
# Navigate to the docs directory
|
||||
cd apps/papra-client
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Start the development server
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
The development server will be available at [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@papra/papra-app-client",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"packageManager": "pnpm@9.12.3",
|
||||
"version": "0.3.0",
|
||||
"packageManager": "pnpm@9.15.4",
|
||||
"description": "Papra frontend client",
|
||||
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
@@ -20,31 +20,33 @@
|
||||
"serve": "vite preview",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint --fix .",
|
||||
"test": "pnpm run test:unit",
|
||||
"test:unit": "vitest run",
|
||||
"test:unit:watch": "vitest watch",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest watch",
|
||||
"test:e2e": "playwright test",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"script:get-missing-i18n-keys": "tsx src/scripts/get-missing-i18n-keys.script.ts"
|
||||
"script:get-missing-i18n-keys": "tsx src/scripts/get-missing-i18n-keys.script.ts",
|
||||
"script:generate-i18n-types": "tsx src/scripts/generate-i18n-types.script.ts",
|
||||
"script:generate-i18n-types:watch": "tsx watch --include src/locales/en.yml src/scripts/generate-i18n-types.script.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@corentinth/chisels": "^1.0.2",
|
||||
"@kobalte/core": "^0.13.4",
|
||||
"@kobalte/core": "^0.13.7",
|
||||
"@kobalte/utils": "^0.9.1",
|
||||
"@modular-forms/solid": "^0.25.0",
|
||||
"@pdfslick/solid": "^2.0.0",
|
||||
"@solid-primitives/i18n": "^2.1.1",
|
||||
"@solid-primitives/storage": "^4.2.1",
|
||||
"@solidjs/router": "^0.14.3",
|
||||
"@tanstack/solid-query": "^5.61.5",
|
||||
"@tanstack/solid-table": "^8.20.5",
|
||||
"@unocss/reset": "^0.64.0",
|
||||
"better-auth": "^1.1.11",
|
||||
"better-auth": "catalog:",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk-solid": "^1.1.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"ofetch": "^1.4.1",
|
||||
"posthog-js": "^1.231.0",
|
||||
"radix3": "^1.1.2",
|
||||
"solid-js": "^1.8.11",
|
||||
"solid-sonner": "^0.2.8",
|
||||
@@ -67,6 +69,7 @@
|
||||
"unocss": "0.65.0-beta.2",
|
||||
"vite": "^5.0.11",
|
||||
"vite-plugin-solid": "^2.8.2",
|
||||
"vitest": "catalog:"
|
||||
"vitest": "catalog:",
|
||||
"yaml": "^2.7.0"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 592 KiB |
@@ -7,9 +7,13 @@ import { QueryClientProvider } from '@tanstack/solid-query';
|
||||
|
||||
import { render, Suspense } from 'solid-js/web';
|
||||
import { CommandPaletteProvider } from './modules/command-palette/command-palette.provider';
|
||||
import { ConfigProvider } from './modules/config/config.provider';
|
||||
import { DemoIndicator } from './modules/demo/demo.provider';
|
||||
import { I18nProvider } from './modules/i18n/i18n.provider';
|
||||
import { ConfirmModalProvider } from './modules/shared/confirm';
|
||||
import { queryClient } from './modules/shared/query/query-client';
|
||||
import { IdentifyUser } from './modules/tracking/components/identify-user.component';
|
||||
import { PageViewTracker } from './modules/tracking/components/pageview-tracker.component';
|
||||
import { Toaster } from './modules/ui/components/sonner';
|
||||
import { routes } from './routes';
|
||||
import '@unocss/reset/tailwind.css';
|
||||
@@ -27,22 +31,31 @@ render(
|
||||
children={routes}
|
||||
root={props => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PageViewTracker />
|
||||
<IdentifyUser />
|
||||
|
||||
<Suspense>
|
||||
<ConfirmModalProvider>
|
||||
<ColorModeScript storageType={localStorageManager.type} storageKey={colorModeStorageKey} initialColorMode={initialColorMode} />
|
||||
<ColorModeProvider
|
||||
initialColorMode={initialColorMode}
|
||||
storageManager={localStorageManager}
|
||||
>
|
||||
<CommandPaletteProvider>
|
||||
<div class="min-h-screen font-sans text-sm font-400">{props.children}</div>
|
||||
<I18nProvider>
|
||||
<ConfirmModalProvider>
|
||||
<ColorModeScript storageType={localStorageManager.type} storageKey={colorModeStorageKey} initialColorMode={initialColorMode} />
|
||||
<ColorModeProvider
|
||||
initialColorMode={initialColorMode}
|
||||
storageManager={localStorageManager}
|
||||
>
|
||||
<CommandPaletteProvider>
|
||||
<ConfigProvider>
|
||||
<div class="min-h-screen font-sans text-sm font-400">
|
||||
{props.children}
|
||||
</div>
|
||||
<DemoIndicator />
|
||||
</ConfigProvider>
|
||||
|
||||
<Toaster />
|
||||
<DemoIndicator />
|
||||
</CommandPaletteProvider>
|
||||
</ColorModeProvider>
|
||||
<Toaster />
|
||||
</CommandPaletteProvider>
|
||||
</ColorModeProvider>
|
||||
|
||||
</ConfirmModalProvider>
|
||||
</ConfirmModalProvider>
|
||||
</I18nProvider>
|
||||
</Suspense>
|
||||
</QueryClientProvider>
|
||||
)}
|
||||
|
||||
219
apps/papra-client/src/locales/en.yml
Normal file
@@ -0,0 +1,219 @@
|
||||
auth:
|
||||
request-password-reset:
|
||||
title: Reset your password
|
||||
description: Enter your email to reset your password.
|
||||
requested: If an account exists for this email, we've sent you an email to reset your password.
|
||||
back-to-login: Back to login
|
||||
form:
|
||||
email:
|
||||
label: Email
|
||||
placeholder: 'Example: ada@papra.app'
|
||||
required: Please enter your email address
|
||||
invalid: This email address is invalid
|
||||
submit: Request password reset
|
||||
|
||||
reset-password:
|
||||
title: Reset your password
|
||||
description: Enter your new password to reset your password.
|
||||
reset: Your password has been reset.
|
||||
back-to-login: Back to login
|
||||
form:
|
||||
new-password:
|
||||
label: New password
|
||||
placeholder: 'Example: **********'
|
||||
required: Please enter your new password
|
||||
min-length: Password must be at least {{ minLength }} characters
|
||||
max-length: Password must be less than {{ maxLength }} characters
|
||||
submit: Reset password
|
||||
|
||||
email-provider:
|
||||
open: Open {{ provider }}
|
||||
|
||||
login:
|
||||
title: Login to Papra
|
||||
description: Enter your email or use social login to access your Papra account.
|
||||
login-with-provider: Login with {{ provider }}
|
||||
no-account: Don't have an account?
|
||||
register: Register
|
||||
form:
|
||||
email:
|
||||
label: Email
|
||||
placeholder: 'Example: ada@papra.app'
|
||||
required: Please enter your email address
|
||||
invalid: This email address is invalid
|
||||
password:
|
||||
label: Password
|
||||
placeholder: Set a password
|
||||
required: Please enter your password
|
||||
remember-me:
|
||||
label: Remember me
|
||||
forgot-password:
|
||||
label: Forgot password?
|
||||
submit: Login
|
||||
|
||||
register:
|
||||
title: Register to Papra
|
||||
description: Enter your email or use social login to access your Papra account.
|
||||
register-with-email: Register with email
|
||||
register-with-provider: Register with {{ provider }}
|
||||
providers:
|
||||
google: Google
|
||||
github: GitHub
|
||||
have-account: Already have an account?
|
||||
login: Login
|
||||
registration-disabled:
|
||||
title: Registration is disabled
|
||||
description: The creation of new accounts is currently disabled on this instance of Papra. Only users with existing accounts can log in. If you think this is a mistake, please contact the administrator of this instance.
|
||||
form:
|
||||
email:
|
||||
label: Email
|
||||
placeholder: 'Example: ada@papra.app'
|
||||
required: Please enter your email address
|
||||
invalid: This email address is invalid
|
||||
password:
|
||||
label: Password
|
||||
placeholder: Set a password
|
||||
required: Please enter your password
|
||||
min-length: Password must be at least {{ minLength }} characters
|
||||
max-length: Password must be less than {{ maxLength }} characters
|
||||
name:
|
||||
label: Name
|
||||
placeholder: 'Example: Ada Lovelace'
|
||||
required: Please enter your name
|
||||
max-length: Name must be less than {{ maxLength }} characters
|
||||
submit: Register
|
||||
email-validation-required:
|
||||
title: Verify your email
|
||||
description: A verification email has been sent to your email address. Please verify your email address by clicking the link in the email.
|
||||
legal-links:
|
||||
description: By continuing, you acknowledge that you understand and agree to the {{ terms }} and {{ privacy }}.
|
||||
terms: Terms of Service
|
||||
privacy: Privacy Policy
|
||||
|
||||
tags:
|
||||
no-tags:
|
||||
title: No tags yet
|
||||
description: This organization has no tags yet. Tags are used to categorize documents. You can add tags to your documents to make them easier to find and organize.
|
||||
create-tag: Create tag
|
||||
|
||||
layout:
|
||||
menu:
|
||||
home: Home
|
||||
documents: Documents
|
||||
tags: Tags
|
||||
tagging-rules: Tagging rules
|
||||
integrations: Integrations
|
||||
deleted-documents: Deleted documents
|
||||
organization-settings: Organization settings
|
||||
|
||||
tagging-rules:
|
||||
field:
|
||||
name: document name
|
||||
content: document content
|
||||
operator:
|
||||
equals: equals
|
||||
not-equals: not equals
|
||||
contains: contains
|
||||
not-contains: not contains
|
||||
starts-with: starts with
|
||||
ends-with: ends with
|
||||
list:
|
||||
title: Tagging rules
|
||||
description: Manage your organization's tagging rules, to automatically tag documents based on conditions you define.
|
||||
demo-warning: 'Note: As this is a demo environment (with no server), tagging rules will not be applied to newly added documents.'
|
||||
no-tagging-rules:
|
||||
title: No tagging rules
|
||||
description: Create a tagging rule to automatically tag your added documents based on conditions you define.
|
||||
create-tagging-rule: Create tagging rule
|
||||
card:
|
||||
no-conditions: No conditions
|
||||
one-condition: 1 condition
|
||||
conditions: '{{ count }} conditions'
|
||||
delete: Delete rule
|
||||
edit: Edit rule
|
||||
create:
|
||||
title: Create tagging rule
|
||||
success: Tagging rule created successfully
|
||||
error: Failed to create tagging rule
|
||||
submit: Create rule
|
||||
form:
|
||||
name:
|
||||
label: Name
|
||||
placeholder: 'Example: Tag invoices'
|
||||
min-length: Please enter a name for the rule
|
||||
max-length: The name must be less than 64 characters
|
||||
description:
|
||||
label: Description
|
||||
placeholder: 'Example: Tag documents with "invoice" in the name'
|
||||
max-length: The description must be less than 256 characters
|
||||
conditions:
|
||||
label: Conditions
|
||||
description: Define the conditions that must be met for the rule to apply. All conditions must be met for the rule to apply.
|
||||
add-condition: Add condition
|
||||
no-conditions:
|
||||
title: No conditions
|
||||
description: You didn't add any conditions to this rule. This rule will apply its tags to all documents.
|
||||
confirm: Apply rule without conditions
|
||||
cancel: Cancel
|
||||
field:
|
||||
label: Field
|
||||
operator:
|
||||
label: Operator
|
||||
value:
|
||||
label: Value
|
||||
placeholder: 'Example: invoice'
|
||||
min-length: Please enter a value for the condition
|
||||
tags:
|
||||
label: Tags
|
||||
description: Select the tags to apply to the added documents that match the conditions
|
||||
min-length: At least one tag to apply is required
|
||||
add-tag: Create tag
|
||||
submit: Create rule
|
||||
update:
|
||||
title: Update tagging rule
|
||||
success: Tagging rule updated successfully
|
||||
error: Failed to update tagging rule
|
||||
submit: Update rule
|
||||
cancel: Cancel
|
||||
demo:
|
||||
popup:
|
||||
description: This is a demo environment, all data is save to your browser local storage.
|
||||
discord: Join the {{ discordLink }} to get support, propose features or just chat.
|
||||
discord-link-label: Discord server
|
||||
reset: Reset demo data
|
||||
hide: Hide
|
||||
|
||||
trash:
|
||||
delete-all:
|
||||
button: Delete all
|
||||
confirm:
|
||||
title: Permanently delete all documents?
|
||||
description: Are you sure you want to permanently delete all documents from the trash? This action cannot be undone.
|
||||
label: Delete
|
||||
cancel: Cancel
|
||||
delete:
|
||||
button: Delete
|
||||
confirm:
|
||||
title: Permanently delete document?
|
||||
description: Are you sure you want to permanently delete this document from the trash? This action cannot be undone.
|
||||
label: Delete
|
||||
cancel: Cancel
|
||||
deleted:
|
||||
success:
|
||||
title: Document deleted
|
||||
description: The document has been permanently deleted.
|
||||
|
||||
import-documents:
|
||||
title:
|
||||
error: '{{ count }} documents failed'
|
||||
success: '{{ count }} documents imported'
|
||||
pending: '{{ count }} / {{ total }} documents imported'
|
||||
none: Import documents
|
||||
no-import-in-progress: No document import in progress
|
||||
|
||||
api-errors:
|
||||
document.already_exists: The document already exists
|
||||
document.file_too_big: The document file is too big
|
||||
intake_email.limit_reached: The maximum number of intake emails for this organization has been reached. Please upgrade your plan to create more intake emails.
|
||||
user.max_organization_count_reached: You have reached the maximum number of organizations you can create, if you need to create more, please contact support.
|
||||
default: An error occurred while processing your request.
|
||||
37
apps/papra-client/src/locales/fr.yml
Normal file
@@ -0,0 +1,37 @@
|
||||
auth:
|
||||
login:
|
||||
title: Connexion à Papra
|
||||
description: Entrez votre adresse e-mail ou utilisez un service tiers pour accéder à votre compte Papra.
|
||||
login-with-provider: Connexion via {{ provider }}
|
||||
no-account: Vous n'avez pas de compte ?
|
||||
register: S'inscrire
|
||||
email-validation-required:
|
||||
title: Vérifiez votre adresse e-mail
|
||||
description: Un e-mail de vérification a été envoyé à votre adresse. Veuillez vérifier votre adresse en cliquant sur le lien dans l'e-mail.
|
||||
legal-links:
|
||||
description: En continuant, vous reconnaissez que vous comprenez et acceptez les {{ terms }} et la {{ privacy }}.
|
||||
terms: Conditions d'utilisation
|
||||
privacy: Politique de confidentialité
|
||||
|
||||
tags:
|
||||
no-tags:
|
||||
title: Aucun tag pour le moment
|
||||
description: Cette organisation n'a pas encore de tags. Les tags sont utilisés pour catégoriser les documents. Vous pouvez ajouter des tags à vos documents pour les rendre plus faciles à trouver et à organiser.
|
||||
create-tag: Créer un tag
|
||||
|
||||
layout:
|
||||
menu:
|
||||
home: Accueil
|
||||
documents: Documents
|
||||
tags: Tags
|
||||
integrations: Intégrations
|
||||
deleted-documents: Documents supprimés
|
||||
organization-settings: Paramètres de l'organisation
|
||||
|
||||
demo:
|
||||
popup:
|
||||
description: Ceci est un environnement de démo, toutes les données sont enregistrées dans le local storage de votre navigateur.
|
||||
discord: Rejoignez le {{ discordLink }} pour obtenir de l'aide, proposer des fonctionnalités ou discuter avec l'équipe.
|
||||
discord-link-label: Serveur Discord
|
||||
reset: Réinitialiser la démo
|
||||
hide: Masquer
|
||||
@@ -1,4 +1,9 @@
|
||||
export const ssoProviders = [
|
||||
{
|
||||
key: 'google',
|
||||
name: 'Google',
|
||||
icon: 'i-tabler-brand-google-filled',
|
||||
},
|
||||
{
|
||||
key: 'github',
|
||||
name: 'GitHub',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { createAuthClient } from 'better-auth/solid';
|
||||
import type { createAuthClient } from './auth.services';
|
||||
|
||||
export function createDemoAuthClient() {
|
||||
return {
|
||||
const baseClient = {
|
||||
useSession: () => () => ({
|
||||
isPending: false,
|
||||
data: {
|
||||
@@ -16,5 +16,18 @@ export function createDemoAuthClient() {
|
||||
social: () => Promise.resolve({}),
|
||||
},
|
||||
signOut: () => Promise.resolve({}),
|
||||
} as unknown as ReturnType<typeof createAuthClient>;
|
||||
signUp: () => Promise.resolve({}),
|
||||
forgetPassword: () => Promise.resolve({}),
|
||||
resetPassword: () => Promise.resolve({}),
|
||||
sendVerificationEmail: () => Promise.resolve({}),
|
||||
};
|
||||
|
||||
return new Proxy(baseClient, {
|
||||
get: (target, prop) => {
|
||||
if (!(prop in target)) {
|
||||
console.warn(`Accessing undefined property "${String(prop)}" in demo auth client`);
|
||||
}
|
||||
return target[prop as keyof typeof target];
|
||||
},
|
||||
}) as unknown as ReturnType<typeof createAuthClient>;
|
||||
}
|
||||
|
||||
15
apps/papra-client/src/modules/auth/auth.models.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Config } from '../config/config';
|
||||
import { get } from 'lodash-es';
|
||||
import { ssoProviders } from './auth.constants';
|
||||
|
||||
export function isAuthErrorWithCode({ error, code }: { error: unknown; code: string }) {
|
||||
return get(error, 'code') === code;
|
||||
}
|
||||
|
||||
export const isEmailVerificationRequiredError = ({ error }: { error: unknown }) => isAuthErrorWithCode({ error, code: 'EMAIL_NOT_VERIFIED' });
|
||||
|
||||
export function getEnabledSsoProviderConfigs({ config }: { config: Config }) {
|
||||
const enabledSsoProviders = ssoProviders.filter(({ key }) => get(config, `auth.providers.${key}.isEnabled`));
|
||||
|
||||
return enabledSsoProviders;
|
||||
}
|
||||
@@ -1,13 +1,40 @@
|
||||
import { createAuthClient } from 'better-auth/solid';
|
||||
import { config } from '../config/config';
|
||||
import { createAuthClient as createBetterAuthClient } from 'better-auth/solid';
|
||||
|
||||
import { buildTimeConfig } from '../config/config';
|
||||
import { trackingServices } from '../tracking/tracking.services';
|
||||
import { createDemoAuthClient } from './auth.demo.services';
|
||||
|
||||
export function createAuthClient() {
|
||||
const client = createBetterAuthClient({
|
||||
baseURL: buildTimeConfig.baseApiUrl,
|
||||
});
|
||||
|
||||
return {
|
||||
// we can't spread the client because it is a proxy object
|
||||
signIn: client.signIn,
|
||||
signUp: client.signUp,
|
||||
forgetPassword: client.forgetPassword,
|
||||
resetPassword: client.resetPassword,
|
||||
sendVerificationEmail: client.sendVerificationEmail,
|
||||
useSession: client.useSession,
|
||||
signOut: async () => {
|
||||
trackingServices.capture({ event: 'User logged out' });
|
||||
const result = await client.signOut();
|
||||
trackingServices.reset();
|
||||
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const {
|
||||
useSession,
|
||||
signIn,
|
||||
signUp,
|
||||
signOut,
|
||||
} = config.isDemoMode
|
||||
forgetPassword,
|
||||
resetPassword,
|
||||
sendVerificationEmail,
|
||||
} = buildTimeConfig.isDemoMode
|
||||
? createDemoAuthClient()
|
||||
: createAuthClient({
|
||||
baseURL: config.baseApiUrl,
|
||||
});
|
||||
: createAuthClient();
|
||||
|
||||
3
apps/papra-client/src/modules/auth/auth.types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { ssoProviders } from './auth.constants';
|
||||
|
||||
export type SsoProviderKey = (typeof ssoProviders)[number]['key'];
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { ParentComponent } from 'solid-js';
|
||||
|
||||
export const AuthLayout: ParentComponent = (props) => {
|
||||
return (
|
||||
<div class="flex justify-center h-screen">
|
||||
|
||||
{/* <div class="hidden xl:block flex-grow-1 p-8">
|
||||
<div class="h-full w-full bg-card rounded-lg border bg-[linear-gradient(to_right,#80808015_1px,transparent_1px),linear-gradient(to_bottom,#80808015_1px,transparent_1px)] bg-[size:48px_48px]">
|
||||
</div>
|
||||
</div> */}
|
||||
<div class="flex-grow-1 max-w-60%">
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { useConfig } from '@/modules/config/config.provider';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { createVitrineUrl } from '@/modules/shared/utils/urls';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { A } from '@solidjs/router';
|
||||
|
||||
export const AuthLegalLinks: Component = () => {
|
||||
const { config } = useConfig();
|
||||
const { te, t } = useI18n();
|
||||
|
||||
if (!config.auth.showLegalLinksOnAuthPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p class="text-muted-foreground mt-2">
|
||||
{te('auth.legal-links.description', {
|
||||
terms: (
|
||||
<Button variant="link" as={A} class="inline px-0" href={createVitrineUrl({ path: 'terms-of-service' })}>
|
||||
{t('auth.legal-links.terms')}
|
||||
</Button>
|
||||
),
|
||||
privacy: (
|
||||
<Button variant="link" as={A} class="inline px-0" href={createVitrineUrl({ path: 'privacy' })}>
|
||||
{t('auth.legal-links.privacy')}
|
||||
</Button>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,8 @@
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { type Component, type ComponentProps, splitProps } from 'solid-js';
|
||||
|
||||
const providers = [
|
||||
{
|
||||
name: 'Gmail',
|
||||
@@ -239,3 +244,21 @@ export function getEmailProvider({ email }: { email?: string }) {
|
||||
|
||||
return { provider };
|
||||
}
|
||||
|
||||
export const OpenEmailProvider: Component<{ email?: string } & ComponentProps<typeof Button>> = (props) => {
|
||||
const [local, rest] = splitProps(props, ['email', 'class']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const { provider } = getEmailProvider({ email: local.email });
|
||||
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button as="a" href={provider.url} target="_blank" rel="noopener noreferrer" class={cn('w-full', local.class)} {...rest}>
|
||||
<div class="i-tabler-external-link mr-2 size-4" />
|
||||
{t('auth.email-provider.open', { provider: provider.name })}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -13,7 +13,7 @@ export const SsoProviderButton: Component<{ name: string; icon: string; onClick:
|
||||
|
||||
return (
|
||||
<Button variant="secondary" class="block w-full flex items-center justify-center" onClick={navigateToProvider} disabled={getIsLoading()}>
|
||||
<span class={cn(`mr-2 text-lg inline-block`, getIsLoading() ? 'i-tabler-loader-2 animate-spin' : props.icon)} />
|
||||
<span class={cn(`mr-2 size-4.5 inline-block`, getIsLoading() ? 'i-tabler-loader-2 animate-spin' : props.icon)} />
|
||||
{props.label}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
|
||||
|
||||
export const EmailValidationRequiredPage: Component = () => {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<div class="flex items-center justify-center h-full p-6 sm:pb-32">
|
||||
<div class="max-w-sm w-full">
|
||||
<div class="i-tabler-mail size-12 text-primary mb-2" />
|
||||
|
||||
<h1 class="text-xl font-bold">
|
||||
{t('auth.email-validation-required.title')}
|
||||
</h1>
|
||||
<p class="text-muted-foreground mt-1 mb-4">
|
||||
{t('auth.email-validation-required.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
};
|
||||
@@ -1,129 +0,0 @@
|
||||
import type { Component, ParentComponent } from 'solid-js';
|
||||
import { config } from '@/modules/config/config';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { createVitrineUrl } from '@/modules/shared/utils/urls';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { Separator } from '@/modules/ui/components/separator';
|
||||
import { A } from '@solidjs/router';
|
||||
import { createSignal, For } from 'solid-js';
|
||||
import { signIn } from '../auth.services';
|
||||
|
||||
const InlineLink: ParentComponent<{ href: string }> = props => (
|
||||
<Button variant="link" as={A} href={props.href} class="inline px-0">
|
||||
{props.children}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const SsoProviderButton: Component<{ name: string; icon: string; onClick: () => Promise<void>; label: string }> = (props) => {
|
||||
const [getIsLoading, setIsLoading] = createSignal(false);
|
||||
|
||||
const navigateToProvider = async () => {
|
||||
setIsLoading(true);
|
||||
await props.onClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant="secondary" class="block w-full flex items-center justify-center" onClick={navigateToProvider} disabled={getIsLoading()}>
|
||||
<span class={cn(`mr-2 text-lg inline-block`, getIsLoading() ? 'i-tabler-loader-2 animate-spin' : props.icon)} />
|
||||
{props.label}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export const GenericAuthPage: Component<{ type: 'login' | 'register' }> = (props) => {
|
||||
const ssoProviders = [
|
||||
// {
|
||||
// name: 'Google',
|
||||
// icon: 'i-tabler-brand-google-filled',
|
||||
// url: new URL('/api/auth/google', baseApiUrl).toString(),
|
||||
// labels: {
|
||||
// login: 'Login with Google',
|
||||
// register: 'Register with Google',
|
||||
// },
|
||||
// },
|
||||
{
|
||||
name: 'GitHub',
|
||||
icon: 'i-tabler-brand-github',
|
||||
onClick: async () => {
|
||||
await signIn.social({ provider: 'github' });
|
||||
},
|
||||
labels: {
|
||||
login: 'Login with GitHub',
|
||||
register: 'Register with GitHub',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const byType = <L, R>({ login, register }: { login: L; register: R }): L | R => (props.type === 'login' ? login : register);
|
||||
|
||||
if (!config.isRegistrationEnabled && props.type === 'register') {
|
||||
return (
|
||||
<div class="flex items-center justify-center min-h-screen p-6">
|
||||
<div class="max-w-sm w-full">
|
||||
<h1 class="text-xl font-bold">
|
||||
Registration is disabled
|
||||
</h1>
|
||||
<p class="text-muted-foreground mt-1 mb-2">
|
||||
Registration is disabled on this instance. Please contact your administrator for more information.
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
Already have an account?
|
||||
{' '}
|
||||
<InlineLink href="/login">Login</InlineLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex items-center justify-center min-h-screen p-6">
|
||||
<div class="max-w-sm w-full">
|
||||
<h1 class="text-xl font-bold">
|
||||
{byType({
|
||||
login: 'Login to Papra',
|
||||
register: 'Register to Papra',
|
||||
})}
|
||||
</h1>
|
||||
<p class="text-muted-foreground mt-1 mb-6">
|
||||
{byType({
|
||||
login: 'Enter your email or use social login to access your Papra account.',
|
||||
register: 'Enter your email or use social login to create your Papra account.',
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div class=" w-full flex flex-col gap-2">
|
||||
<For each={ssoProviders}>
|
||||
{provider => (
|
||||
<SsoProviderButton label={byType(provider.labels)} {...provider} />
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Separator class="my-6" />
|
||||
|
||||
<p class="text-muted-foreground">
|
||||
{byType({
|
||||
login: 'Don\'t have an account?',
|
||||
register: 'Already have an account?',
|
||||
})}
|
||||
{' '}
|
||||
<InlineLink href={byType({ login: '/register', register: '/login' })}>
|
||||
{byType({ login: 'Register', register: 'Login' })}
|
||||
</InlineLink>
|
||||
</p>
|
||||
|
||||
<p class="text-muted-foreground mt-2">
|
||||
By continuing, you acknowledge that you understand and agree to the
|
||||
{' '}
|
||||
<InlineLink href={createVitrineUrl({ path: 'terms-of-service' })}>Terms of Service</InlineLink>
|
||||
{' '}
|
||||
and
|
||||
{' '}
|
||||
<InlineLink href={createVitrineUrl({ path: 'privacy-policy' })}>Privacy Policy</InlineLink>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,19 +1,32 @@
|
||||
import type { SsoProviderKey } from '../auth.types';
|
||||
import { useConfig } from '@/modules/config/config.provider';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { createForm } from '@/modules/shared/form/form';
|
||||
import { createVitrineUrl } from '@/modules/shared/utils/urls';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { Checkbox, CheckboxControl, CheckboxLabel } from '@/modules/ui/components/checkbox';
|
||||
import { Separator } from '@/modules/ui/components/separator';
|
||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { A } from '@solidjs/router';
|
||||
import { type Component, For } from 'solid-js';
|
||||
import { A, useNavigate } from '@solidjs/router';
|
||||
import { type Component, createSignal, For, Show } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { ssoProviders } from '../auth.constants';
|
||||
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
|
||||
import { getEnabledSsoProviderConfigs, isEmailVerificationRequiredError } from '../auth.models';
|
||||
import { signIn } from '../auth.services';
|
||||
import { AuthLayout } from '../components/auth-layout.component';
|
||||
import { AuthLegalLinks } from '../components/legal-links.component';
|
||||
import { SsoProviderButton } from '../components/sso-provider-button.component';
|
||||
|
||||
export const EmailLoginForm: Component = () => {
|
||||
const navigate = useNavigate();
|
||||
const { config } = useConfig();
|
||||
const { t } = useI18n();
|
||||
|
||||
const { form, Form, Field } = createForm({
|
||||
onSubmit: async ({ email, password }) => {
|
||||
const { error } = await signIn.email({ email, password });
|
||||
onSubmit: async ({ email, password, rememberMe }) => {
|
||||
const { error } = await signIn.email({ email, password, rememberMe, callbackURL: config.baseUrl });
|
||||
|
||||
if (isEmailVerificationRequiredError({ error })) {
|
||||
navigate('/email-validation-required');
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
@@ -23,15 +36,18 @@ export const EmailLoginForm: Component = () => {
|
||||
email: v.pipe(
|
||||
v.string(),
|
||||
v.trim(),
|
||||
v.nonEmpty('Please enter your email address'),
|
||||
v.email('This is not a valid email address'),
|
||||
v.nonEmpty(t('auth.login.form.email.required')),
|
||||
v.email(t('auth.login.form.email.invalid')),
|
||||
),
|
||||
password: v.pipe(
|
||||
v.string('Password is required'),
|
||||
v.nonEmpty('Please enter your password'),
|
||||
v.string(t('auth.login.form.password.required')),
|
||||
v.nonEmpty(t('auth.login.form.password.required')),
|
||||
),
|
||||
rememberMe: v.boolean(),
|
||||
}),
|
||||
|
||||
initialValues: {
|
||||
rememberMe: true,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -39,8 +55,8 @@ export const EmailLoginForm: Component = () => {
|
||||
<Field name="email">
|
||||
{(field, inputProps) => (
|
||||
<TextFieldRoot class="flex flex-col gap-1 mb-4">
|
||||
<TextFieldLabel for="email">Email</TextFieldLabel>
|
||||
<TextField type="email" id="email" placeholder="Eg. ada@papra.app" {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
|
||||
<TextFieldLabel for="email">{t('auth.login.form.email.label')}</TextFieldLabel>
|
||||
<TextField type="email" id="email" placeholder={t('auth.login.form.email.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
@@ -49,14 +65,32 @@ export const EmailLoginForm: Component = () => {
|
||||
<Field name="password">
|
||||
{(field, inputProps) => (
|
||||
<TextFieldRoot class="flex flex-col gap-1 mb-4">
|
||||
<TextFieldLabel for="password">Password</TextFieldLabel>
|
||||
<TextField type="password" id="password" placeholder="Your password" {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
|
||||
<TextFieldLabel for="password">{t('auth.login.form.password.label')}</TextFieldLabel>
|
||||
|
||||
<TextField type="password" id="password" placeholder={t('auth.login.form.password.placeholder')} {...inputProps} value={field.value} aria-invalid={Boolean(field.error)} />
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Button type="submit" class="w-full">Login</Button>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<Field name="rememberMe" type="boolean">
|
||||
{(field, inputProps) => (
|
||||
<Checkbox class="flex items-center gap-2" defaultChecked={field.value}>
|
||||
<CheckboxControl inputProps={inputProps} />
|
||||
<CheckboxLabel class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
{t('auth.login.form.remember-me.label')}
|
||||
</CheckboxLabel>
|
||||
</Checkbox>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Button variant="link" as={A} class="inline p-0! h-auto" href="/request-password-reset">
|
||||
{t('auth.login.form.forgot-password.label')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button type="submit" class="w-full">{t('auth.login.form.submit')}</Button>
|
||||
|
||||
<div class="text-red-500 text-sm mt-4">{form.response.message}</div>
|
||||
|
||||
@@ -65,49 +99,59 @@ export const EmailLoginForm: Component = () => {
|
||||
};
|
||||
|
||||
export const LoginPage: Component = () => {
|
||||
const loginWithProvider = async (provider: typeof ssoProviders[number]) => {
|
||||
await signIn.social({ provider: provider.key });
|
||||
const { config } = useConfig();
|
||||
const { t } = useI18n();
|
||||
|
||||
const [getShowEmailLogin, setShowEmailLogin] = createSignal(false);
|
||||
|
||||
const loginWithProvider = async (provider: { key: SsoProviderKey }) => {
|
||||
await signIn.social({ provider: provider.key, callbackURL: config.baseUrl });
|
||||
};
|
||||
|
||||
const getHasSsoProviders = () => getEnabledSsoProviderConfigs({ config }).length > 0;
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<div class="flex items-center justify-center min-h-screen p-6 pb-18">
|
||||
<div class="flex items-center justify-center h-full p-6 sm:pb-32">
|
||||
<div class="max-w-sm w-full">
|
||||
<h1 class="text-xl font-bold">
|
||||
Login to Papra
|
||||
</h1>
|
||||
<p class="text-muted-foreground mt-1 mb-4">
|
||||
Enter your email or use social login to access your Papra account.
|
||||
</p>
|
||||
<h1 class="text-xl font-bold">{t('auth.login.title')}</h1>
|
||||
<p class="text-muted-foreground mt-1 mb-4">{t('auth.login.description')}</p>
|
||||
|
||||
{/* <EmailLoginForm />
|
||||
{getShowEmailLogin() || !getHasSsoProviders()
|
||||
? <EmailLoginForm />
|
||||
: (
|
||||
<Button onClick={() => setShowEmailLogin(true)} class="w-full">
|
||||
<div class="i-tabler-mail mr-2 size-4.5" />
|
||||
{t('auth.login.login-with-provider', { provider: 'Email' })}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Separator class="my-4" /> */}
|
||||
<Show when={getHasSsoProviders()}>
|
||||
<Separator class="my-4" />
|
||||
|
||||
<For each={ssoProviders}>
|
||||
{provider => (
|
||||
<SsoProviderButton name={provider.name} icon={provider.icon} onClick={() => loginWithProvider(provider)} label={`Login with ${provider.name}`} />
|
||||
)}
|
||||
</For>
|
||||
<div class="flex flex-col gap-2">
|
||||
<For each={getEnabledSsoProviderConfigs({ config })}>
|
||||
{provider => (
|
||||
<SsoProviderButton
|
||||
name={provider.name}
|
||||
icon={provider.icon}
|
||||
onClick={() => loginWithProvider(provider)}
|
||||
label={t('auth.login.login-with-provider', { provider: provider.name })}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<p class="text-muted-foreground mt-4">
|
||||
Don't have an account?
|
||||
{t('auth.login.no-account')}
|
||||
{' '}
|
||||
<Button variant="link" as={A} class="inline px-0" href="/register">
|
||||
Register
|
||||
{t('auth.login.register')}
|
||||
</Button>
|
||||
</p>
|
||||
|
||||
<p class="text-muted-foreground mt-2">
|
||||
By continuing, you acknowledge that you understand and agree to the
|
||||
{' '}
|
||||
<Button variant="link" as={A} class="inline px-0" href={createVitrineUrl({ path: 'terms-of-service' })}>Terms of Service</Button>
|
||||
{' '}
|
||||
and
|
||||
{' '}
|
||||
<Button variant="link" as={A} class="inline px-0" href={createVitrineUrl({ path: 'privacy-policy' })}>Privacy Policy</Button>
|
||||
.
|
||||
</p>
|
||||
<AuthLegalLinks />
|
||||
</div>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
|
||||
191
apps/papra-client/src/modules/auth/pages/register.page.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import type { ssoProviders } from '../auth.constants';
|
||||
import { useConfig } from '@/modules/config/config.provider';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { createForm } from '@/modules/shared/form/form';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { Separator } from '@/modules/ui/components/separator';
|
||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { A, useNavigate } from '@solidjs/router';
|
||||
import { type Component, createSignal, For, Show } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
|
||||
import { getEnabledSsoProviderConfigs } from '../auth.models';
|
||||
import { signIn, signUp } from '../auth.services';
|
||||
import { AuthLegalLinks } from '../components/legal-links.component';
|
||||
import { SsoProviderButton } from '../components/sso-provider-button.component';
|
||||
|
||||
export const EmailRegisterForm: Component = () => {
|
||||
const { config } = useConfig();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
const { form, Form, Field } = createForm({
|
||||
onSubmit: async ({ email, password, name }) => {
|
||||
const { error } = await signUp.email({
|
||||
email,
|
||||
password,
|
||||
name,
|
||||
callbackURL: config.baseUrl,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (config.auth.isEmailVerificationRequired) {
|
||||
navigate('/email-validation-required');
|
||||
return;
|
||||
}
|
||||
|
||||
navigate('/');
|
||||
},
|
||||
schema: v.object({
|
||||
email: v.pipe(
|
||||
v.string(),
|
||||
v.trim(),
|
||||
v.nonEmpty(t('auth.register.form.email.required')),
|
||||
v.email(t('auth.register.form.email.invalid')),
|
||||
),
|
||||
password: v.pipe(
|
||||
v.string(),
|
||||
v.nonEmpty(t('auth.register.form.password.required')),
|
||||
v.minLength(8, t('auth.register.form.password.min-length', { minLength: 8 })),
|
||||
v.maxLength(128, t('auth.register.form.password.max-length', { maxLength: 128 })),
|
||||
),
|
||||
name: v.pipe(
|
||||
v.string(t('auth.register.form.name.label')),
|
||||
v.nonEmpty(t('auth.register.form.name.required')),
|
||||
v.maxLength(64, t('auth.register.form.name.max-length', { maxLength: 64 })),
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<Field name="email">
|
||||
{(field, inputProps) => (
|
||||
<TextFieldRoot class="flex flex-col gap-1 mb-4">
|
||||
<TextFieldLabel for="email">{t('auth.register.form.email.label')}</TextFieldLabel>
|
||||
<TextField type="email" id="email" placeholder={t('auth.register.form.email.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field name="name">
|
||||
{(field, inputProps) => (
|
||||
<TextFieldRoot class="flex flex-col gap-1 mb-4">
|
||||
<TextFieldLabel for="name">{t('auth.register.form.name.label')}</TextFieldLabel>
|
||||
<TextField type="text" id="name" placeholder={t('auth.register.form.name.placeholder')} {...inputProps} value={field.value} aria-invalid={Boolean(field.error)} />
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field name="password">
|
||||
{(field, inputProps) => (
|
||||
<TextFieldRoot class="flex flex-col gap-1 mb-4">
|
||||
<TextFieldLabel for="password">{t('auth.register.form.password.label')}</TextFieldLabel>
|
||||
|
||||
<TextField type="password" id="password" placeholder={t('auth.register.form.password.placeholder')} {...inputProps} value={field.value} aria-invalid={Boolean(field.error)} />
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Button type="submit" class="w-full">{t('auth.register.form.submit')}</Button>
|
||||
|
||||
<div class="text-red-500 text-sm mt-4">{form.response.message}</div>
|
||||
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export const RegisterPage: Component = () => {
|
||||
const { config } = useConfig();
|
||||
const { t } = useI18n();
|
||||
|
||||
if (!config.auth.isRegistrationEnabled) {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<div class="flex items-center justify-center h-full p-6 sm:pb-32">
|
||||
<div class="max-w-sm w-full">
|
||||
<h1 class="text-xl font-bold">
|
||||
{t('auth.register.registration-disabled.title')}
|
||||
</h1>
|
||||
<p class="text-muted-foreground mt-1 mb-4">
|
||||
{t('auth.register.registration-disabled.description')}
|
||||
</p>
|
||||
|
||||
<p class="text-muted-foreground mt-4">
|
||||
{t('auth.register.have-account')}
|
||||
{' '}
|
||||
<Button variant="link" as={A} class="inline px-0" href="/login">
|
||||
{t('auth.register.login')}
|
||||
</Button>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const [getShowEmailRegister, setShowEmailRegister] = createSignal(false);
|
||||
|
||||
const registerWithProvider = async (provider: typeof ssoProviders[number]) => {
|
||||
await signIn.social({ provider: provider.key });
|
||||
};
|
||||
|
||||
const getHasSsoProviders = () => getEnabledSsoProviderConfigs({ config }).length > 0;
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<div class="flex items-center justify-center h-full p-6 sm:pb-32">
|
||||
<div class="max-w-sm w-full">
|
||||
<h1 class="text-xl font-bold">
|
||||
{t('auth.register.title')}
|
||||
</h1>
|
||||
<p class="text-muted-foreground mt-1 mb-4">
|
||||
{t('auth.register.description')}
|
||||
</p>
|
||||
|
||||
{getShowEmailRegister() || !getHasSsoProviders()
|
||||
? <EmailRegisterForm />
|
||||
: (
|
||||
<Button onClick={() => setShowEmailRegister(true)} class="w-full">
|
||||
<div class="i-tabler-mail mr-2 size-4.5" />
|
||||
{t('auth.register.register-with-email')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Show when={getHasSsoProviders()}>
|
||||
<Separator class="my-4" />
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<For each={getEnabledSsoProviderConfigs({ config })}>
|
||||
{provider => (
|
||||
<SsoProviderButton
|
||||
name={provider.name}
|
||||
icon={provider.icon}
|
||||
onClick={() => registerWithProvider(provider)}
|
||||
label={t('auth.register.register-with-provider', { provider: t(`auth.register.providers.${provider.key}`) })}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<p class="text-muted-foreground mt-4">
|
||||
{t('auth.register.have-account')}
|
||||
{' '}
|
||||
<Button variant="link" as={A} class="inline px-0" href="/login">
|
||||
{t('auth.register.login')}
|
||||
</Button>
|
||||
</p>
|
||||
|
||||
<AuthLegalLinks />
|
||||
</div>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useConfig } from '@/modules/config/config.provider';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { createForm } from '@/modules/shared/form/form';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { buildUrl } from '@corentinth/chisels';
|
||||
import { A, useNavigate } from '@solidjs/router';
|
||||
import { type Component, createSignal, onMount } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
|
||||
import { forgetPassword } from '../auth.services';
|
||||
import { OpenEmailProvider } from '../components/open-email-provider.component';
|
||||
|
||||
export const ResetPasswordForm: Component<{ onSubmit: (args: { email: string }) => Promise<void> }> = (props) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const { form, Form, Field } = createForm({
|
||||
onSubmit: props.onSubmit,
|
||||
schema: v.object({
|
||||
email: v.pipe(
|
||||
v.string(),
|
||||
v.trim(),
|
||||
v.nonEmpty(t('auth.request-password-reset.form.email.required')),
|
||||
v.email(t('auth.request-password-reset.form.email.invalid')),
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<Field name="email">
|
||||
{(field, inputProps) => (
|
||||
<TextFieldRoot class="flex flex-col gap-1 mb-4">
|
||||
<TextFieldLabel for="email">{t('auth.request-password-reset.form.email.label')}</TextFieldLabel>
|
||||
<TextField type="email" id="email" placeholder={t('auth.request-password-reset.form.email.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Button type="submit" class="w-full">
|
||||
{t('auth.request-password-reset.form.submit')}
|
||||
</Button>
|
||||
|
||||
<div class="text-red-500 text-sm mt-2">{form.response.message}</div>
|
||||
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export const RequestPasswordResetPage: Component = () => {
|
||||
const [getHasPasswordResetBeenRequested, setHasPasswordResetBeenRequested] = createSignal(false);
|
||||
const [getEmail, setEmail] = createSignal<string | undefined>(undefined);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { config } = useConfig();
|
||||
const navigate = useNavigate();
|
||||
|
||||
onMount(() => {
|
||||
if (config.auth.isPasswordResetEnabled) {
|
||||
navigate('/login');
|
||||
}
|
||||
});
|
||||
|
||||
const onPasswordResetRequested = async ({ email }: { email: string }) => {
|
||||
const { error } = await forgetPassword({
|
||||
email,
|
||||
redirectTo: buildUrl({
|
||||
path: '/reset-password',
|
||||
baseUrl: config.baseUrl,
|
||||
}),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
setEmail(email);
|
||||
setHasPasswordResetBeenRequested(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<div class="flex items-center justify-center p-6 sm:pb-32">
|
||||
<div class="max-w-sm w-full">
|
||||
<h1 class="text-xl font-bold">
|
||||
{t('auth.request-password-reset.title')}
|
||||
</h1>
|
||||
|
||||
{getHasPasswordResetBeenRequested()
|
||||
? (
|
||||
<>
|
||||
<div class="text-muted-foreground mt-1 mb-4">
|
||||
{t('auth.request-password-reset.requested')}
|
||||
</div>
|
||||
|
||||
<OpenEmailProvider email={getEmail()} variant="secondary" class="w-full mb-4" />
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<p class="text-muted-foreground mt-1 mb-4">
|
||||
{t('auth.request-password-reset.description')}
|
||||
</p>
|
||||
|
||||
<ResetPasswordForm onSubmit={onPasswordResetRequested} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button as={A} href="/login" class="w-full" variant={getHasPasswordResetBeenRequested() ? 'default' : 'ghost'}>
|
||||
<div class="i-tabler-arrow-left mr-2 size-4" />
|
||||
{t('auth.request-password-reset.back-to-login')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
};
|
||||
118
apps/papra-client/src/modules/auth/pages/reset-password.page.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useConfig } from '@/modules/config/config.provider';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { createForm } from '@/modules/shared/form/form';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { A, Navigate, useNavigate, useSearchParams } from '@solidjs/router';
|
||||
import { type Component, createSignal } from 'solid-js';
|
||||
import { onMount } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
|
||||
import { resetPassword } from '../auth.services';
|
||||
|
||||
export const ResetPasswordForm: Component<{ onSubmit: (args: { newPassword: string }) => Promise<void> }> = (props) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const { form, Form, Field } = createForm({
|
||||
onSubmit: props.onSubmit,
|
||||
schema: v.object({
|
||||
newPassword: v.pipe(
|
||||
v.string(),
|
||||
v.nonEmpty(t('auth.reset-password.form.new-password.required')),
|
||||
v.minLength(8, t('auth.reset-password.form.new-password.min-length', { minLength: 8 })),
|
||||
v.maxLength(128, t('auth.reset-password.form.new-password.max-length', { maxLength: 128 })),
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<Field name="newPassword">
|
||||
{(field, inputProps) => (
|
||||
<TextFieldRoot class="flex flex-col gap-1 mb-4">
|
||||
<TextFieldLabel for="newPassword">{t('auth.reset-password.form.new-password.label')}</TextFieldLabel>
|
||||
<TextField type="password" id="newPassword" placeholder={t('auth.reset-password.form.new-password.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Button type="submit" class="w-full">
|
||||
{t('auth.reset-password.form.submit')}
|
||||
</Button>
|
||||
|
||||
<div class="text-red-500 text-sm mt-2">{form.response.message}</div>
|
||||
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export const ResetPasswordPage: Component = () => {
|
||||
const [getHasPasswordBeenReset, setHasPasswordBeenReset] = createSignal(false);
|
||||
const [searchParams] = useSearchParams();
|
||||
const token = searchParams.token;
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
if (!token || typeof token !== 'string') {
|
||||
return <Navigate href="/login" />;
|
||||
}
|
||||
|
||||
const { config } = useConfig();
|
||||
const navigate = useNavigate();
|
||||
|
||||
onMount(() => {
|
||||
if (config.auth.isPasswordResetEnabled) {
|
||||
navigate('/login');
|
||||
}
|
||||
});
|
||||
|
||||
const onPasswordResetRequested = async ({ newPassword }: { newPassword: string }) => {
|
||||
const { error } = await resetPassword({
|
||||
newPassword,
|
||||
token,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
setHasPasswordBeenReset(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<div class="flex items-center justify-center p-6 sm:pb-32">
|
||||
<div class="max-w-sm w-full">
|
||||
<h1 class="text-xl font-bold">
|
||||
{t('auth.reset-password.title')}
|
||||
</h1>
|
||||
|
||||
{getHasPasswordBeenReset()
|
||||
? (
|
||||
<>
|
||||
<div class="text-muted-foreground mt-1 mb-4">
|
||||
{t('auth.reset-password.reset')}
|
||||
</div>
|
||||
|
||||
<Button as={A} href="/login" class="w-full">
|
||||
{t('auth.reset-password.back-to-login')}
|
||||
<div class="i-tabler-login-2 ml-2 size-4" />
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<p class="text-muted-foreground mt-1 mb-4">
|
||||
{t('auth.reset-password.description')}
|
||||
</p>
|
||||
|
||||
<ResetPasswordForm onSubmit={onPasswordResetRequested} />
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
};
|
||||
76
apps/papra-client/src/modules/config/config.provider.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { ParentComponent } from 'solid-js';
|
||||
import { createQuery } from '@tanstack/solid-query';
|
||||
import { merge } from 'lodash-es';
|
||||
import { createContext, Match, Switch, useContext } from 'solid-js';
|
||||
import { Button } from '../ui/components/button';
|
||||
import { EmptyState } from '../ui/components/empty';
|
||||
import { createToast } from '../ui/components/sonner';
|
||||
import { buildTimeConfig, type Config, type RuntimePublicConfig } from './config';
|
||||
import { fetchPublicConfig } from './config.services';
|
||||
|
||||
const ConfigContext = createContext<{
|
||||
config: Config;
|
||||
}>();
|
||||
|
||||
export function useConfig() {
|
||||
const context = useContext(ConfigContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Config context not found, make sure you are using useConfig inside ConfigProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export const ConfigProvider: ParentComponent = (props) => {
|
||||
const query = createQuery(() => ({
|
||||
queryKey: ['config'],
|
||||
queryFn: fetchPublicConfig,
|
||||
}));
|
||||
|
||||
const mergeConfigs = (runtimeConfig: RuntimePublicConfig): Config => {
|
||||
return merge({}, buildTimeConfig, runtimeConfig);
|
||||
};
|
||||
|
||||
const retry = async () => {
|
||||
const result = await query.refetch();
|
||||
|
||||
if (result.error) {
|
||||
createToast({
|
||||
message: 'Server still unreachable',
|
||||
description: 'The server remains unreachable, try again later.',
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={query.error}>
|
||||
<EmptyState
|
||||
title="Server unreachable"
|
||||
description="The server seems to be unreachable, if you are self-hosting, make sure the server is running and properly configured. You may want to check the console for more information."
|
||||
icon="i-tabler-server-spark"
|
||||
class="p-6 pt-12 sm:pt-32"
|
||||
cta={(
|
||||
<Button
|
||||
onClick={retry}
|
||||
variant="outline"
|
||||
>
|
||||
<span class="i-tabler-refresh size-4 mr-2 text-primary" />
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</Match>
|
||||
|
||||
<Match when={query.data?.config}>
|
||||
{getConfig => (
|
||||
<ConfigContext.Provider value={{ config: mergeConfigs(getConfig()) }}>
|
||||
{props.children}
|
||||
</ConfigContext.Provider>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
};
|
||||
11
apps/papra-client/src/modules/config/config.services.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { RuntimePublicConfig } from './config';
|
||||
import { apiClient } from '../shared/http/api-client';
|
||||
|
||||
export async function fetchPublicConfig() {
|
||||
const { config } = await apiClient<{ config: RuntimePublicConfig }>({
|
||||
path: '/api/config',
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
return { config };
|
||||
}
|
||||
@@ -1,9 +1,38 @@
|
||||
export const config = {
|
||||
papraVersion: import.meta.env.VITE_PAPRA_VERSION,
|
||||
baseApiUrl: (import.meta.env.VITE_BASE_API_URL ?? window.location.origin) as string,
|
||||
vitrineBaseUrl: (import.meta.env.VITE_VITRINE_BASE_URL ?? 'http://localhost:3000/') as string,
|
||||
isRegistrationEnabled: import.meta.env.VITE_IS_REGISTRATION_ENABLED !== 'false',
|
||||
isDemoMode: import.meta.env.VITE_IS_DEMO_MODE === 'true',
|
||||
export const isDev = import.meta.env.MODE === 'development';
|
||||
|
||||
const asBoolean = (value: string | undefined, defaultValue: boolean) => value === undefined ? defaultValue : value.trim().toLowerCase() === 'true';
|
||||
const asString = <T extends string | undefined>(value: string | undefined, defaultValue?: T): T extends undefined ? string | undefined : string => (value ?? defaultValue) as T extends undefined ? string | undefined : string;
|
||||
const asNumber = <T extends number | undefined>(value: string | undefined, defaultValue?: T): T extends undefined ? number | undefined : number => (value === undefined ? defaultValue : Number(value)) as T extends undefined ? number | undefined : number;
|
||||
|
||||
export const buildTimeConfig = {
|
||||
papraVersion: asString(import.meta.env.VITE_PAPRA_VERSION),
|
||||
baseUrl: asString(import.meta.env.VITE_BASE_URL, window.location.origin),
|
||||
baseApiUrl: asString(import.meta.env.VITE_BASE_API_URL, window.location.origin),
|
||||
vitrineBaseUrl: asString(import.meta.env.VITE_VITRINE_BASE_URL, 'http://localhost:3000/'),
|
||||
isDemoMode: asBoolean(import.meta.env.VITE_IS_DEMO_MODE, false),
|
||||
auth: {
|
||||
isRegistrationEnabled: asBoolean(import.meta.env.VITE_AUTH_IS_REGISTRATION_ENABLED, true),
|
||||
isPasswordResetEnabled: asBoolean(import.meta.env.VITE_AUTH_IS_PASSWORD_RESET_ENABLED, true),
|
||||
isEmailVerificationRequired: asBoolean(import.meta.env.VITE_AUTH_IS_EMAIL_VERIFICATION_REQUIRED, true),
|
||||
showLegalLinksOnAuthPage: asBoolean(import.meta.env.VITE_AUTH_SHOW_LEGAL_LINKS_ON_AUTH_PAGE, false),
|
||||
providers: {
|
||||
github: { isEnabled: asBoolean(import.meta.env.VITE_AUTH_PROVIDERS_GITHUB_IS_ENABLED, false) },
|
||||
google: { isEnabled: asBoolean(import.meta.env.VITE_AUTH_PROVIDERS_GOOGLE_IS_ENABLED, false) },
|
||||
},
|
||||
},
|
||||
documents: {
|
||||
deletedDocumentsRetentionDays: asNumber(import.meta.env.VITE_DOCUMENTS_DELETED_DOCUMENTS_RETENTION_DAYS, 30),
|
||||
},
|
||||
posthog: {
|
||||
apiKey: asString(import.meta.env.VITE_POSTHOG_API_KEY),
|
||||
host: asString(import.meta.env.VITE_POSTHOG_HOST),
|
||||
isEnabled: asBoolean(import.meta.env.VITE_POSTHOG_ENABLED, false),
|
||||
},
|
||||
intakeEmails: {
|
||||
isEnabled: asBoolean(import.meta.env.VITE_INTAKE_EMAILS_IS_ENABLED, false),
|
||||
emailGenerationDomain: asString(import.meta.env.VITE_INTAKE_EMAILS_EMAIL_GENERATION_DOMAIN),
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type Config = typeof config;
|
||||
export type Config = typeof buildTimeConfig;
|
||||
export type RuntimePublicConfig = Pick<Config, 'auth'>;
|
||||
|
||||
@@ -2,7 +2,8 @@ import { get } from 'lodash-es';
|
||||
import { FetchError } from 'ofetch';
|
||||
import { createRouter } from 'radix3';
|
||||
import { defineHandler } from './demo-api-mock.models';
|
||||
import { documentFileStorage, documentStorage, organizationStorage } from './demo.storage';
|
||||
import { documentFileStorage, documentStorage, organizationStorage, tagDocumentStorage, taggingRuleStorage, tagStorage } from './demo.storage';
|
||||
import { findMany, getValues } from './demo.storage.models';
|
||||
|
||||
function assert(condition: unknown, { message = 'Error', status }: { message?: string; status?: number } = {}): asserts condition {
|
||||
if (!condition) {
|
||||
@@ -38,6 +39,22 @@ async function deserializeFile({ name, type, content }: Awaited<ReturnType<typeo
|
||||
}
|
||||
|
||||
const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
...defineHandler({
|
||||
path: '/api/config',
|
||||
method: 'GET',
|
||||
handler: () => ({
|
||||
config: {
|
||||
auth: {
|
||||
isEmailVerificationRequired: false,
|
||||
isPasswordResetEnabled: false,
|
||||
providers: {
|
||||
github: { isEnabled: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/users/me',
|
||||
method: 'GET',
|
||||
@@ -51,34 +68,6 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
}),
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations',
|
||||
method: 'GET',
|
||||
handler: async () => {
|
||||
const keys = await organizationStorage.getKeys();
|
||||
const organizations = await Promise.all(keys.map(key => organizationStorage.getItem(key)));
|
||||
|
||||
return { organizations };
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations',
|
||||
method: 'POST',
|
||||
handler: async ({ body }) => {
|
||||
const organization = {
|
||||
id: `org_${Math.random().toString(36).slice(2)}`,
|
||||
name: get(body, 'name'),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await organizationStorage.setItem(organization.id, organization);
|
||||
|
||||
return { organization };
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/documents',
|
||||
method: 'GET',
|
||||
@@ -86,12 +75,21 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
const organization = organizationStorage.getItem(organizationId);
|
||||
assert(organization, { status: 403 });
|
||||
|
||||
const allKeys = await documentStorage.getKeys();
|
||||
const keys = allKeys.filter(key => key.startsWith(organizationId));
|
||||
const documents = await findMany(documentStorage, document => document.organizationId === organizationId && !document.deletedAt);
|
||||
|
||||
const documents = await Promise.all(keys.map(key => documentStorage.getItem(key)));
|
||||
const filteredDocuments = await Promise.all(
|
||||
documents.map(async (document) => {
|
||||
const tagDocuments = await findMany(tagDocumentStorage, tagDocument => tagDocument?.documentId === document?.id);
|
||||
const allTags = await getValues(tagStorage);
|
||||
|
||||
const filteredDocuments = documents.filter(document => !document?.deletedAt);
|
||||
const tags = allTags.filter(tag => tagDocuments.some(tagDocument => tagDocument?.tagId === tag?.id));
|
||||
|
||||
return {
|
||||
...document,
|
||||
tags,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const {
|
||||
pageIndex = 0,
|
||||
@@ -124,6 +122,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
mimeType: file.type,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
tags: [],
|
||||
};
|
||||
|
||||
const key = `${organizationId}:${document.id}`;
|
||||
@@ -131,10 +130,40 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
await documentFileStorage.setItem(key, await serializeFile(file));
|
||||
await documentStorage.setItem(key, document);
|
||||
|
||||
// Simulate a slow response
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
return { document };
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/customer-portal',
|
||||
method: 'GET',
|
||||
handler: async () => {
|
||||
throw Object.assign(new FetchError('Not available in demo'), { status: 501 });
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/documents/statistics',
|
||||
method: 'GET',
|
||||
handler: async ({ params: { organizationId } }) => {
|
||||
const organization = await organizationStorage.getItem(organizationId);
|
||||
|
||||
assert(organization, { status: 403 });
|
||||
|
||||
const documents = await findMany(documentStorage, document => document.organizationId === organizationId);
|
||||
|
||||
return {
|
||||
organizationStats: {
|
||||
documentsCount: documents.length,
|
||||
documentsSize: documents.reduce((acc, document) => acc + document.originalSize, 0),
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/documents/search',
|
||||
method: 'GET',
|
||||
@@ -148,10 +177,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
const organization = organizationStorage.getItem(organizationId);
|
||||
assert(organization, { status: 403 });
|
||||
|
||||
const allKeys = await documentStorage.getKeys();
|
||||
const keys = allKeys.filter(key => key.startsWith(organizationId));
|
||||
|
||||
const documents = await Promise.all(keys.map(key => documentStorage.getItem(key)));
|
||||
const documents = await findMany(documentStorage, document => document?.organizationId === organizationId);
|
||||
|
||||
const filteredDocuments = documents.filter(document => document?.name.includes(searchQuery) && !document?.deletedAt);
|
||||
|
||||
@@ -169,12 +195,10 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
const organization = organizationStorage.getItem(organizationId);
|
||||
assert(organization, { status: 403 });
|
||||
|
||||
const allKeys = await documentStorage.getKeys();
|
||||
const keys = allKeys.filter(key => key.startsWith(organizationId));
|
||||
|
||||
const documents = await Promise.all(keys.map(key => documentStorage.getItem(key)));
|
||||
|
||||
const deletedDocuments = documents.filter(document => document?.deletedAt);
|
||||
const deletedDocuments = await findMany(
|
||||
documentStorage,
|
||||
document => document.organizationId === organizationId && document.deletedAt !== undefined,
|
||||
);
|
||||
|
||||
return {
|
||||
documents: deletedDocuments,
|
||||
@@ -192,7 +216,15 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
|
||||
assert(document, { status: 404 });
|
||||
|
||||
return { document };
|
||||
const tagDocuments = await findMany(tagDocumentStorage, tagDocument => tagDocument.documentId === documentId);
|
||||
const tags = await findMany(tagStorage, tag => tagDocuments.some(tagDocument => tagDocument.tagId === tag.id));
|
||||
|
||||
return {
|
||||
document: {
|
||||
...document,
|
||||
tags,
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -247,6 +279,150 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/tags',
|
||||
method: 'GET',
|
||||
handler: async ({ params: { organizationId } }) => {
|
||||
const organization = await organizationStorage.getItem(organizationId);
|
||||
|
||||
assert(organization, { status: 403 });
|
||||
|
||||
const tags = await findMany(tagStorage, tag => tag.organizationId === organizationId);
|
||||
const documents = await findMany(documentStorage, document => document.organizationId === organizationId);
|
||||
|
||||
const tagsWithDocumentsCount = tags.map(tag => ({
|
||||
...tag,
|
||||
documentsCount: documents.filter(document => document.tags.some(t => t.id === tag.id)).length,
|
||||
}));
|
||||
|
||||
return {
|
||||
tags: tagsWithDocumentsCount,
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/tags',
|
||||
method: 'POST',
|
||||
handler: async ({ params: { organizationId }, body }) => {
|
||||
const organization = await organizationStorage.getItem(organizationId);
|
||||
|
||||
assert(organization, { status: 403 });
|
||||
|
||||
const tag = {
|
||||
id: `tag_${Math.random().toString(36).slice(2)}`,
|
||||
organizationId,
|
||||
name: get(body, 'name'),
|
||||
color: get(body, 'color'),
|
||||
description: get(body, 'description'),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await tagStorage.setItem(tag.id, tag);
|
||||
|
||||
return { tag };
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/tags/:tagId',
|
||||
method: 'PUT',
|
||||
handler: async ({ params: { organizationId, tagId }, body }) => {
|
||||
const organization = await organizationStorage.getItem(organizationId);
|
||||
|
||||
assert(organization, { status: 403 });
|
||||
|
||||
const tag = await tagStorage.getItem(tagId);
|
||||
|
||||
assert(tag, { status: 404 });
|
||||
|
||||
await tagStorage.setItem(tagId, Object.assign(tag, body, { updatedAt: new Date() }));
|
||||
|
||||
return { tag };
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/tags/:tagId',
|
||||
method: 'DELETE',
|
||||
handler: async ({ params: { organizationId, tagId } }) => {
|
||||
const organization = await organizationStorage.getItem(organizationId);
|
||||
|
||||
assert(organization, { status: 403 });
|
||||
|
||||
await tagStorage.removeItem(tagId);
|
||||
|
||||
const tagDocuments = await findMany(tagDocumentStorage, tagDocument => tagDocument.tagId === tagId);
|
||||
|
||||
await Promise.all(tagDocuments.map(tagDocument => tagDocumentStorage.removeItem(tagDocument.id)));
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/documents/:documentId/tags',
|
||||
method: 'POST',
|
||||
handler: async ({ params: { organizationId, documentId }, body }) => {
|
||||
const organization = await organizationStorage.getItem(organizationId);
|
||||
|
||||
assert(organization, { status: 403 });
|
||||
|
||||
const tagId = get(body, 'tagId');
|
||||
|
||||
assert(tagId, { status: 400 });
|
||||
|
||||
const tagDocument = {
|
||||
id: `tagDoc_${Math.random().toString(36).slice(2)}`,
|
||||
tagId,
|
||||
documentId,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
await tagDocumentStorage.setItem(tagDocument.id, tagDocument);
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/documents/:documentId/tags/:tagId',
|
||||
method: 'DELETE',
|
||||
handler: async ({ params: { organizationId, documentId, tagId } }) => {
|
||||
const organization = await organizationStorage.getItem(organizationId);
|
||||
|
||||
assert(organization, { status: 403 });
|
||||
|
||||
const tagDocuments = await findMany(tagDocumentStorage, tagDocument => tagDocument.tagId === tagId && tagDocument.documentId === documentId);
|
||||
|
||||
await Promise.all(tagDocuments.map(tagDocument => tagDocumentStorage.removeItem(tagDocument.id)));
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations',
|
||||
method: 'GET',
|
||||
handler: async () => {
|
||||
const organizations = await getValues(organizationStorage);
|
||||
|
||||
return { organizations };
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations',
|
||||
method: 'POST',
|
||||
handler: async ({ body }) => {
|
||||
const organization = {
|
||||
id: `org_${Math.random().toString(36).slice(2)}`,
|
||||
name: get(body, 'name'),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await organizationStorage.setItem(organization.id, organization);
|
||||
|
||||
return { organization };
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId',
|
||||
method: 'GET',
|
||||
@@ -255,9 +431,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
|
||||
assert(organization, { status: 403 });
|
||||
|
||||
return {
|
||||
organization,
|
||||
};
|
||||
return { organization };
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -285,6 +459,91 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
return { organization };
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/tagging-rules',
|
||||
method: 'GET',
|
||||
handler: async ({ params: { organizationId } }) => {
|
||||
const taggingRules = await findMany(taggingRuleStorage, taggingRule => taggingRule.organizationId === organizationId);
|
||||
|
||||
return { taggingRules };
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/tagging-rules',
|
||||
method: 'POST',
|
||||
handler: async ({ params: { organizationId }, body }) => {
|
||||
const taggingRule = {
|
||||
id: `tr_${Math.random().toString(36).slice(2)}`,
|
||||
organizationId,
|
||||
name: get(body, 'name'),
|
||||
description: get(body, 'description'),
|
||||
conditions: get(body, 'conditions'),
|
||||
actions: get(body, 'tagIds').map((tagId: string) => ({ tagId })),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await taggingRuleStorage.setItem(taggingRule.id, taggingRule);
|
||||
|
||||
return { taggingRule };
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/tagging-rules/:taggingRuleId',
|
||||
method: 'GET',
|
||||
handler: async ({ params: { taggingRuleId } }) => {
|
||||
const taggingRule = await taggingRuleStorage.getItem(taggingRuleId);
|
||||
|
||||
assert(taggingRule, { status: 404 });
|
||||
|
||||
return { taggingRule };
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/tagging-rules/:taggingRuleId',
|
||||
method: 'DELETE',
|
||||
handler: async ({ params: { taggingRuleId } }) => {
|
||||
await taggingRuleStorage.removeItem(taggingRuleId);
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/tagging-rules/:taggingRuleId',
|
||||
method: 'PUT',
|
||||
handler: async ({ params: { taggingRuleId }, body }) => {
|
||||
const taggingRule = await taggingRuleStorage.getItem(taggingRuleId);
|
||||
|
||||
assert(taggingRule, { status: 404 });
|
||||
|
||||
await taggingRuleStorage.setItem(taggingRuleId, Object.assign(taggingRule, body, { updatedAt: new Date() }));
|
||||
|
||||
return { taggingRule };
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/documents/trash',
|
||||
method: 'DELETE',
|
||||
handler: async ({ params: { organizationId } }) => {
|
||||
const documents = await findMany(documentStorage, document => document.organizationId === organizationId && Boolean(document.deletedAt));
|
||||
|
||||
await Promise.all(documents.map(document => documentStorage.removeItem(`${organizationId}:${document.id}`)));
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/documents/trash/:documentId',
|
||||
method: 'DELETE',
|
||||
handler: async ({ params: { organizationId, documentId } }) => {
|
||||
const key = `${organizationId}:${documentId}`;
|
||||
|
||||
await documentStorage.removeItem(key);
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
export const router = createRouter({ routes: inMemoryApiMock, strictTrailingSlash: false });
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useNavigate } from '@solidjs/router';
|
||||
import { A, useNavigate } from '@solidjs/router';
|
||||
import { type Component, createSignal } from 'solid-js';
|
||||
import { Portal } from 'solid-js/web';
|
||||
import { config } from '../config/config';
|
||||
import { buildTimeConfig } from '../config/config';
|
||||
import { useI18n } from '../i18n/i18n.provider';
|
||||
import { Button } from '../ui/components/button';
|
||||
import { clearDemoStorage } from './demo.storage';
|
||||
|
||||
export const DemoIndicator: Component = () => {
|
||||
const [getIsMinified, setIsMinified] = createSignal(false);
|
||||
const navigate = useNavigate();
|
||||
const { t, te } = useI18n();
|
||||
|
||||
const clearDemo = async () => {
|
||||
await clearDemoStorage();
|
||||
@@ -16,7 +18,7 @@ export const DemoIndicator: Component = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{config.isDemoMode && (
|
||||
{buildTimeConfig.isDemoMode && (
|
||||
<Portal>
|
||||
{getIsMinified()
|
||||
? (
|
||||
@@ -29,15 +31,18 @@ export const DemoIndicator: Component = () => {
|
||||
: (
|
||||
<div class="fixed bottom-4 right-4 z-50 bg-primary text-primary-foreground p-5 py-4 rounded-xl shadow-md max-w-300px">
|
||||
<p class="text-sm">
|
||||
This is a demo environment, all data is save to your browser local storage.
|
||||
{t('demo.popup.description')}
|
||||
</p>
|
||||
<p class="text-sm mt-2">
|
||||
{te('demo.popup.discord', { discordLink: <A href="https://papra.app/discord" target="_blank" rel="noopener noreferrer" class="underline font-bold">{t('demo.popup.discord-link-label')}</A> })}
|
||||
</p>
|
||||
<div class="flex justify-end mt-4 gap-2">
|
||||
<Button variant="secondary" onClick={clearDemo} size="sm" class="text-primary shadow-none">
|
||||
Reset demo data
|
||||
{t('demo.popup.reset')}
|
||||
</Button>
|
||||
|
||||
<Button onClick={() => setIsMinified(true)} class="bg-transparent hover:text-primary" variant="outline" size="sm">
|
||||
Hide
|
||||
{t('demo.popup.hide')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
23
apps/papra-client/src/modules/demo/demo.storage.models.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Storage, StorageValue } from 'unstorage';
|
||||
|
||||
export async function getValues<T extends StorageValue>(storage: Storage<T>): Promise<T[]> {
|
||||
const keys = await storage.getKeys();
|
||||
|
||||
const values = await Promise.all(keys.map(key => storage.getItem(key))) as T[];
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
export async function findOne<T extends StorageValue>(storage: Storage<T>, predicate: (value: T) => boolean): Promise< T | null> {
|
||||
const values = await getValues(storage);
|
||||
const found = values.find(predicate);
|
||||
|
||||
return found ?? null;
|
||||
}
|
||||
|
||||
export async function findMany<T extends StorageValue>(storage: Storage<T>, predicate: (value: T) => boolean): Promise<T[]> {
|
||||
const values = await getValues(storage);
|
||||
const found = values.filter(predicate);
|
||||
|
||||
return found;
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { Document } from '../documents/documents.types';
|
||||
import type { Organization } from '../organizations/organizations.types';
|
||||
import type { TaggingRule } from '../tagging-rules/tagging-rules.types';
|
||||
import type { Tag } from '../tags/tags.types';
|
||||
import { createStorage, prefixStorage } from 'unstorage';
|
||||
import localStorageDriver from 'unstorage/drivers/localstorage';
|
||||
import { trackingServices } from '../tracking/tracking.services';
|
||||
|
||||
const storage = createStorage<any>({
|
||||
driver: localStorageDriver({ base: 'demo:' }),
|
||||
@@ -10,7 +13,11 @@ const storage = createStorage<any>({
|
||||
export const organizationStorage = prefixStorage<Organization>(storage, 'organizations');
|
||||
export const documentStorage = prefixStorage<Document>(storage, 'documents');
|
||||
export const documentFileStorage = prefixStorage(storage, 'documentFiles');
|
||||
export const tagStorage = prefixStorage<Omit<Tag, 'documentsCount'>>(storage, 'tags');
|
||||
export const tagDocumentStorage = prefixStorage<{ documentId: string; tagId: string; id: string }>(storage, 'tagDocuments');
|
||||
export const taggingRuleStorage = prefixStorage<TaggingRule>(storage, 'taggingRules');
|
||||
|
||||
export async function clearDemoStorage() {
|
||||
await storage.clear();
|
||||
trackingServices.capture({ event: 'Demo storage cleared' });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { ParentComponent } from 'solid-js';
|
||||
import type { Document } from '../documents.types';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { promptUploadFiles } from '@/modules/shared/files/upload';
|
||||
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { A } from '@solidjs/router';
|
||||
import { throttle } from 'lodash-es';
|
||||
import { createContext, createSignal, For, Match, Show, Switch, useContext } from 'solid-js';
|
||||
import { Portal } from 'solid-js/web';
|
||||
import { invalidateOrganizationDocumentsQuery } from '../documents.composables';
|
||||
import { uploadDocument } from '../documents.services';
|
||||
|
||||
const DocumentUploadContext = createContext<{
|
||||
uploadDocuments: (args: { files: File[]; organizationId: string }) => Promise<void>;
|
||||
}>();
|
||||
|
||||
export function useDocumentUpload({ organizationId }: { organizationId: string }) {
|
||||
const context = useContext(DocumentUploadContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('DocumentUploadContext not found');
|
||||
}
|
||||
|
||||
const { uploadDocuments } = context;
|
||||
|
||||
return {
|
||||
uploadDocuments: async ({ files }: { files: File[] }) => uploadDocuments({ files, organizationId }),
|
||||
promptImport: async () => {
|
||||
const { files } = await promptUploadFiles();
|
||||
|
||||
await uploadDocuments({ files, organizationId });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type TaskSuccess = {
|
||||
file: File;
|
||||
status: 'success';
|
||||
document: Document;
|
||||
};
|
||||
|
||||
type TaskError = {
|
||||
file: File;
|
||||
status: 'error';
|
||||
error: Error;
|
||||
};
|
||||
|
||||
type Task = TaskSuccess | TaskError | {
|
||||
file: File;
|
||||
status: 'pending' | 'uploading' ;
|
||||
};
|
||||
|
||||
export const DocumentUploadProvider: ParentComponent = (props) => {
|
||||
const throttledInvalidateOrganizationDocumentsQuery = throttle(invalidateOrganizationDocumentsQuery, 500);
|
||||
const { getErrorMessage } = useI18nApiErrors();
|
||||
const { t } = useI18n();
|
||||
|
||||
const [getState, setState] = createSignal<'open' | 'closed' | 'collapsed'>('closed');
|
||||
const [getTasks, setTasks] = createSignal<Task[]>([]);
|
||||
|
||||
const updateTaskStatus = (args: { file: File; status: 'success'; document: Document } | { file: File; status: 'error'; error: Error } | { file: File; status: 'pending' | 'uploading' }) => {
|
||||
setTasks(tasks => tasks.map(task => task.file === args.file ? { ...task, ...args } : task));
|
||||
};
|
||||
|
||||
const uploadDocuments = async ({ files, organizationId }: { files: File[]; organizationId: string }) => {
|
||||
setTasks(tasks => [...tasks, ...files.map(file => ({ file, status: 'pending' } as const))]);
|
||||
setState('open');
|
||||
|
||||
await Promise.all(files.map(async (file) => {
|
||||
updateTaskStatus({ file, status: 'uploading' });
|
||||
|
||||
const [result, error] = await safely(uploadDocument({ file, organizationId }));
|
||||
|
||||
if (error) {
|
||||
updateTaskStatus({ file, status: 'error', error });
|
||||
} else {
|
||||
const { document } = result;
|
||||
|
||||
updateTaskStatus({ file, status: 'success', document });
|
||||
}
|
||||
|
||||
await throttledInvalidateOrganizationDocumentsQuery({ organizationId });
|
||||
}));
|
||||
};
|
||||
|
||||
const getTitle = () => {
|
||||
if (getTasks().length === 0) {
|
||||
return t('import-documents.title.none');
|
||||
}
|
||||
|
||||
const successCount = getTasks().filter(task => task.status === 'success').length;
|
||||
const errorCount = getTasks().filter(task => task.status === 'error').length;
|
||||
const totalCount = getTasks().length;
|
||||
|
||||
if (errorCount > 0) {
|
||||
return t('import-documents.title.error', { count: errorCount });
|
||||
}
|
||||
|
||||
if (successCount === totalCount) {
|
||||
return t('import-documents.title.success', { count: successCount });
|
||||
}
|
||||
|
||||
return t('import-documents.title.pending', { count: successCount, total: totalCount });
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setState('closed');
|
||||
setTasks([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<DocumentUploadContext.Provider value={{ uploadDocuments }}>
|
||||
{props.children}
|
||||
|
||||
<Portal>
|
||||
<Show when={getState() !== 'closed'}>
|
||||
<div class="fixed bottom-0 right-0 sm:right-20px w-full sm:w-400px bg-card border-l border-t border-r sm:rounded-t-xl shadow-lg">
|
||||
<div class="flex items-center gap-1 pl-6 pr-4 py-3 border-b">
|
||||
<h2 class="text-base font-bold flex-1">{getTitle()}</h2>
|
||||
|
||||
<Button variant="ghost" size="icon" onClick={() => setState(state => state === 'open' ? 'collapsed' : 'open')}>
|
||||
<div class={cn('i-tabler-chevron-down size-5 transition-transform', getState() === 'collapsed' && 'rotate-180')} />
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="icon" onClick={close}>
|
||||
<div class="i-tabler-x size-5"></div>
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
|
||||
<Show when={getState() === 'open'}>
|
||||
<div class="flex flex-col overflow-y-auto h-[450px] pb-4">
|
||||
<For each={getTasks()}>
|
||||
{task => (
|
||||
|
||||
<Switch>
|
||||
<Match when={task.status === 'success'}>
|
||||
<A
|
||||
href={`/organizations/${(task as TaskSuccess).document.organizationId}/documents/${(task as TaskSuccess).document.id}`}
|
||||
class="text-sm truncate min-w-0 flex items-center gap-4 min-h-48px group hover:bg-muted/50 transition-colors px-6 border-b border-border/80"
|
||||
>
|
||||
<div class="flex-1 truncate">
|
||||
{task.file.name}
|
||||
</div>
|
||||
|
||||
<div class="flex-none">
|
||||
<div class="i-tabler-circle-check text-primary size-5.5 group-hover:hidden"></div>
|
||||
<div class="i-tabler-arrow-right text-muted-foreground size-5.5 hidden group-hover:block"></div>
|
||||
</div>
|
||||
</A>
|
||||
</Match>
|
||||
|
||||
<Match when={task.status === 'error'}>
|
||||
<div class="text-sm truncate min-w-0 flex items-center gap-4 min-h-48px px-6 border-b border-border/80">
|
||||
<div class="flex-1 truncate">
|
||||
<div class="flex-1 truncate">{task.file.name}</div>
|
||||
|
||||
<div class="text-xs text-muted-foreground truncate text-red-500">
|
||||
{getErrorMessage({ error: (task as TaskError).error })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-none">
|
||||
<div class="i-tabler-circle-x text-red-500 size-5.5"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
|
||||
<Match when={['pending', 'uploading'].includes(task.status)}>
|
||||
<div class="text-sm truncate min-w-0 flex items-center gap-4 min-h-48px px-6 border-b border-border/80">
|
||||
<div class="flex-1 truncate">
|
||||
{task.file.name}
|
||||
</div>
|
||||
|
||||
<div class="flex-none">
|
||||
<div class="i-tabler-loader-2 animate-spin text-muted-foreground size-5.5"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show when={getTasks().length === 0}>
|
||||
<div class="flex flex-col items-center justify-center gap-2 h-full mb-10">
|
||||
<div class="flex flex-col items-center justify-center gap-2 ">
|
||||
<div class="i-tabler-file-import size-10 text-muted-foreground"></div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-muted-foreground text-center mt-2">
|
||||
{t('import-documents.no-import-in-progress')}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
</Show>
|
||||
|
||||
</div>
|
||||
</Show>
|
||||
</Portal>
|
||||
</DocumentUploadContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,49 +1,19 @@
|
||||
import type { DropdownMenuSubTriggerProps } from '@kobalte/core/dropdown-menu';
|
||||
import type { Component } from 'solid-js';
|
||||
import type { Document } from '../documents.types';
|
||||
import { useConfirmModal } from '@/modules/shared/confirm';
|
||||
import { queryClient } from '@/modules/shared/query/query-client';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/modules/ui/components/dropdown-menu';
|
||||
import { createToast } from '@/modules/ui/components/sonner';
|
||||
import { A } from '@solidjs/router';
|
||||
import { deleteDocument } from '../documents.services';
|
||||
import { useDeleteDocument } from '../documents.composables';
|
||||
|
||||
export const DocumentManagementDropdown: Component<{ document: Document }> = (props) => {
|
||||
const { confirm } = useConfirmModal();
|
||||
const { deleteDocument } = useDeleteDocument();
|
||||
|
||||
const deleteDoc = async () => {
|
||||
const isConfirmed = await confirm({
|
||||
title: 'Delete document',
|
||||
message: (
|
||||
<>
|
||||
Are you sure you want to delete
|
||||
{' '}
|
||||
<span class="font-bold">{props.document.name}</span>
|
||||
?
|
||||
</>
|
||||
),
|
||||
confirmButton: {
|
||||
text: 'Delete document',
|
||||
variant: 'destructive',
|
||||
},
|
||||
cancelButton: {
|
||||
text: 'Cancel',
|
||||
},
|
||||
});
|
||||
|
||||
if (!isConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteDocument({
|
||||
documentId: props.document.id,
|
||||
organizationId: props.document.organizationId,
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ['organizations', props.document.organizationId, 'documents'] });
|
||||
createToast({ type: 'success', message: 'Document deleted' });
|
||||
};
|
||||
const deleteDoc = () => deleteDocument({
|
||||
documentId: props.document.id,
|
||||
organizationId: props.document.organizationId,
|
||||
documentName: props.document.name,
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { Tag } from '@/modules/tags/tags.types';
|
||||
import type { TooltipTriggerProps } from '@kobalte/core/tooltip';
|
||||
import type { ColumnDef } from '@tanstack/solid-table';
|
||||
import type { Document } from '../documents.types';
|
||||
import { timeAgo } from '@/modules/shared/date/time-ago';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { TagLink } from '@/modules/tags/components/tag.component';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/modules/ui/components/select';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/modules/ui/components/table';
|
||||
@@ -41,12 +43,27 @@ export const standardActionsColumn: ColumnDef<Document> = {
|
||||
),
|
||||
};
|
||||
|
||||
export const tagsColumn: ColumnDef<Document> = {
|
||||
header: () => (<span class="hidden sm:block">Tags</span>),
|
||||
accessorKey: 'tags',
|
||||
cell: data => (
|
||||
<div class="text-muted-foreground hidden sm:flex flex-wrap gap-1">
|
||||
<For each={data.getValue<Tag[]>()}>
|
||||
{tag => (
|
||||
<TagLink {...tag} class="text-xs" />
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const DocumentsPaginatedList: Component<{
|
||||
documents: Document[];
|
||||
documentsCount: number;
|
||||
getPagination: Accessor<Pagination>;
|
||||
setPagination: Setter<Pagination>;
|
||||
getPagination?: Accessor<Pagination>;
|
||||
setPagination?: Setter<Pagination>;
|
||||
extraColumns?: ColumnDef<Document>[];
|
||||
showPagination?: boolean;
|
||||
}> = (props) => {
|
||||
const table = createSolidTable({
|
||||
get data() {
|
||||
@@ -66,7 +83,7 @@ export const DocumentsPaginatedList: Component<{
|
||||
href={`/organizations/${data.row.original.organizationId}/documents/${data.row.original.id}`}
|
||||
class="font-bold truncate block hover:underline"
|
||||
>
|
||||
{data.row.original.name.split('.').shift()}
|
||||
{data.row.original.name.split('.').slice(0, -1).join('.')}
|
||||
</A>
|
||||
|
||||
<div class="text-xs text-muted-foreground lh-tight">
|
||||
@@ -120,7 +137,7 @@ export const DocumentsPaginatedList: Component<{
|
||||
onPaginationChange: props.setPagination,
|
||||
state: {
|
||||
get pagination() {
|
||||
return props.getPagination();
|
||||
return props.getPagination?.();
|
||||
},
|
||||
},
|
||||
manualPagination: true,
|
||||
@@ -130,9 +147,6 @@ export const DocumentsPaginatedList: Component<{
|
||||
return (
|
||||
<div>
|
||||
<Switch>
|
||||
<Match when={props.documentsCount === 0}>
|
||||
<p>No documents found</p>
|
||||
</Match>
|
||||
<Match when={props.documentsCount > 0}>
|
||||
<Table>
|
||||
|
||||
@@ -176,76 +190,78 @@ export const DocumentsPaginatedList: Component<{
|
||||
|
||||
</Table>
|
||||
|
||||
<div class="flex flex-col-reverse items-center gap-4 sm:flex-row sm:justify-end mt-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<p class="whitespace-nowrap text-sm font-medium">Rows per page</p>
|
||||
<Select
|
||||
value={table.getState().pagination.pageSize}
|
||||
onChange={value => value && table.setPageSize(value)}
|
||||
options={[15, 50, 100]}
|
||||
itemComponent={props => (
|
||||
<SelectItem item={props.item}>{props.item.rawValue}</SelectItem>
|
||||
)}
|
||||
>
|
||||
<SelectTrigger class="h-8 w-[4.5rem]">
|
||||
<SelectValue<string>>
|
||||
{state => state.selectedOption()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
<Show when={props.showPagination ?? true}>
|
||||
<div class="flex flex-col-reverse items-center gap-4 sm:flex-row sm:justify-end mt-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<p class="whitespace-nowrap text-sm font-medium">Rows per page</p>
|
||||
<Select
|
||||
value={table.getState().pagination.pageSize}
|
||||
onChange={value => value && table.setPageSize(value)}
|
||||
options={[15, 50, 100]}
|
||||
itemComponent={props => (
|
||||
<SelectItem item={props.item}>{props.item.rawValue}</SelectItem>
|
||||
)}
|
||||
>
|
||||
<SelectTrigger class="h-8 w-[4.5rem]">
|
||||
<SelectValue<string>>
|
||||
{state => state.selectedOption()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
</div>
|
||||
<div class="flex items-center justify-center whitespace-nowrap text-sm font-medium">
|
||||
Page
|
||||
{' '}
|
||||
{table.getState().pagination.pageIndex + 1}
|
||||
{' '}
|
||||
of
|
||||
{' '}
|
||||
{table.getPageCount()}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Button
|
||||
aria-label="Go to first page"
|
||||
variant="outline"
|
||||
class="flex size-8 p-0"
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<div class="size-4 i-tabler-chevrons-left" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Go to previous page"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="size-8"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<div class="size-4 i-tabler-chevron-left" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Go to next page"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="size-8"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<div class="size-4 i-tabler-chevron-right" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Go to last page"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="flex size-8"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<div class="size-4 i-tabler-chevrons-right" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-center whitespace-nowrap text-sm font-medium">
|
||||
Page
|
||||
{' '}
|
||||
{table.getState().pagination.pageIndex + 1}
|
||||
{' '}
|
||||
of
|
||||
{' '}
|
||||
{table.getPageCount()}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Button
|
||||
aria-label="Go to first page"
|
||||
variant="outline"
|
||||
class="flex size-8 p-0"
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<div class="size-4 i-tabler-chevrons-left" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Go to previous page"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="size-8"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<div class="size-4 i-tabler-chevron-left" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Go to next page"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="size-8"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<div class="size-4 i-tabler-chevron-right" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Go to last page"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="flex size-8"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<div class="size-4 i-tabler-chevrons-right" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { icons as tablerIconSet } from '@iconify-json/tabler';
|
||||
import { values } from 'lodash-es';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { getDocumentIcon, iconByFileType } from './document.models';
|
||||
import { getDaysBeforePermanentDeletion, getDocumentIcon, iconByFileType } from './document.models';
|
||||
|
||||
describe('files models', () => {
|
||||
describe('iconByFileType', () => {
|
||||
@@ -79,4 +79,26 @@ describe('files models', () => {
|
||||
expect(icon).to.eql('i-tabler-file-type-html');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDaysBeforePermanentDeletion', () => {
|
||||
test('get the amount of days before a document is permanently deleted, basically the difference between the deletion date and now', () => {
|
||||
const document = { deletedAt: new Date('2021-01-01') };
|
||||
const deletedDocumentsRetentionDays = 30;
|
||||
const now = new Date('2021-01-10');
|
||||
|
||||
const daysBeforeDeletion = getDaysBeforePermanentDeletion({ document, deletedDocumentsRetentionDays, now });
|
||||
|
||||
expect(daysBeforeDeletion).to.eql(21);
|
||||
});
|
||||
|
||||
test('if the document has not been deleted, the days before permanent deletion is undefined', () => {
|
||||
const document = { deletedAt: undefined };
|
||||
const deletedDocumentsRetentionDays = 30;
|
||||
const now = new Date('2021-01-10');
|
||||
|
||||
const daysBeforeDeletion = getDaysBeforePermanentDeletion({ document, deletedDocumentsRetentionDays, now });
|
||||
|
||||
expect(daysBeforeDeletion).to.eql(undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { addDays, differenceInDays } from 'date-fns';
|
||||
|
||||
export const iconByFileType = {
|
||||
'*': 'i-tabler-file',
|
||||
'image': 'i-tabler-photo',
|
||||
@@ -35,3 +37,15 @@ export function getDocumentIcon({ document, iconsMap = iconByFileType }: { docum
|
||||
|
||||
return icon;
|
||||
}
|
||||
|
||||
export function getDaysBeforePermanentDeletion({ document, deletedDocumentsRetentionDays, now = new Date() }: { document: { deletedAt?: Date }; deletedDocumentsRetentionDays: number; now?: Date }) {
|
||||
if (!document.deletedAt) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const deletionDate = addDays(document.deletedAt, deletedDocumentsRetentionDays);
|
||||
|
||||
const daysBeforeDeletion = differenceInDays(deletionDate, now);
|
||||
|
||||
return daysBeforeDeletion;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { Document } from './documents.types';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { throttle } from 'lodash-es';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { useConfirmModal } from '../shared/confirm';
|
||||
import { promptUploadFiles } from '../shared/files/upload';
|
||||
import { isHttpErrorWithCode } from '../shared/http/http-errors';
|
||||
import { queryClient } from '../shared/query/query-client';
|
||||
import { createToast } from '../ui/components/sonner';
|
||||
import { deleteDocument, restoreDocument, uploadDocument } from './documents.services';
|
||||
|
||||
export function invalidateOrganizationDocumentsQuery({ organizationId }: { organizationId: string }) {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: ['organizations', organizationId],
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteDocument() {
|
||||
const { confirm } = useConfirmModal();
|
||||
|
||||
return {
|
||||
async deleteDocument({ documentId, organizationId, documentName }: { documentId: string; organizationId: string; documentName: string }) {
|
||||
const isConfirmed = await confirm({
|
||||
title: 'Delete document',
|
||||
message: (
|
||||
<>
|
||||
Are you sure you want to delete
|
||||
{' '}
|
||||
<span class="font-bold">{documentName}</span>
|
||||
?
|
||||
</>
|
||||
),
|
||||
confirmButton: {
|
||||
text: 'Delete document',
|
||||
variant: 'destructive',
|
||||
},
|
||||
cancelButton: {
|
||||
text: 'Cancel',
|
||||
},
|
||||
});
|
||||
|
||||
if (!isConfirmed) {
|
||||
return { hasDeleted: false };
|
||||
}
|
||||
|
||||
await deleteDocument({
|
||||
documentId,
|
||||
organizationId,
|
||||
});
|
||||
|
||||
await invalidateOrganizationDocumentsQuery({ organizationId });
|
||||
createToast({ type: 'success', message: 'Document deleted' });
|
||||
|
||||
return { hasDeleted: true };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useRestoreDocument() {
|
||||
const [getIsRestoring, setIsRestoring] = createSignal(false);
|
||||
|
||||
return {
|
||||
getIsRestoring,
|
||||
async restore({ document }: { document: Document }) {
|
||||
setIsRestoring(true);
|
||||
|
||||
await restoreDocument({
|
||||
documentId: document.id,
|
||||
organizationId: document.organizationId,
|
||||
});
|
||||
|
||||
await invalidateOrganizationDocumentsQuery({ organizationId: document.organizationId });
|
||||
|
||||
createToast({ type: 'success', message: 'Document restored' });
|
||||
setIsRestoring(false);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toastUploadError({ error, file }: { error: Error; file: File }) {
|
||||
if (isHttpErrorWithCode({ error, code: 'document.already_exists' })) {
|
||||
createToast({
|
||||
type: 'error',
|
||||
message: 'Document already exists',
|
||||
description: `The document ${file.name} already exists, it has not been uploaded.`,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isHttpErrorWithCode({ error, code: 'document.file_too_big' })) {
|
||||
createToast({
|
||||
type: 'error',
|
||||
message: 'Document too big',
|
||||
description: `The document ${file.name} is too big, it has not been uploaded.`,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
createToast({
|
||||
type: 'error',
|
||||
message: 'Failed to upload document',
|
||||
description: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadDocuments({ organizationId }: { organizationId: string }) {
|
||||
const uploadDocuments = async ({ files }: { files: File[] }) => {
|
||||
const throttledInvalidateOrganizationDocumentsQuery = throttle(invalidateOrganizationDocumentsQuery, 500);
|
||||
|
||||
await Promise.all(files.map(async (file) => {
|
||||
const [, error] = await safely(uploadDocument({ file, organizationId }));
|
||||
|
||||
if (error) {
|
||||
toastUploadError({ error, file });
|
||||
}
|
||||
|
||||
await throttledInvalidateOrganizationDocumentsQuery({ organizationId });
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
uploadDocuments,
|
||||
promptImport: async () => {
|
||||
const { files } = await promptUploadFiles();
|
||||
|
||||
await uploadDocuments({ files });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -28,10 +28,14 @@ export async function fetchOrganizationDocuments({
|
||||
organizationId,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
filters,
|
||||
}: {
|
||||
organizationId: string;
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
filters?: {
|
||||
tags?: string[];
|
||||
};
|
||||
}) {
|
||||
const {
|
||||
documents,
|
||||
@@ -42,6 +46,7 @@ export async function fetchOrganizationDocuments({
|
||||
query: {
|
||||
pageIndex,
|
||||
pageSize,
|
||||
...filters,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -180,3 +185,26 @@ export async function searchDocuments({
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getOrganizationDocumentsStats({ organizationId }: { organizationId: string }) {
|
||||
const { organizationStats } = await apiClient<{ organizationStats: { documentsCount: number; documentsSize: number } }>({
|
||||
method: 'GET',
|
||||
path: `/api/organizations/${organizationId}/documents/statistics`,
|
||||
});
|
||||
|
||||
return { organizationStats };
|
||||
}
|
||||
|
||||
export async function deleteAllTrashDocuments({ organizationId }: { organizationId: string }) {
|
||||
await apiClient({
|
||||
method: 'DELETE',
|
||||
path: `/api/organizations/${organizationId}/documents/trash`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteTrashDocument({ documentId, organizationId }: { documentId: string; organizationId: string }) {
|
||||
await apiClient({
|
||||
method: 'DELETE',
|
||||
path: `/api/organizations/${organizationId}/documents/trash/${documentId}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Tag } from '../tags/tags.types';
|
||||
|
||||
export type Document = {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
@@ -6,6 +8,8 @@ export type Document = {
|
||||
originalSize: number;
|
||||
createdAt: Date;
|
||||
updatedAt?: Date;
|
||||
isDeleted?: boolean;
|
||||
deletedAt?: Date;
|
||||
deletedBy?: string;
|
||||
tags: Tag[];
|
||||
};
|
||||
|
||||
@@ -1,35 +1,30 @@
|
||||
import type { Document } from '../documents.types';
|
||||
import { useConfig } from '@/modules/config/config.provider';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { useConfirmModal } from '@/modules/shared/confirm';
|
||||
import { timeAgo } from '@/modules/shared/date/time-ago';
|
||||
import { queryClient } from '@/modules/shared/query/query-client';
|
||||
import { Alert, AlertDescription } from '@/modules/ui/components/alert';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { createToast } from '@/modules/ui/components/sonner';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { createQuery, keepPreviousData } from '@tanstack/solid-query';
|
||||
import { createMutation, createQuery, keepPreviousData } from '@tanstack/solid-query';
|
||||
import { type Component, createSignal, Show, Suspense } from 'solid-js';
|
||||
import { DocumentsPaginatedList } from '../components/documents-list.component';
|
||||
import { fetchOrganizationDeletedDocuments, restoreDocument } from '../documents.services';
|
||||
import { useRestoreDocument } from '../documents.composables';
|
||||
import { deleteAllTrashDocuments, deleteTrashDocument, fetchOrganizationDeletedDocuments } from '../documents.services';
|
||||
|
||||
const RestoreDocumentButton: Component<{ document: Document }> = (props) => {
|
||||
const [getIsLoading, setIsLoading] = createSignal(false);
|
||||
|
||||
const restore = async ({ document }: { document: Document }) => {
|
||||
setIsLoading(true);
|
||||
|
||||
await restoreDocument({
|
||||
documentId: document.id,
|
||||
organizationId: document.organizationId,
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ['organizations', document.organizationId, 'documents'] });
|
||||
|
||||
createToast({ type: 'success', message: 'Document restored' });
|
||||
setIsLoading(false);
|
||||
};
|
||||
const { getIsRestoring, restore } = useRestoreDocument();
|
||||
|
||||
return (
|
||||
<Button variant="outline" size="sm" onClick={() => restore({ document: props.document })} isLoading={getIsLoading()}>
|
||||
{ getIsLoading()
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => restore({ document: props.document })}
|
||||
isLoading={getIsRestoring()}
|
||||
>
|
||||
{ getIsRestoring()
|
||||
? (<>Restoring...</>)
|
||||
: (
|
||||
<>
|
||||
@@ -41,9 +36,117 @@ const RestoreDocumentButton: Component<{ document: Document }> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const PermanentlyDeleteTrashDocumentButton: Component<{ document: Document; organizationId: string }> = (props) => {
|
||||
const { confirm } = useConfirmModal();
|
||||
const { t } = useI18n();
|
||||
|
||||
const deleteMutation = createMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
await deleteTrashDocument({ documentId: props.document.id, organizationId: props.organizationId });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['organizations', props.organizationId, 'documents', 'deleted'] });
|
||||
|
||||
createToast({
|
||||
message: t('trash.deleted.success.title'),
|
||||
description: t('trash.deleted.success.description'),
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const handleClick = async () => {
|
||||
if (!await confirm({
|
||||
title: t('trash.delete.confirm.title'),
|
||||
message: t('trash.delete.confirm.description'),
|
||||
confirmButton: {
|
||||
text: t('trash.delete.confirm.label'),
|
||||
variant: 'destructive',
|
||||
},
|
||||
cancelButton: {
|
||||
text: t('trash.delete.confirm.cancel'),
|
||||
},
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClick}
|
||||
isLoading={deleteMutation.isPending}
|
||||
class="text-red-500 hover:text-red-600"
|
||||
>
|
||||
{deleteMutation.isPending
|
||||
? (<>Deleting...</>)
|
||||
: (
|
||||
<>
|
||||
<div class="i-tabler-trash size-4 mr-2" />
|
||||
{t('trash.delete.button')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const DeleteAllTrashDocumentsButton: Component<{ organizationId: string }> = (props) => {
|
||||
const { confirm } = useConfirmModal();
|
||||
const { t } = useI18n();
|
||||
|
||||
const deleteAllMutation = createMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
await deleteAllTrashDocuments({ organizationId: props.organizationId });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['organizations', props.organizationId, 'documents', 'deleted'] });
|
||||
},
|
||||
}));
|
||||
|
||||
const handleClick = async () => {
|
||||
if (!await confirm({
|
||||
title: t('trash.delete-all.confirm.title'),
|
||||
message: t('trash.delete-all.confirm.description'),
|
||||
confirmButton: {
|
||||
text: t('trash.delete-all.confirm.label'),
|
||||
variant: 'destructive',
|
||||
},
|
||||
cancelButton: {
|
||||
text: t('trash.delete-all.confirm.cancel'),
|
||||
},
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteAllMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClick}
|
||||
isLoading={deleteAllMutation.isPending}
|
||||
class="text-red-500 hover:text-red-600"
|
||||
>
|
||||
{deleteAllMutation.isPending
|
||||
? (<>Deleting...</>)
|
||||
: (
|
||||
<>
|
||||
<div class="i-tabler-trash size-4 mr-2" />
|
||||
{t('trash.delete-all.button')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export const DeletedDocumentsPage: Component = () => {
|
||||
const [getPagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 100 });
|
||||
const params = useParams();
|
||||
const { config } = useConfig();
|
||||
|
||||
const query = createQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'documents', 'deleted', getPagination()],
|
||||
@@ -61,7 +164,11 @@ export const DeletedDocumentsPage: Component = () => {
|
||||
<Alert variant="muted" class="my-4 flex items-center gap-6 xl:gap-4">
|
||||
<div class="i-tabler-info-circle size-10 xl:size-8 text-primary flex-shrink-0 hidden sm:block" />
|
||||
<AlertDescription>
|
||||
All deleted documents are stored in the trash bin for 30 days. Passing this delay, the documents will be permanently deleted, and you will not be able to restore them.
|
||||
All deleted documents are stored in the trash bin for
|
||||
{' '}
|
||||
{config.documents.deletedDocumentsRetentionDays}
|
||||
{' '}
|
||||
days. Passing this delay, the documents will be permanently deleted, and you will not be able to restore them.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -71,12 +178,20 @@ export const DeletedDocumentsPage: Component = () => {
|
||||
<div class="i-tabler-trash text-primary size-12" aria-hidden="true" />
|
||||
<div class="text-xl font-medium">No deleted documents</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
You have no deleted documents. Documents that are deleted will be moved to the trash bin for 30 days.
|
||||
You have no deleted documents. Documents that are deleted will be moved to the trash bin for
|
||||
{' '}
|
||||
{config.documents.deletedDocumentsRetentionDays}
|
||||
{' '}
|
||||
days.
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={query.data && query.data?.documents.length > 0}>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<DeleteAllTrashDocumentsButton organizationId={params.organizationId} />
|
||||
</div>
|
||||
|
||||
<DocumentsPaginatedList
|
||||
documents={query.data?.documents ?? []}
|
||||
documentsCount={query.data?.documentsCount ?? 0}
|
||||
@@ -96,8 +211,9 @@ export const DeletedDocumentsPage: Component = () => {
|
||||
{
|
||||
id: 'actions',
|
||||
cell: data => (
|
||||
<div class="flex items-center justify-end">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<RestoreDocumentButton document={data.row.original} />
|
||||
<PermanentlyDeleteTrashDocumentButton document={data.row.original} organizationId={params.organizationId} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { useConfig } from '@/modules/config/config.provider';
|
||||
import { timeAgo } from '@/modules/shared/date/time-ago';
|
||||
import { downloadFile } from '@/modules/shared/files/download';
|
||||
import { DocumentTagPicker } from '@/modules/tags/components/tag-picker.component';
|
||||
import { addTagToDocument, removeTagFromDocument } from '@/modules/tags/tags.services';
|
||||
import { Alert } from '@/modules/ui/components/alert';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { Separator } from '@/modules/ui/components/separator';
|
||||
import { formatBytes } from '@corentinth/chisels';
|
||||
import { useNavigate, useParams } from '@solidjs/router';
|
||||
import { createQueries } from '@tanstack/solid-query';
|
||||
import { type Component, type JSX, Show, Suspense } from 'solid-js';
|
||||
import { type Component, For, type JSX, Show, Suspense } from 'solid-js';
|
||||
import { DocumentPreview } from '../components/document-preview.component';
|
||||
import { getDaysBeforePermanentDeletion } from '../document.models';
|
||||
import { useDeleteDocument, useRestoreDocument } from '../documents.composables';
|
||||
import { fetchDocument, fetchDocumentFile } from '../documents.services';
|
||||
import '@pdfslick/solid/dist/pdf_viewer.css';
|
||||
|
||||
@@ -18,15 +26,17 @@ const KeyValues: Component<{ data?: KeyValueItem[] }> = (props) => {
|
||||
return (
|
||||
<div class="flex flex-col gap-2">
|
||||
<table>
|
||||
{props.data?.map(item => (
|
||||
<tr>
|
||||
<td class="py-1 pr-2 text-sm text-muted-foreground flex items-center gap-2">
|
||||
{item.icon && <div class={item.icon}></div>}
|
||||
{item.label}
|
||||
</td>
|
||||
<td class="py-1 pl-2 text-sm">{item.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
<For each={props.data}>
|
||||
{item => (
|
||||
<tr>
|
||||
<td class="py-1 pr-2 text-sm text-muted-foreground flex items-center gap-2">
|
||||
{item.icon && <div class={item.icon}></div>}
|
||||
{item.label}
|
||||
</td>
|
||||
<td class="py-1 pl-2 text-sm">{item.value}</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
@@ -34,6 +44,10 @@ const KeyValues: Component<{ data?: KeyValueItem[] }> = (props) => {
|
||||
|
||||
export const DocumentPage: Component = () => {
|
||||
const params = useParams();
|
||||
const { deleteDocument } = useDeleteDocument();
|
||||
const { restore, getIsRestoring } = useRestoreDocument();
|
||||
const navigate = useNavigate();
|
||||
const { config } = useConfig();
|
||||
|
||||
const queries = createQueries(() => ({
|
||||
queries: [
|
||||
@@ -48,29 +62,114 @@ export const DocumentPage: Component = () => {
|
||||
],
|
||||
}));
|
||||
|
||||
const deleteDoc = async () => {
|
||||
if (!queries[0].data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { hasDeleted } = await deleteDocument({
|
||||
documentId: params.documentId,
|
||||
organizationId: params.organizationId,
|
||||
documentName: queries[0].data.document.name,
|
||||
});
|
||||
|
||||
if (!hasDeleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(`/organizations/${params.organizationId}/documents`);
|
||||
};
|
||||
|
||||
const getDataUrl = () => queries[1].data ? URL.createObjectURL(queries[1].data) : undefined;
|
||||
|
||||
return (
|
||||
<div class="p-6 flex gap-6 h-full flex-col md:flex-row max-w-7xl mx-auto">
|
||||
<Suspense>
|
||||
<div class="md:flex-1">
|
||||
<div class="md:flex-1 md:border-r">
|
||||
<Show when={queries[0].data?.document}>
|
||||
{getDocument => (
|
||||
<div class="flex gap-4">
|
||||
<div class="flex gap-4 md:pr-6">
|
||||
<div class="flex-1">
|
||||
<h1 class="text-xl font-semibold">{getDocument().name}</h1>
|
||||
<p class="text-sm text-muted-foreground mb-6">{getDocument().id}</p>
|
||||
<Button
|
||||
onClick={() => downloadFile({
|
||||
fileName: getDocument().name,
|
||||
url: getDataUrl()!,
|
||||
})}
|
||||
variant="default"
|
||||
class="mb-4"
|
||||
>
|
||||
<div class="i-tabler-download mr-2"></div>
|
||||
Download
|
||||
</Button>
|
||||
|
||||
<div class="flex gap-2 mb-2">
|
||||
<Button
|
||||
onClick={() => downloadFile({ fileName: getDocument().name, url: getDataUrl()! })}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<div class="i-tabler-download size-4 mr-2"></div>
|
||||
Download
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.open(getDataUrl()!, '_blank')}
|
||||
size="sm"
|
||||
>
|
||||
<div class="i-tabler-eye size-4 mr-2"></div>
|
||||
Open in new tab
|
||||
</Button>
|
||||
|
||||
{getDocument().isDeleted
|
||||
? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => restore({ document: getDocument() })}
|
||||
isLoading={getIsRestoring()}
|
||||
>
|
||||
<div class="i-tabler-refresh size-4 mr-2"></div>
|
||||
Restore
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={deleteDoc}
|
||||
>
|
||||
<div class="i-tabler-trash size-4 mr-2"></div>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DocumentTagPicker
|
||||
organizationId={params.organizationId}
|
||||
tagIds={getDocument().tags.map(tag => tag.id)}
|
||||
onTagAdded={async ({ tag }) => {
|
||||
await addTagToDocument({
|
||||
documentId: params.documentId,
|
||||
organizationId: params.organizationId,
|
||||
tagId: tag.id,
|
||||
});
|
||||
}}
|
||||
|
||||
onTagRemoved={async ({ tag }) => {
|
||||
await removeTagFromDocument({
|
||||
documentId: params.documentId,
|
||||
organizationId: params.organizationId,
|
||||
tagId: tag.id,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{getDocument().isDeleted && (
|
||||
<Alert variant="destructive" class="mt-6">
|
||||
This document has been deleted and will be permanently removed in
|
||||
{' '}
|
||||
{getDaysBeforePermanentDeletion({
|
||||
document: getDocument(),
|
||||
deletedDocumentsRetentionDays: config.documents.deletedDocumentsRetentionDays,
|
||||
})}
|
||||
{' '}
|
||||
days.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Separator class="my-6" />
|
||||
|
||||
<KeyValues data={[
|
||||
{
|
||||
@@ -81,7 +180,17 @@ export const DocumentPage: Component = () => {
|
||||
{
|
||||
label: 'Name',
|
||||
value: getDocument().name,
|
||||
icon: 'i-tabler-file',
|
||||
icon: 'i-tabler-file-text',
|
||||
},
|
||||
{
|
||||
label: 'Type',
|
||||
value: getDocument().mimeType,
|
||||
icon: 'i-tabler-file-unknown',
|
||||
},
|
||||
{
|
||||
label: 'Size',
|
||||
value: formatBytes({ bytes: getDocument().originalSize, base: 1000 }),
|
||||
icon: 'i-tabler-weight',
|
||||
},
|
||||
{
|
||||
label: 'Created At',
|
||||
|
||||
@@ -1,88 +1,52 @@
|
||||
import type { TooltipTriggerProps } from '@kobalte/core/tooltip';
|
||||
import type { Document } from '../documents.types';
|
||||
import { timeAgo } from '@/modules/shared/date/time-ago';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/modules/ui/components/tooltip';
|
||||
import { formatBytes } from '@corentinth/chisels';
|
||||
import { A, useParams } from '@solidjs/router';
|
||||
import { fetchOrganization } from '@/modules/organizations/organizations.services';
|
||||
import { Tag } from '@/modules/tags/components/tag.component';
|
||||
import { fetchTags } from '@/modules/tags/tags.services';
|
||||
import { useParams, useSearchParams } from '@solidjs/router';
|
||||
import { createQueries, keepPreviousData } from '@tanstack/solid-query';
|
||||
import { type Component, createSignal, Suspense } from 'solid-js';
|
||||
import { DocumentManagementDropdown } from '../components/document-management-dropdown.component';
|
||||
import { castArray } from 'lodash-es';
|
||||
import { type Component, createSignal, For, Show, Suspense } from 'solid-js';
|
||||
import { DocumentUploadArea } from '../components/document-upload-area.component';
|
||||
import { createdAtColumn, DocumentsPaginatedList, standardActionsColumn } from '../components/documents-list.component';
|
||||
import { getDocumentIcon } from '../document.models';
|
||||
import { createdAtColumn, DocumentsPaginatedList, standardActionsColumn, tagsColumn } from '../components/documents-list.component';
|
||||
import { fetchOrganizationDocuments } from '../documents.services';
|
||||
|
||||
const DocumentCard: Component<{ document: Document; organizationId?: string }> = (props) => {
|
||||
const params = useParams();
|
||||
|
||||
const getOrganizationId = () => props.organizationId ?? params.organizationId;
|
||||
|
||||
return (
|
||||
<div class="border rounded-lg overflow-hidden flex gap-4 p-3 pr-2 items-center">
|
||||
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
|
||||
<div class={cn(getDocumentIcon({ document: props.document }), 'size-6 text-primary')}></div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex flex-col gap-1 truncate">
|
||||
<A
|
||||
href={`/organizations/${getOrganizationId()}/documents/${props.document.id}`}
|
||||
class="font-bold truncate block hover:underline"
|
||||
>
|
||||
{props.document.name.split('.').shift()}
|
||||
</A>
|
||||
|
||||
<div class="text-xs text-muted-foreground lh-tight">
|
||||
{formatBytes({ bytes: props.document.originalSize, base: 1000 })}
|
||||
{' '}
|
||||
-
|
||||
{' '}
|
||||
{props.document.name.split('.').pop()?.toUpperCase()}
|
||||
{' '}
|
||||
-
|
||||
{' '}
|
||||
<Tooltip>
|
||||
<TooltipTrigger as={(tooltipProps: TooltipTriggerProps) => (
|
||||
<span {...tooltipProps}>
|
||||
{timeAgo({ date: props.document.createdAt })}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{props.document.createdAt.toLocaleString()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<DocumentManagementDropdown document={props.document} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DocumentsPage: Component = () => {
|
||||
const params = useParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [getPagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 100 });
|
||||
|
||||
const getFiltererTagIds = () => searchParams.tags ? castArray(searchParams.tags) : [];
|
||||
|
||||
const query = createQueries(() => ({
|
||||
queries: [
|
||||
{
|
||||
queryKey: ['organizations', params.organizationId, 'documents', getPagination()],
|
||||
queryKey: ['organizations', params.organizationId, 'documents', getPagination(), getFiltererTagIds()],
|
||||
queryFn: () => fetchOrganizationDocuments({
|
||||
organizationId: params.organizationId,
|
||||
...getPagination(),
|
||||
filters: {
|
||||
tags: getFiltererTagIds(),
|
||||
},
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
},
|
||||
{
|
||||
queryKey: ['organizations', params.organizationId],
|
||||
queryFn: () => fetchOrganization({ organizationId: params.organizationId }),
|
||||
},
|
||||
{
|
||||
queryKey: ['organizations', params.organizationId, 'tags'],
|
||||
queryFn: () => fetchTags({ organizationId: params.organizationId }),
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const getFilteredTags = () => query[2].data?.tags.filter(tag => getFiltererTagIds().includes(tag.id)) ?? [];
|
||||
const hasFilters = () => getFiltererTagIds().length > 0;
|
||||
|
||||
return (
|
||||
<div class="p-6 mt-4 pb-32">
|
||||
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">
|
||||
<Suspense>
|
||||
{query[0].data?.documents?.length === 0
|
||||
{query[0].data?.documents?.length === 0 && !hasFilters()
|
||||
? (
|
||||
<>
|
||||
<h2 class="text-xl font-bold ">
|
||||
@@ -100,17 +64,27 @@ export const DocumentsPage: Component = () => {
|
||||
: (
|
||||
<>
|
||||
<h2 class="text-lg font-semibold mb-4">
|
||||
Latest imported documents
|
||||
Documents
|
||||
</h2>
|
||||
<div class="grid gap-4 grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{query[0].data?.documents.slice(0, 5).map(document => (
|
||||
<DocumentCard document={document} />
|
||||
))}
|
||||
</div>
|
||||
<Show when={hasFilters()}>
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<For each={getFilteredTags()}>
|
||||
{tag => (
|
||||
<Tag
|
||||
{...tag}
|
||||
closable
|
||||
onClose={() => setSearchParams({ tags: getFiltererTagIds().filter(id => id !== tag.id) })}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<h2 class="text-lg font-semibold mt-8 mb-2">
|
||||
All documents
|
||||
</h2>
|
||||
<Show when={hasFilters() && query[0].data?.documentsCount === 0}>
|
||||
<p class="text-muted-foreground mt-1 mb-6">
|
||||
No documents found
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
<DocumentsPaginatedList
|
||||
documents={query[0].data?.documents ?? []}
|
||||
@@ -118,6 +92,7 @@ export const DocumentsPage: Component = () => {
|
||||
getPagination={getPagination}
|
||||
setPagination={setPagination}
|
||||
extraColumns={[
|
||||
tagsColumn,
|
||||
createdAtColumn,
|
||||
standardActionsColumn,
|
||||
]}
|
||||
|
||||
4
apps/papra-client/src/modules/i18n/i18n.constants.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const locales = [
|
||||
{ key: 'en', name: 'English' },
|
||||
{ key: 'fr', name: 'Français' },
|
||||
];
|
||||
127
apps/papra-client/src/modules/i18n/i18n.models.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { createTranslator, findMatchingLocale } from './i18n.models';
|
||||
|
||||
describe('i18n models', () => {
|
||||
describe('findMatchingLocale', () => {
|
||||
test('preferred regional language to regional language', () => {
|
||||
const preferredLocales = ['pt-BR'].map(x => new Intl.Locale(x));
|
||||
const supportedLocales = ['en', 'pt-BR'].map(x => new Intl.Locale(x));
|
||||
const locale = findMatchingLocale({ preferredLocales, supportedLocales });
|
||||
|
||||
expect(locale).to.eql('pt-BR');
|
||||
});
|
||||
|
||||
test('preferred non-regional language to non-regional language', () => {
|
||||
const preferredLocales = ['pt'].map(x => new Intl.Locale(x));
|
||||
const supportedLocales = ['pt-BR', 'pt'].map(x => new Intl.Locale(x));
|
||||
const locale = findMatchingLocale({ preferredLocales, supportedLocales });
|
||||
|
||||
expect(locale).to.eql('pt');
|
||||
});
|
||||
|
||||
test('preferred regional language to non-regional language', () => {
|
||||
const preferredLocales = ['en-GB'].map(x => new Intl.Locale(x));
|
||||
const supportedLocales = ['pt-BR', 'en'].map(x => new Intl.Locale(x));
|
||||
const locale = findMatchingLocale({ preferredLocales, supportedLocales });
|
||||
|
||||
expect(locale).to.eql('en');
|
||||
});
|
||||
|
||||
test('preferred language with different region to supported language', () => {
|
||||
const preferredLocales = ['en-CA'].map(x => new Intl.Locale(x));
|
||||
const supportedLocales = ['fr-FR', 'en-US'].map(x => new Intl.Locale(x));
|
||||
const locale = findMatchingLocale({ preferredLocales, supportedLocales });
|
||||
|
||||
expect(locale).to.eql('en-US');
|
||||
});
|
||||
|
||||
test('preferred language not in supported locales', () => {
|
||||
const preferredLocales = ['it-IT'].map(x => new Intl.Locale(x));
|
||||
const supportedLocales = ['es-ES', 'de-DE'].map(x => new Intl.Locale(x));
|
||||
const locale = findMatchingLocale({ preferredLocales, supportedLocales });
|
||||
|
||||
expect(locale).to.eql('en');
|
||||
});
|
||||
|
||||
test('empty preferred locales', () => {
|
||||
const preferredLocales: Intl.Locale[] = [];
|
||||
const supportedLocales = ['en', 'pt-BR'].map(x => new Intl.Locale(x));
|
||||
const locale = findMatchingLocale({ preferredLocales, supportedLocales });
|
||||
|
||||
expect(locale).to.eql('en');
|
||||
});
|
||||
|
||||
test('empty supported locales', () => {
|
||||
const preferredLocales = ['en-GB', 'pt-BR'].map(x => new Intl.Locale(x));
|
||||
const supportedLocales: Intl.Locale[] = [];
|
||||
const locale = findMatchingLocale({ preferredLocales, supportedLocales });
|
||||
|
||||
expect(locale).to.eql('en');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createTranslator', () => {
|
||||
test('it build a function that return the value of a key in the provided dictionary', () => {
|
||||
const dictionary = {
|
||||
hello: 'Hello!',
|
||||
};
|
||||
const t = createTranslator({ getDictionary: () => dictionary });
|
||||
|
||||
expect(t('hello')).to.eql('Hello!');
|
||||
});
|
||||
|
||||
test('the translator returns the key if the key is not in the dictionary', () => {
|
||||
const dictionary = {
|
||||
hello: 'Hello!',
|
||||
};
|
||||
const t = createTranslator({ getDictionary: () => dictionary });
|
||||
|
||||
expect(t('world' as any)).to.eql('world');
|
||||
});
|
||||
|
||||
test('the translator replaces the placeholders in the translation', () => {
|
||||
const dictionary = {
|
||||
hello: 'Hello, {{ name }}!',
|
||||
};
|
||||
const t = createTranslator({ getDictionary: () => dictionary });
|
||||
|
||||
expect(t('hello', { name: 'John' })).to.eql('Hello, John!');
|
||||
});
|
||||
|
||||
test('the translator replaces all occurrences of the placeholder', () => {
|
||||
const dictionary = {
|
||||
hello: 'Hello, {{ name }}! How are you, {{ name }}?',
|
||||
};
|
||||
const t = createTranslator({ getDictionary: () => dictionary });
|
||||
|
||||
expect(t('hello', { name: 'John' })).to.eql('Hello, John! How are you, John?');
|
||||
});
|
||||
|
||||
test('the translator replaces multiple placeholders', () => {
|
||||
const dictionary = {
|
||||
hello: 'Hello, {{ name }} {{ surname }}!',
|
||||
};
|
||||
const t = createTranslator({ getDictionary: () => dictionary });
|
||||
|
||||
expect(t('hello', { name: 'John', surname: 'Doe' })).to.eql('Hello, John Doe!');
|
||||
});
|
||||
|
||||
test('when no value is provided for a placeholder, it keeps the placeholder', () => {
|
||||
const dictionary = {
|
||||
hello: 'Hello, {{ name }}!',
|
||||
};
|
||||
const t = createTranslator({ getDictionary: () => dictionary });
|
||||
|
||||
expect(t('hello')).to.eql('Hello, {{ name }}!');
|
||||
});
|
||||
|
||||
test('the spaces around the placeholder are optional', () => {
|
||||
const dictionary = {
|
||||
hello: '{{name}}, {{ name }}, {{ name}} and {{ name }}!',
|
||||
};
|
||||
const t = createTranslator({ getDictionary: () => dictionary });
|
||||
|
||||
expect(t('hello', { name: 'John' })).to.eql('John, John, John and John!');
|
||||
});
|
||||
});
|
||||
});
|
||||
74
apps/papra-client/src/modules/i18n/i18n.models.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { JSX } from 'solid-js';
|
||||
import type { Locale } from './i18n.provider';
|
||||
|
||||
// This tries to get the most preferred language compatible with the supported languages
|
||||
// It tries to find a supported language by comparing both region and language, if not, then just language
|
||||
// For example:
|
||||
// en-GB -> en
|
||||
// pt-BR -> pt-BR
|
||||
export function findMatchingLocale({
|
||||
preferredLocales,
|
||||
supportedLocales,
|
||||
}: {
|
||||
preferredLocales: Intl.Locale[];
|
||||
supportedLocales: Intl.Locale[];
|
||||
}) {
|
||||
for (const locale of preferredLocales) {
|
||||
const localeMatchRegion = supportedLocales.find(x => x.baseName === locale.baseName);
|
||||
|
||||
if (localeMatchRegion) {
|
||||
return localeMatchRegion.baseName as Locale;
|
||||
}
|
||||
|
||||
const localeMatchLanguage = supportedLocales.find(x => x.language === locale.language);
|
||||
if (localeMatchLanguage) {
|
||||
return localeMatchLanguage.baseName as Locale;
|
||||
}
|
||||
}
|
||||
return 'en';
|
||||
}
|
||||
|
||||
export function createTranslator<Dict extends Record<string, string>>({ getDictionary }: { getDictionary: () => Dict }) {
|
||||
return (key: keyof Dict, args?: Record<string, string | number>) => {
|
||||
const translationFromDictionary = getDictionary()[key];
|
||||
|
||||
if (!translationFromDictionary && import.meta.env.DEV) {
|
||||
console.warn(`Translation not found for key: ${String(key)}`);
|
||||
}
|
||||
|
||||
let translation: string = translationFromDictionary ?? key;
|
||||
|
||||
if (args) {
|
||||
for (const [key, value] of Object.entries(args)) {
|
||||
translation = translation.replace(new RegExp(`{{\\s*${key}\\s*}}`, 'g'), String(value));
|
||||
}
|
||||
}
|
||||
|
||||
return translation;
|
||||
};
|
||||
}
|
||||
|
||||
export function createFragmentTranslator<Dict extends Record<string, string>>({ getDictionary }: { getDictionary: () => Dict }) {
|
||||
return (key: keyof Dict, args?: Record<string, JSX.Element>) => {
|
||||
const translation: string = getDictionary()[key] ?? key;
|
||||
|
||||
if (args) {
|
||||
const fragments: JSX.Element[] = [];
|
||||
|
||||
const parts = translation.split(/(\{\{[^}]+\}\})/g);
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.startsWith('{{') && part.endsWith('}}')) {
|
||||
const key = part.slice(2, -2).trim();
|
||||
fragments.push(args[key]);
|
||||
} else {
|
||||
fragments.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
return fragments;
|
||||
}
|
||||
|
||||
return translation;
|
||||
};
|
||||
}
|
||||
69
apps/papra-client/src/modules/i18n/i18n.provider.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { Accessor, ParentComponent, Setter } from 'solid-js';
|
||||
import type { LocaleKeys } from './locales.types';
|
||||
import { makePersisted } from '@solid-primitives/storage';
|
||||
import { createContext, createEffect, createResource, createSignal, Show, useContext } from 'solid-js';
|
||||
import defaultDict from '../../locales/en.yml?flattened';
|
||||
import { locales } from './i18n.constants';
|
||||
import { createFragmentTranslator, createTranslator, findMatchingLocale } from './i18n.models';
|
||||
|
||||
export type Locale = typeof locales[number]['key'];
|
||||
type Dictionary = Record<LocaleKeys, string>;
|
||||
|
||||
const I18nContext = createContext<{
|
||||
t: ReturnType<typeof createTranslator<Dictionary>>;
|
||||
te: ReturnType<typeof createFragmentTranslator<Dictionary>>;
|
||||
getLocale: Accessor<Locale>;
|
||||
setLocale: Setter<Locale>;
|
||||
locales: typeof locales;
|
||||
}>();
|
||||
|
||||
export function useI18n() {
|
||||
const context = useContext(I18nContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('I18n context not found, useI18n must be used within I18nProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
async function fetchDictionary(locale: Locale): Promise<Dictionary> {
|
||||
const { default: dict } = await import(`../../locales/${locale}.yml?flattened`);
|
||||
|
||||
return {
|
||||
...defaultDict,
|
||||
...dict,
|
||||
};
|
||||
}
|
||||
|
||||
export const I18nProvider: ParentComponent = (props) => {
|
||||
const browserLocale = findMatchingLocale({
|
||||
preferredLocales: navigator.languages.map(x => new Intl.Locale(x)),
|
||||
supportedLocales: locales.map(x => new Intl.Locale(x.key)),
|
||||
});
|
||||
const [getLocale, setLocale] = makePersisted(createSignal<Locale>(browserLocale), { name: 'papra_locale', storage: localStorage });
|
||||
|
||||
const [dict] = createResource(getLocale, fetchDictionary);
|
||||
|
||||
createEffect(() => {
|
||||
document.documentElement.lang = getLocale();
|
||||
});
|
||||
|
||||
return (
|
||||
<Show when={dict.latest}>
|
||||
{getDictionary => (
|
||||
<I18nContext.Provider
|
||||
value={{
|
||||
t: createTranslator({ getDictionary }),
|
||||
te: createFragmentTranslator({ getDictionary }),
|
||||
getLocale,
|
||||
setLocale,
|
||||
locales,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</I18nContext.Provider>
|
||||
)}
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
29
apps/papra-client/src/modules/i18n/locales.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
const rawLocales = import.meta.glob('../../locales/*.yml', { eager: true, query: '?flattened' });
|
||||
const { en: defaultLocal, ...locales } = Object.fromEntries(
|
||||
Object.entries(rawLocales).map(([key, value]: [string, any]) => [key.replace('../../locales/', '').replace('.yml', ''), value.default]),
|
||||
);
|
||||
|
||||
describe('locales', () => {
|
||||
for (const [locale, translations] of Object.entries(locales)) {
|
||||
describe(locale, () => {
|
||||
test(`locale ${locale} must not have extra keys compared to default`, () => {
|
||||
const extraKeys = Object
|
||||
.keys(translations)
|
||||
.filter(key => !(key in defaultLocal));
|
||||
|
||||
expect(extraKeys).to.eql([], `Extra keys found in ${locale}`);
|
||||
});
|
||||
|
||||
test(`all translations in ${locale} must be strings`, () => {
|
||||
const nonStringTranslations = Object
|
||||
.entries(translations)
|
||||
.filter(([, value]) => typeof value !== 'string')
|
||||
.map(([key]) => key);
|
||||
|
||||
expect(nonStringTranslations).to.eql([], `Non-string translations found in ${locale}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
2
apps/papra-client/src/modules/i18n/locales.types.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { IntakeEmail } from './intake-emails.types';
|
||||
import { apiClient } from '../shared/http/api-client';
|
||||
import { coerceDates } from '../shared/http/http-client.models';
|
||||
|
||||
export async function fetchIntakeEmails({ organizationId }: { organizationId: string }) {
|
||||
const { intakeEmails } = await apiClient<{ intakeEmails: IntakeEmail[] }>({
|
||||
path: `/api/organizations/${organizationId}/intake-emails`,
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
return {
|
||||
intakeEmails: intakeEmails.map(coerceDates),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createIntakeEmail({ organizationId }: { organizationId: string }) {
|
||||
const { intakeEmail } = await apiClient<{ intakeEmail: IntakeEmail }>({
|
||||
path: `/api/organizations/${organizationId}/intake-emails`,
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
return {
|
||||
intakeEmail: coerceDates(intakeEmail),
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteIntakeEmail({ organizationId, intakeEmailId }: { organizationId: string; intakeEmailId: string }) {
|
||||
await apiClient({
|
||||
path: `/api/organizations/${organizationId}/intake-emails/${intakeEmailId}`,
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateIntakeEmail({
|
||||
organizationId,
|
||||
intakeEmailId,
|
||||
isEnabled,
|
||||
allowedOrigins,
|
||||
}: {
|
||||
organizationId: string;
|
||||
intakeEmailId: string;
|
||||
isEnabled?: boolean;
|
||||
allowedOrigins?: string[];
|
||||
}) {
|
||||
const { intakeEmail } = await apiClient<{ intakeEmail: IntakeEmail }>({
|
||||
path: `/api/organizations/${organizationId}/intake-emails/${intakeEmailId}`,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
isEnabled,
|
||||
allowedOrigins,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
intakeEmail: coerceDates(intakeEmail),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export type IntakeEmail = {
|
||||
id: string;
|
||||
emailAddress: string;
|
||||
organizationId: string;
|
||||
isEnabled: boolean;
|
||||
allowedOrigins: string[];
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date | undefined;
|
||||
};
|
||||
@@ -0,0 +1,347 @@
|
||||
import type { DialogTriggerProps } from '@kobalte/core/dialog';
|
||||
import type { IntakeEmail } from '../intake-emails.types';
|
||||
import { useConfig } from '@/modules/config/config.provider';
|
||||
import { useConfirmModal } from '@/modules/shared/confirm';
|
||||
import { createForm } from '@/modules/shared/form/form';
|
||||
import { isHttpErrorWithCode } from '@/modules/shared/http/http-errors';
|
||||
import { queryClient } from '@/modules/shared/query/query-client';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { Alert, AlertDescription } from '@/modules/ui/components/alert';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { Card } from '@/modules/ui/components/card';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/modules/ui/components/dialog';
|
||||
import { EmptyState } from '@/modules/ui/components/empty';
|
||||
import { createToast } from '@/modules/ui/components/sonner';
|
||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { createQuery } from '@tanstack/solid-query';
|
||||
import { type Component, For, type JSX, Show, Suspense } from 'solid-js';
|
||||
import { createSignal } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { createIntakeEmail, deleteIntakeEmail, fetchIntakeEmails, updateIntakeEmail } from '../intake-emails.services';
|
||||
|
||||
const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) => JSX.Element; intakeEmails: IntakeEmail }> = (props) => {
|
||||
const [getAllowedOrigins, setAllowedOrigins] = createSignal([...props.intakeEmails.allowedOrigins]);
|
||||
|
||||
const update = async () => {
|
||||
await updateIntakeEmail({
|
||||
organizationId: props.intakeEmails.organizationId,
|
||||
intakeEmailId: props.intakeEmails.id,
|
||||
allowedOrigins: getAllowedOrigins(),
|
||||
});
|
||||
};
|
||||
|
||||
const deleteAllowedOrigin = async ({ origin }: { origin: string }) => {
|
||||
setAllowedOrigins(origins => origins.filter(o => o !== origin));
|
||||
await update();
|
||||
};
|
||||
|
||||
const { form, Form, Field } = createForm({
|
||||
schema: v.object({
|
||||
email: v.pipe(
|
||||
v.string(),
|
||||
v.trim(),
|
||||
v.email('Please enter a valid email address'),
|
||||
),
|
||||
}),
|
||||
onSubmit: async ({ email }) => {
|
||||
if (getAllowedOrigins().includes(email)) {
|
||||
throw new Error('This email is already in the allowed origins for this intake email');
|
||||
}
|
||||
|
||||
setAllowedOrigins(origins => [...origins, email]);
|
||||
await update();
|
||||
},
|
||||
});
|
||||
|
||||
async function invalidateQuery() {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['organizations', props.intakeEmails.organizationId, 'intake-emails'],
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={isOpen => !isOpen && invalidateQuery()}>
|
||||
<DialogTrigger as={props.children} />
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Allowed origins</DialogTitle>
|
||||
<DialogDescription>
|
||||
Only emails sent to
|
||||
{' '}
|
||||
<span class="font-medium text-primary">{props.intakeEmails.emailAddress}</span>
|
||||
{' '}
|
||||
from these origins will be processed. If no origins are specified, all emails will be discarded.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form>
|
||||
<Field name="email">
|
||||
{(field, inputProps) => (
|
||||
<TextFieldRoot class="flex flex-col gap-1 mb-4 mt-4">
|
||||
<TextFieldLabel for="email">Add allowed origin email</TextFieldLabel>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<TextField type="email" id="email" placeholder="Eg. ada@papra.app" {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
|
||||
<Button type="submit">
|
||||
<div class="i-tabler-plus size-4 mr-2" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="text-red-500 text-sm mt-4">{form.response.message}</div>
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error }</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
</Form>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<For each={getAllowedOrigins()}>
|
||||
{origin => (
|
||||
<div class="flex items-center gap-2 justify-between border rounded-lg p-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="bg-muted size-9 rounded-lg flex items-center justify-center">
|
||||
<div class="i-tabler-mail size-5 text-primary" />
|
||||
</div>
|
||||
<div class="font-medium text-sm">
|
||||
{origin}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-label="Delete allowed origin"
|
||||
size="icon"
|
||||
class="text-red"
|
||||
onClick={() => deleteAllowedOrigin({ origin })}
|
||||
>
|
||||
<div class="i-tabler-trash size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const IntakeEmailsPage: Component = () => {
|
||||
const { config } = useConfig();
|
||||
|
||||
if (!config.intakeEmails.isEnabled) {
|
||||
return (
|
||||
<Card class="p-6">
|
||||
<h2 class="text-base font-bold">Intake Emails</h2>
|
||||
<p class="text-muted-foreground mt-1">
|
||||
Intake emails are disabled on this instance. Please contact your administrator to enable them.
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const params = useParams();
|
||||
const { confirm } = useConfirmModal();
|
||||
|
||||
const query = createQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'intake-emails'],
|
||||
queryFn: () => fetchIntakeEmails({ organizationId: params.organizationId }),
|
||||
}));
|
||||
|
||||
const createEmail = async () => {
|
||||
const [,error] = await safely(createIntakeEmail({ organizationId: params.organizationId }));
|
||||
|
||||
if (isHttpErrorWithCode({ error, code: 'intake_email.limit_reached' })) {
|
||||
createToast({
|
||||
message: 'The maximum number of intake emails for this organization has been reached. Please upgrade your plan to create more intake emails.',
|
||||
type: 'error',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await query.refetch();
|
||||
|
||||
createToast({
|
||||
message: 'Intake email created',
|
||||
type: 'success',
|
||||
});
|
||||
};
|
||||
|
||||
const deleteEmail = async ({ intakeEmailId }: { intakeEmailId: string }) => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Delete intake email?',
|
||||
message: 'Are you sure you want to delete this intake email? This action cannot be undone.',
|
||||
cancelButton: {
|
||||
text: 'Cancel',
|
||||
},
|
||||
confirmButton: {
|
||||
text: 'Delete intake email',
|
||||
variant: 'destructive',
|
||||
},
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteIntakeEmail({ organizationId: params.organizationId, intakeEmailId });
|
||||
await query.refetch();
|
||||
|
||||
createToast({
|
||||
message: 'Intake email deleted',
|
||||
type: 'success',
|
||||
});
|
||||
};
|
||||
|
||||
const updateEmail = async ({ intakeEmailId, isEnabled }: { intakeEmailId: string; isEnabled: boolean }) => {
|
||||
await updateIntakeEmail({ organizationId: params.organizationId, intakeEmailId, isEnabled });
|
||||
await query.refetch();
|
||||
|
||||
createToast({
|
||||
message: `Intake email ${isEnabled ? 'enabled' : 'disabled'}`,
|
||||
type: 'success',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card class="p-6">
|
||||
|
||||
<h2 class="text-base font-bold">Intake Emails</h2>
|
||||
|
||||
<p class="text-muted-foreground mt-1">
|
||||
Intake emails address are used to automatically ingest emails into Papra. Just forward emails to the intake email address and their attachments will be added to your organization's documents.
|
||||
</p>
|
||||
|
||||
<Alert variant="default" class="mt-4 flex items-center gap-4 xl:gap-4 text-muted-foreground">
|
||||
<div class="i-tabler-info-circle size-10 xl:size-8 text-primary flex-shrink-0 " />
|
||||
|
||||
<AlertDescription>
|
||||
Only enabled intake emails from allowed origins will be processed. You can enable or disable an intake email at any time.
|
||||
</AlertDescription>
|
||||
|
||||
</Alert>
|
||||
|
||||
<Suspense>
|
||||
<Show when={query.data?.intakeEmails}>
|
||||
{intakeEmails => (
|
||||
<Show
|
||||
when={intakeEmails().length > 0}
|
||||
fallback={(
|
||||
<div class="mt-4 py-8 border-2 border-dashed rounded-lg text-center">
|
||||
<EmptyState
|
||||
title="No intake emails"
|
||||
description="Generate an intake address to easily ingest emails attachments."
|
||||
class="pt-0"
|
||||
icon="i-tabler-mail"
|
||||
cta={(
|
||||
<Button variant="secondary" onClick={createEmail}>
|
||||
<div class="i-tabler-plus size-4 mr-2" />
|
||||
Generate intake email
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div class="mt-4 mb-4 flex items-center justify-between">
|
||||
<div class="text-muted-foreground">
|
||||
{`${intakeEmails().length} intake email${intakeEmails().length > 1 ? 's' : ''} for this organization`}
|
||||
</div>
|
||||
|
||||
<Button onClick={createEmail}>
|
||||
<div class="i-tabler-plus size-4 mr-2" />
|
||||
New intake email
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<For each={intakeEmails()}>
|
||||
{intakeEmail => (
|
||||
<div class="flex items-center justify-between border rounded-lg p-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="bg-muted size-9 rounded-lg flex items-center justify-center">
|
||||
<div class={cn('i-tabler-mail size-5', intakeEmail.isEnabled ? 'text-primary' : 'text-muted-foreground')} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="font-medium">
|
||||
{intakeEmail.emailAddress}
|
||||
|
||||
<Show when={!intakeEmail.isEnabled}>
|
||||
<span class="text-muted-foreground text-xs ml-2">(Disabled)</span>
|
||||
</Show>
|
||||
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={intakeEmail.allowedOrigins.length > 0}
|
||||
fallback={(
|
||||
<div class="text-xs text-warning flex items-center gap-1.5">
|
||||
<div class="i-tabler-alert-triangle size-3.75" />
|
||||
No allowed email origins
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div class="text-xs text-muted-foreground flex items-center gap-2">
|
||||
{`Allowed from ${intakeEmail.allowedOrigins.length} address${intakeEmail.allowedOrigins.length > 1 ? 'es' : ''}`}
|
||||
</div>
|
||||
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => updateEmail({ intakeEmailId: intakeEmail.id, isEnabled: !intakeEmail.isEnabled })}
|
||||
>
|
||||
<div class="i-tabler-power size-4 mr-2" />
|
||||
{intakeEmail.isEnabled ? 'Disable' : 'Enable'}
|
||||
</Button>
|
||||
|
||||
<AllowedOriginsDialog intakeEmails={intakeEmail}>
|
||||
{(props: DialogTriggerProps) => (
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-label="Edit intake email"
|
||||
{...props}
|
||||
class="flex items-center gap-2 leading-none"
|
||||
>
|
||||
<div class="i-tabler-edit size-4" />
|
||||
Manage origins addresses
|
||||
</Button>
|
||||
)}
|
||||
</AllowedOriginsDialog>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => deleteEmail({ intakeEmailId: intakeEmail.id })}
|
||||
aria-label="Delete intake email"
|
||||
class="text-red"
|
||||
>
|
||||
<div class="i-tabler-trash size-4 mr-2" />
|
||||
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
|
||||
</div>
|
||||
|
||||
</Show>
|
||||
|
||||
)}
|
||||
</Show>
|
||||
</Suspense>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { createForm } from '@/modules/shared/form/form';
|
||||
import { isHttpErrorWithCode } from '@/modules/shared/http/http-errors';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import * as v from 'valibot';
|
||||
import { organizationNameSchema } from '../organizations.schemas';
|
||||
|
||||
@@ -10,7 +12,15 @@ export const CreateOrganizationForm: Component<{
|
||||
initialOrganizationName?: string;
|
||||
}> = (props) => {
|
||||
const { form, Form, Field } = createForm({
|
||||
onSubmit: ({ organizationName }) => props.onSubmit({ organizationName }),
|
||||
onSubmit: async ({ organizationName }) => {
|
||||
const [, error] = await safely(props.onSubmit({ organizationName }));
|
||||
|
||||
if (isHttpErrorWithCode({ error, code: 'user.max_organization_count_reached' })) {
|
||||
throw new Error('You have reached the maximum number of organizations you can create, if you need to create more, please contact support.');
|
||||
}
|
||||
|
||||
throw error;
|
||||
},
|
||||
schema: v.object({
|
||||
organizationName: organizationNameSchema,
|
||||
}),
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
export type Organization = { id: string; name: string; createdAt: Date; updatedAt?: Date };
|
||||
export type Organization = {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
updatedAt?: Date;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { DocumentUploadArea } from '@/modules/documents/components/document-upload-area.component';
|
||||
import { createdAtColumn, DocumentsPaginatedList, standardActionsColumn, tagsColumn } from '@/modules/documents/components/documents-list.component';
|
||||
import { useUploadDocuments } from '@/modules/documents/documents.composables';
|
||||
import { fetchOrganizationDocuments, getOrganizationDocumentsStats } from '@/modules/documents/documents.services';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { formatBytes } from '@corentinth/chisels';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { createQueries, keepPreviousData } from '@tanstack/solid-query';
|
||||
import { type Component, createSignal, Show, Suspense } from 'solid-js';
|
||||
|
||||
export const OrganizationPage: Component = () => {
|
||||
const params = useParams();
|
||||
const [getPagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 100 });
|
||||
|
||||
const query = createQueries(() => ({
|
||||
queries: [
|
||||
{
|
||||
queryKey: ['organizations', params.organizationId, 'documents', getPagination()],
|
||||
queryFn: () => fetchOrganizationDocuments({
|
||||
organizationId: params.organizationId,
|
||||
...getPagination(),
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
},
|
||||
{
|
||||
queryKey: ['organizations', params.organizationId, 'documents', 'stats'],
|
||||
queryFn: () => getOrganizationDocumentsStats({ organizationId: params.organizationId }),
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const { promptImport } = useUploadDocuments({ organizationId: params.organizationId });
|
||||
|
||||
return (
|
||||
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">
|
||||
<Suspense>
|
||||
{query[0].data?.documents?.length === 0
|
||||
? (
|
||||
<>
|
||||
<h2 class="text-xl font-bold ">
|
||||
No documents
|
||||
</h2>
|
||||
|
||||
<p class="text-muted-foreground mt-1 mb-6">
|
||||
There are no documents in this organization yet. Start by uploading some documents.
|
||||
</p>
|
||||
|
||||
<DocumentUploadArea />
|
||||
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-12">
|
||||
|
||||
<Button onClick={promptImport} class="h-auto items-start flex-col gap-4 py-4 px-6">
|
||||
<div class="i-tabler-upload size-6"></div>
|
||||
|
||||
Upload documents
|
||||
</Button>
|
||||
|
||||
<Show when={query[1].data?.organizationStats}>
|
||||
{organizationStats => (
|
||||
<>
|
||||
<div class="border rounded-lg p-2 flex items-center gap-4 py-4 px-6">
|
||||
<div class="flex gap-2 items-baseline">
|
||||
<span class="font-light text-2xl">
|
||||
{organizationStats().documentsCount}
|
||||
</span>
|
||||
<span class="text-muted-foreground">
|
||||
documents in total
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-lg p-2 flex items-center gap-4 py-4 px-6">
|
||||
<div class="flex gap-2 items-baseline">
|
||||
<span class="font-light text-2xl">
|
||||
{formatBytes({ bytes: organizationStats().documentsSize, base: 1000 })}
|
||||
</span>
|
||||
<span class="text-muted-foreground">
|
||||
total size
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<h2 class="text-lg font-semibold mb-4">
|
||||
Latest imported documents
|
||||
</h2>
|
||||
|
||||
<DocumentsPaginatedList
|
||||
documents={query[0].data?.documents ?? []}
|
||||
documentsCount={query[0].data?.documentsCount ?? 0}
|
||||
getPagination={getPagination}
|
||||
setPagination={setPagination}
|
||||
extraColumns={[
|
||||
tagsColumn,
|
||||
createdAtColumn,
|
||||
standardActionsColumn,
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,13 +1,16 @@
|
||||
import type { Organization } from '../organizations.types';
|
||||
import { buildTimeConfig } from '@/modules/config/config';
|
||||
import { useConfirmModal } from '@/modules/shared/confirm';
|
||||
import { createForm } from '@/modules/shared/form/form';
|
||||
import { getCustomerPortalUrl } from '@/modules/subscriptions/subscriptions.services';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/modules/ui/components/card';
|
||||
import { createToast } from '@/modules/ui/components/sonner';
|
||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { createQuery } from '@tanstack/solid-query';
|
||||
import { type Component, Show, Suspense } from 'solid-js';
|
||||
import { type Component, createSignal, Show, Suspense } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { useDeleteOrganization, useUpdateOrganization } from '../organizations.composables';
|
||||
import { organizationNameSchema } from '../organizations.schemas';
|
||||
@@ -57,6 +60,43 @@ const DeleteOrganizationCard: Component<{ organization: Organization }> = (props
|
||||
);
|
||||
};
|
||||
|
||||
export const SubscriptionCard: Component<{ organization: Organization }> = (props) => {
|
||||
const [getIsLoading, setIsLoading] = createSignal(false);
|
||||
|
||||
const goToCustomerPortal = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const [result, error] = await safely(getCustomerPortalUrl({ organizationId: props.organization.id }));
|
||||
|
||||
if (error) {
|
||||
createToast({ type: 'error', message: 'Failed to get customer portal URL' });
|
||||
setIsLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(result.customerPortalUrl, '_blank');
|
||||
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card class="flex flex-col sm:flex-row justify-between gap-4 sm:items-center p-6 ">
|
||||
<div>
|
||||
<div class="font-semibold">Subscription</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
Manage your billing, invoices and payment methods.
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={goToCustomerPortal} isLoading={getIsLoading()} class="flex-shrink-0" disabled={buildTimeConfig.isDemoMode}>
|
||||
Manage subscription
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const UpdateOrganizationNameCard: Component<{ organization: Organization }> = (props) => {
|
||||
const { updateOrganization } = useUpdateOrganization();
|
||||
|
||||
@@ -139,6 +179,7 @@ export const OrganizationsSettingsPage: Component = () => {
|
||||
|
||||
<div class="mt-6 flex flex-col gap-6">
|
||||
<UpdateOrganizationNameCard organization={getOrganization()} />
|
||||
<SubscriptionCard organization={getOrganization()} />
|
||||
<DeleteOrganizationCard organization={getOrganization()} />
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { A, useNavigate } from '@solidjs/router';
|
||||
import { createQuery } from '@tanstack/solid-query';
|
||||
import { type Component, createEffect, on } from 'solid-js';
|
||||
import { type Component, createEffect, For, on } from 'solid-js';
|
||||
import { fetchOrganizations } from '../organizations.services';
|
||||
|
||||
export const OrganizationsPage: Component = () => {
|
||||
@@ -31,23 +31,25 @@ export const OrganizationsPage: Component = () => {
|
||||
</p>
|
||||
|
||||
<div class="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{queries.data?.organizations.map(organization => (
|
||||
<A
|
||||
href={`/organizations/${organization.id}`}
|
||||
class="border rounded-lg overflow-hidden"
|
||||
>
|
||||
<div class="bg-card border-b flex items-center justify-center p-6">
|
||||
<div class="size-16 text-muted-foreground"></div>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
|
||||
<div class="w-full text-left font-bold truncate block">
|
||||
{organization.name}
|
||||
<For each={queries.data?.organizations}>
|
||||
{organization => (
|
||||
<A
|
||||
href={`/organizations/${organization.id}`}
|
||||
class="border rounded-lg overflow-hidden"
|
||||
>
|
||||
<div class="bg-card border-b flex items-center justify-center p-6">
|
||||
<div class="size-16 text-muted-foreground"></div>
|
||||
</div>
|
||||
</div>
|
||||
</A>
|
||||
))}
|
||||
|
||||
<div class="p-4">
|
||||
|
||||
<div class="w-full text-left font-bold truncate block">
|
||||
{organization.name}
|
||||
</div>
|
||||
</div>
|
||||
</A>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<A href="/organizations/create" class="border rounded-lg overflow-hidden border-dashed border-2px p-4 flex flex-col items-center justify-center text-center gap-2 group">
|
||||
<div class="i-tabler-plus size-16 text-muted-foreground op-50 group-hover:(text-primary op-100) transition" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FormProps, PartialValues } from '@modular-forms/solid';
|
||||
import type { FormErrors, FormProps, PartialValues } from '@modular-forms/solid';
|
||||
import type * as v from 'valibot';
|
||||
import { createForm as createModularForm, valiForm } from '@modular-forms/solid';
|
||||
import { createForm as createModularForm, FormError, valiForm } from '@modular-forms/solid';
|
||||
import { createHook } from '../hooks/hooks';
|
||||
|
||||
export function createForm<Schema extends v.ObjectSchema<any, any>>({
|
||||
@@ -18,7 +18,7 @@ export function createForm<Schema extends v.ObjectSchema<any, any>>({
|
||||
submitHook.on(onSubmit);
|
||||
}
|
||||
|
||||
const [form, { Form, Field }] = createModularForm<v.InferInput<Schema>>({
|
||||
const [form, { Form, Field, FieldArray }] = createModularForm<v.InferInput<Schema>>({
|
||||
validate: valiForm(schema),
|
||||
initialValues,
|
||||
});
|
||||
@@ -27,7 +27,9 @@ export function createForm<Schema extends v.ObjectSchema<any, any>>({
|
||||
form,
|
||||
Form: (props: Omit<FormProps<v.InferInput<Schema>, undefined>, 'of'>) => Form({ ...props, onSubmit: submitHook.trigger }),
|
||||
Field,
|
||||
FieldArray,
|
||||
onSubmit: submitHook.on,
|
||||
submit: submitHook.trigger,
|
||||
createFormError: ({ message, fields }: { message: string; fields?: FormErrors<v.InferInput<Schema>> }) => new FormError<v.InferInput<Schema>>(message, fields),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { config } from '@/modules/config/config';
|
||||
import { buildTimeConfig } from '@/modules/config/config';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { httpClient, type HttpClientOptions, type ResponseType } from './http-client';
|
||||
import { isHttpErrorWithStatusCode } from './http-errors';
|
||||
@@ -10,7 +10,7 @@ export async function apiClient<T, R extends ResponseType = 'json'>({
|
||||
path: string;
|
||||
} & Omit<HttpClientOptions<R>, 'url'>) {
|
||||
const requestConfig: HttpClientOptions<R> = {
|
||||
baseUrl: config.baseApiUrl,
|
||||
baseUrl: buildTimeConfig.baseApiUrl,
|
||||
url: path,
|
||||
credentials: 'include',
|
||||
...rest,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { LocaleKeys } from '@/modules/i18n/locales.types';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { get } from 'lodash-es';
|
||||
|
||||
export function useI18nApiErrors({ t = useI18n().t }: { t?: ReturnType<typeof useI18n>['t'] } = {}) {
|
||||
const getTranslationFromApiErrorCode = ({ code }: { code: string }) => {
|
||||
return t(`api-errors.${code}` as LocaleKeys);
|
||||
};
|
||||
|
||||
const getTranslationFromApiError = ({ error }: { error: unknown }) => {
|
||||
const code = get(error, 'data.error.code') ?? get(error, 'code');
|
||||
|
||||
if (!code) {
|
||||
return t('api-errors.default');
|
||||
}
|
||||
|
||||
return getTranslationFromApiErrorCode({ code });
|
||||
};
|
||||
|
||||
return {
|
||||
getErrorMessage: (args: { error: unknown } | { code: string }) => {
|
||||
if ('error' in args) {
|
||||
return getTranslationFromApiError({ error: args.error });
|
||||
}
|
||||
|
||||
return getTranslationFromApiErrorCode({ code: args.code });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { getFormData } from './http-client.models';
|
||||
import { coerceDates, getFormData } from './http-client.models';
|
||||
|
||||
describe('http-client models', () => {
|
||||
describe('getFormData', () => {
|
||||
@@ -17,4 +17,62 @@ describe('http-client models', () => {
|
||||
expect(formData.get('file2')).to.be.instanceOf(File);
|
||||
});
|
||||
});
|
||||
|
||||
describe('coerceDates', () => {
|
||||
test('transforms common date strings (createdAt, updatedAt, deletedAt) into Date objects, keeping the rest of the object intact', () => {
|
||||
const obj = {
|
||||
createdAt: '2021-01-01T00:00:00.000Z',
|
||||
updatedAt: '2021-01-02T00:00:00.000Z',
|
||||
deletedAt: '2021-01-03T00:00:00.000Z',
|
||||
foo: 'bar',
|
||||
baz: 'qux',
|
||||
};
|
||||
|
||||
const coercedObj = coerceDates(obj);
|
||||
|
||||
expect(coercedObj).to.eql({
|
||||
createdAt: new Date('2021-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2021-01-02T00:00:00.000Z'),
|
||||
deletedAt: new Date('2021-01-03T00:00:00.000Z'),
|
||||
foo: 'bar',
|
||||
baz: 'qux',
|
||||
});
|
||||
});
|
||||
|
||||
test('nullish values are transformed into undefined', () => {
|
||||
const obj = {
|
||||
createdAt: null,
|
||||
updatedAt: undefined,
|
||||
deletedAt: '2021-01-03T00:00:00.000Z',
|
||||
foo: 'bar',
|
||||
baz: 'qux',
|
||||
};
|
||||
|
||||
const coercedObj = coerceDates(obj);
|
||||
|
||||
expect(coercedObj).to.eql({
|
||||
createdAt: undefined,
|
||||
updatedAt: undefined,
|
||||
deletedAt: new Date('2021-01-03T00:00:00.000Z'),
|
||||
foo: 'bar',
|
||||
baz: 'qux',
|
||||
});
|
||||
});
|
||||
|
||||
test('non present date keys are not transformed', () => {
|
||||
const obj = {
|
||||
createdAt: '2021-01-03T00:00:00.000Z',
|
||||
foo: 'bar',
|
||||
baz: 'qux',
|
||||
};
|
||||
|
||||
const coercedObj = coerceDates(obj);
|
||||
|
||||
expect(coercedObj).to.eql({
|
||||
createdAt: new Date('2021-01-03T00:00:00.000Z'),
|
||||
foo: 'bar',
|
||||
baz: 'qux',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||