Compare commits

...

53 Commits

Author SHA1 Message Date
Corentin Thomasset
249b3bcfd2 chore(release): update versions (#285)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-13 22:44:37 +02:00
Corentin Thomasset
d7838b5d57 chore(release): remove commitMode from release job configuration (#284) 2025-05-13 20:14:08 +00:00
Corentin Thomasset
f170ddd817 chore(release): use PAT for release PR creation (#283) 2025-05-13 20:05:57 +00:00
Corentin Thomasset
4f53c70854 chore(release): update permissions for release job (#281) 2025-05-13 16:44:47 +00:00
Corentin Thomasset
85fa5c4342 chore(version): added changeset for versioning (#280) 2025-05-13 13:48:55 +02:00
Corentin Thomasset
c5d984a3a0 refactor(docker): build transitive dependencies (#277) 2025-05-08 20:47:57 +02:00
Corentin Thomasset
565bd8d7fd feat(webhooks): added webhook management and logic (#276) 2025-05-08 18:52:11 +02:00
Corentin Thomasset
9b72aa886c feat(cli): added cli documentation (#275) 2025-05-02 23:30:33 +02:00
Corentin Thomasset
7410455093 feat(cli): setup base cli (#274) 2025-05-02 00:20:57 +02:00
riskpoint-per
dd8f194fd0 feat(server): add azure blob storage support (#261)
* add azure blob storage support

* set stream to nodejs.readable

* Update apps/papra-server/src/modules/documents/storage/drivers/az-blob/az-blob.storage-driver.ts

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>

* fix lock file

* bugfixes

* fix lint issues

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2025-04-28 09:34:28 +02:00
Corentin Thomasset
803c39cbc8 chore(packages): added api sdk package (#270) 2025-04-27 22:08:59 +02:00
Joshua Anderson
096331a4ee feat(server): add support for b2 object storage type (#232)
* feat(b2): add support for b2 object storage type

* feat(b2): fix order of tsconfig entries

* feat(b2): fix accidental responseType change

* fix(b2): remove unnecessary try-catches

* refactor(b2): use error factories
2025-04-27 21:35:29 +02:00
Corentin Thomasset
59ba9465f6 docs(features): marked tagging rules and folder ingestion as available features (#268) 2025-04-27 13:57:11 +00:00
Corentin Thomasset
a1056702af feat(docs): fixed broken links with auto check (#267) 2025-04-27 13:20:34 +00:00
Corentin Thomasset
fd44897bca fix(documents): hard delete file in storage driver (#266) 2025-04-27 14:52:05 +02:00
Corentin Thomasset
332d836d11 fix(ingestion-folders): added schema validation coercion in config (#265) 2025-04-27 12:11:05 +00:00
Corentin Thomasset
f613198cbd refactor(storage): replace generic error messages with specific file not found errors (#264) 2025-04-27 13:52:47 +02:00
Corentin Thomasset
80491a5a58 chore(deps): update eslint and and eslint config (#260) 2025-04-27 13:43:17 +02:00
Corentin Thomasset
605e21a410 chore(deps): updated pnpm to 10.9.0 in all package.json files (#258) 2025-04-25 13:15:32 +00:00
Corentin Thomasset
dec589b6ed fix(documents): remove incorrect default tab value (#259) 2025-04-25 13:08:39 +00:00
Corentin Thomasset
c0bd6e2ae4 refactor(i18n): flattened keys directly in yaml (#255)
* refactor(i18n): flattened keys directly in yaml

* Update apps/papra-client/src/locales/fr.yml

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-04-24 21:17:37 +00:00
Corentin Thomasset
6287aaa973 feat(i18n): auto generate i18n type in dev mode (#254) 2025-04-24 20:03:21 +00:00
Corentin Thomasset
cc2edc59b0 feat(server): added api-keys (#248) 2025-04-24 21:13:56 +02:00
Corentin Thomasset
9cba84e38b refactor(client, services): updated API responses to use AsDto and date coercion (#250) 2025-04-22 22:35:48 +02:00
Corentin Thomasset
5fe401778d feat(documents): enhance document page with alert for content extraction details (#249) 2025-04-22 19:26:48 +00:00
Joshua Anderson
38aa1ea7f1 feat(documents): add document searchable content view and edit (#230)
* feat(documents): add tab for viewing content

* feat(documents): allow editing document content

* fix(demo): add content to demo document api

* refactor(documents): fix api structure for updating documents

* refactor(documents): return updated document after change

* refactor(documents): use correct api validation

* refactor(documents): move update to repository

* refactor(documents): limit height of content view

* refactor(documents): use new validation schemas
2025-04-22 21:14:57 +02:00
Corentin Thomasset
ab98c1b255 refactor(validation): mutualized resources ids for route validation (#241) 2025-04-19 16:27:51 +02:00
Corentin Thomasset
265f06f8b7 docs(CONTRIBUTING): updated guidelines for PRs (#240) 2025-04-18 21:47:59 +00:00
Corentin Thomasset
a787b7915c chore(github): added CODEOWNERS file (#239) 2025-04-18 21:40:37 +00:00
Joshua Anderson
0ba6a09923 fix(documents): fix padding when tags input wraps (#227)
* fix(documents): fix padding when tags input wraps

* fix(documents): use padding for y not just b
2025-04-18 22:58:50 +02:00
Joshua Anderson
6880bfd41c feat(documents): wrap txt document preview (#231)
* feat(documents): wrap txt document preview

* Update apps/papra-client/src/modules/documents/components/document-preview.component.tsx

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2025-04-18 22:43:48 +02:00
Corentin Thomasset
21a2c95e56 fix(tags): exclude deleted documents from tags docs count (#234) 2025-04-18 08:43:32 +00:00
Joshua Anderson
19e2083a71 feat(documents): add create tag button on document page (#220)
* feat(documents): fix tag selection input width

* feat(documents): add create tag button on document page

also added i18n just for the tag button, to be used for future localizing
2025-04-17 23:09:47 +02:00
Corentin Thomasset
e6b2d9fb2d refactor(documents): add functions to retrieve document name and extension (#217) 2025-04-16 23:07:54 +02:00
Corentin Thomasset
5140a64c40 chore: release v0.3.0 2025-04-16 20:00:09 +02:00
Corentin Thomasset
9ddb7d545d feat(ingestion): added folder ingestion support (#215) 2025-04-16 00:46:04 +02:00
Corentin Thomasset
2a73551ca4 feat(documents): restore document in trash when same file is uploaded (#213) 2025-04-11 22:08:55 +02:00
Corentin Thomasset
7be56455b0 feat(documents): implement document upload context with status (#212) 2025-04-11 18:41:38 +02:00
Corentin Thomasset
1085bf079c feat(documents): delete documents from the trash (#211) 2025-04-10 20:24:47 +00:00
Corentin Thomasset
b13986e1e3 refactor(config): remove support for wildcard '*' in trustedOrigins (#210) 2025-04-10 18:56:18 +00:00
Corentin Thomasset
d4462f942b refactor(auth, i18n): extracted hard coded text for i18n (#205)
* refactor(auth, i18n): extracted hard coded text for i18n

* Update apps/papra-client/src/modules/auth/pages/register.page.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-04-10 20:55:26 +02:00
Corentin Thomasset
2f2ad90fd3 feat(documents): made file upload limit disableable (#209) 2025-04-10 18:47:22 +00:00
Corentin Thomasset
2bbb68aa17 feat(tagging-rules): added documents auto tagging rules (#200) 2025-04-09 23:51:23 +02:00
Corentin Thomasset
2b2827cdb3 feat(demo): added Discord support link in demo popup (#203)
* feat(demo): added Discord support link in demo popup

* Update apps/papra-client/src/modules/demo/demo.provider.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-04-07 20:34:19 +00:00
Corentin Thomasset
4b4621e4d0 chore(issues): added issue template configuration with Discord community link (#202) 2025-04-06 20:57:23 +00:00
Corentin Thomasset
fd0f79feb0 docs(docker): improved docker and docker-compose instructions for Papra deployment (#201) 2025-04-06 19:25:02 +00:00
Corentin Thomasset
b9c2448805 feat(docs): implement text wrapping for documentation in .env configuration display (#199) 2025-04-06 07:37:50 +00:00
Corentin Thomasset
542225fabc feat(docs): add full .env configuration display in self-hosting guide (#198) 2025-04-06 09:19:07 +02:00
Corentin Thomasset
e4af2653ea chore: release v0.2.1 2025-04-05 19:31:39 +02:00
Corentin Thomasset
4dd15527c0 feat(config): add trustedOrigins configuration (#195) 2025-04-05 19:30:58 +02:00
Corentin Thomasset
ae0f69043d fix(docs): update Discord invitation links (#193) 2025-04-05 13:39:59 +02:00
Corentin Thomasset
79eafdb3ee feat(intake-emails): when deleting intake email in organization, delete in OwlRelay too (#192)
* feat(intake-emails): delete email in owlrelay too

* Update apps/papra-server/src/modules/intake-emails/drivers/random-username/random-username.intake-email-driver.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-04-04 16:23:55 +02:00
Corentin Thomasset
979df5dad8 docs(readme): updated Papra screenshot (#189) 2025-04-03 16:26:01 +00:00
316 changed files with 19377 additions and 1591 deletions

8
.changeset/README.md Normal file
View File

@@ -0,0 +1,8 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)

19
.changeset/config.json Normal file
View File

@@ -0,0 +1,19 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
"changelog": [
"@changesets/changelog-github",
{ "repo": "papra-hq/papra"}
],
"commit": false,
"fixed": [
["@papra/app-client", "@papra/app-server"]
],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": [],
"privatePackages": {
"tag": true,
"version": true
}
}

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View 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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 151 KiB

View File

@@ -38,11 +38,12 @@ jobs:
- name: Run unit test
run: pnpm test
- name: Build the app
run: pnpm build
# Ensure locales types are up to date
# Ensure locales types are up to date, must be run before building the app
- 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)
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)
- name: Build the app
run: pnpm build

View File

@@ -26,7 +26,9 @@ jobs:
cache: 'pnpm'
- name: Install dependencies
run: pnpm i --frozen-lockfile
run: |
pnpm i --frozen-lockfile
pnpm --filter "@papra/app-server^..." build
- name: Run linters
run: pnpm lint

View File

@@ -0,0 +1,41 @@
name: CI - Api SDK
on:
pull_request:
push:
branches:
- main
jobs:
ci-packages-api-sdk:
name: CI - Api SDK
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/api-sdk
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm i
- name: Run linters
run: pnpm lint
- name: Type check
run: pnpm typecheck
# - name: Run unit test
# run: pnpm test
- name: Build the app
run: pnpm build

44
.github/workflows/ci-packages-cli.yaml vendored Normal file
View File

@@ -0,0 +1,44 @@
name: CI - CLI
on:
pull_request:
push:
branches:
- main
jobs:
ci-packages-cli:
name: CI - CLI
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/cli
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm i
- name: Build related packages
run: cd ../api-sdk && pnpm build
- name: Run linters
run: pnpm lint
- name: Type check
run: pnpm typecheck
# - name: Run unit test
# run: pnpm test
- name: Build the app
run: pnpm build

View File

@@ -0,0 +1,41 @@
name: CI - Webhooks
on:
pull_request:
push:
branches:
- main
jobs:
ci-packages-webhooks:
name: CI - Webhooks
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/webhooks
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm i
- name: Run linters
run: pnpm lint
- name: Type check
run: pnpm typecheck
- name: Run unit test
run: pnpm test
- name: Build the app
run: pnpm build

View File

@@ -3,7 +3,7 @@ name: Release new versions
on:
push:
tags:
- 'v*.*.*'
- '@papra/app-server@*'
permissions:
contents: read
@@ -15,13 +15,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Get release version from tag
if: ${{ github.event_name == 'push' }}
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/@papra/app-server@}" >> $GITHUB_ENV
- name: Get release version from input
if: ${{ github.event_name == 'workflow_dispatch' }}
run: echo "RELEASE_VERSION=${{ github.event.inputs.release_version }}" >> $GITHUB_ENV
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4

43
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: Release
on:
push:
branches:
- main
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
release:
name: Release
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
id-token: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm i
- name: Create Release Pull Request
id: changesets
uses: changesets/action@v1
with:
# Note: pnpm install after versioning is necessary to refresh lockfile
version: pnpm run version
publish: pnpm exec changeset publish
commit: "chore(release): update versions"
title: "chore(release): update versions"
env:
GITHUB_TOKEN: ${{ secrets.CHANGESET_GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

1
.gitignore vendored
View File

@@ -37,5 +37,6 @@ cache
*.sqlite
local-documents
ingestion
.cursorrules
*.traineddata

1
CODEOWNERS Normal file
View File

@@ -0,0 +1 @@
* @papra-hq/papra-maintainers

View File

@@ -22,11 +22,11 @@ We follow a **GitHub Flow** model where all PRs should target the `main` branch,
- 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.
- Draft PRs are welcome to get feedback early on your work but only when requested, they'll not be reviewed.
### Branching
@@ -60,7 +60,7 @@ 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.
- When developing in papra-client (using `pnpm dev`), the i18n types definition will automatically update when you touch the [`en.yml`](./apps/papra-client/src/locales/en.yml) file, so no need to run the command above.
## Development Setup

View File

@@ -22,7 +22,7 @@
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://github.com/orgs/papra-hq/projects/2">Roadmap</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://discord.gg/8UPjzsrBNF">Discord</a>
<a href="https://papra.app/discord">Discord</a>
<!-- <span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://dashboard.papra.app">Managed instance</a> -->
</p>
@@ -59,9 +59,9 @@ Papra is currently in **beta**. The core functionality is stable and usable, but
- **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.
- **Tagging Rules**: Automatically tag documents based on custom rules.
- **Folder ingestion**: Automatically import documents from a folder.
- *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.
@@ -92,7 +92,7 @@ This project is licensed under the AGPL-3.0 License - see the [LICENSE](./LICENS
## Community
Join the community on [Papra's Discord server](https://discord.gg/8UPjzsrBNF) to discuss the project, ask questions, or get help.
Join the community on [Papra's Discord server](https://papra.app/discord) to discuss the project, ask questions, or get help.
## Credits

7
apps/docs/CHANGELOG.md Normal file
View File

@@ -0,0 +1,7 @@
# @papra/docs
## 0.3.1
### Patch Changes
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix broken lint and added auto link check

View File

@@ -1,6 +1,7 @@
import { env } from 'node:process';
import starlight from '@astrojs/starlight';
import { defineConfig } from 'astro/config';
import starlightLinksValidator from 'starlight-links-validator';
import starlightThemeRapide from 'starlight-theme-rapide';
import { sidebar } from './src/content/navigation';
import posthogRawScript from './src/scripts/posthog.script.js?raw';
@@ -16,18 +17,18 @@ export default defineConfig({
site: 'https://docs.papra.app',
integrations: [
starlight({
plugins: [starlightThemeRapide()],
plugins: [starlightThemeRapide(), starlightLinksValidator({ exclude: ['http://localhost:1221'] })],
title: 'Papra Docs',
logo: {
dark: './src/assets/logo-dark.svg',
light: './src/assets/logo-light.svg',
alt: 'Papra Logo',
},
social: {
github: 'https://github.com/papra-hq/papra',
blueSky: 'https://bsky.app/profile/papra.app',
discord: 'https://discord.gg/8UPjzsrBNF',
},
social: [
{ href: 'https://github.com/papra-hq/papra', icon: 'github', label: 'GitHub' },
{ href: 'https://bsky.app/profile/papra.app', icon: 'blueSky', label: 'BlueSky' },
{ href: 'https://papra.app/discord', icon: 'discord', label: 'Discord' },
],
expressiveCode: {
themes: ['vitesse-black', 'vitesse-light'],
},

View File

@@ -1,8 +1,9 @@
{
"name": "@papra/docs",
"type": "module",
"version": "0.2.0",
"packageManager": "pnpm@9.15.4",
"version": "0.3.1",
"private": true,
"packageManager": "pnpm@10.9.0",
"description": "Papra documentation website",
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
"license": "AGPL-3.0-or-later",
@@ -17,10 +18,11 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@astrojs/starlight": "^0.31.0",
"astro": "^5.1.5",
"@astrojs/starlight": "^0.34.2",
"astro": "^5.7.10",
"sharp": "^0.32.5",
"starlight-theme-rapide": "^0.3.0",
"starlight-links-validator": "^0.16.0",
"starlight-theme-rapide": "^0.5.0",
"zod-to-json-schema": "^3.24.5"
},
"devDependencies": {

View File

@@ -52,4 +52,34 @@ ${documentation}
`.trim()).join('\n\n---\n\n');
export { mdSections };
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 };

View File

@@ -1,55 +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.
- Single lightweight image
- Only one container to manage
- Available for all platforms (arm64, arm/v7, x86_64)
- Root and Rootless image variants
## Prerequisites
Before you begin, ensure that you have Docker installed on your system. You can download and install Docker from the official [Docker website](https://www.docker.com/get-started).
Verify your installation:
```bash frame="none"
docker --version
```
## Installation
You can run Papra using the following command:
```bash frame="none"
docker run -d --name papra --restart unless-stopped -p 1221:1221 ghcr.io/papra-hq/papra
```
It will automatically download the latest image and start the container. The application will be available at [http://localhost:1221](http://localhost:1221).
## Root and Rootless installation
Papra can be installed in two different ways:
- **Rootless** (recommended): 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` and the default `:latest` tag points to the latest rootless image.
- **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`.
## 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
docker pull corentinth/papra:latest-rootless
docker pull corentinth/papra:latest-root
# Using GitHub Container Registry
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
```

View 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
```

View File

@@ -5,18 +5,35 @@ slug: self-hosting/using-docker-compose
import { Steps } from '@astrojs/starlight/components';
Docker Compose makes it easy to deploy Papra on your server or local machine. Follow these simple steps to get your instance up and running quickly.
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. Prepare your environment
1. Initialize Project Structure
Make sure you have [Docker](https://www.docker.com/get-started) and [Docker Compose](https://docs.docker.com/compose/install/) installed on your system.
Create working directory and persistent storage subdirectories:
Verify your installation:
```bash frame="none"
docker --version
```bash
mkdir -p papra/app-data/{db,documents} && cd papra
```
2. Create Docker Compose file
@@ -26,16 +43,14 @@ Docker Compose makes it easy to deploy Papra on your server or local machine. Fo
```yaml
services:
papra:
image: corentinth/papra:latest-rootless
ports:
- '1221:1221'
volumes:
- papra-data:/app/app-data
container_name: papra
image: ghcr.io/papra-hq/papra:latest
restart: unless-stopped
volumes:
papra-data:
driver: local
ports:
- "1221:1221"
volumes:
- ./app-data:/app/app-data
user: "${UID}:${GID}"
```
3. Start Papra
@@ -43,10 +58,10 @@ Docker Compose makes it easy to deploy Papra on your server or local machine. Fo
From the directory containing your `docker-compose.yml` file, run:
```bash
docker compose up -d
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.
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

View File

@@ -4,10 +4,11 @@ slug: self-hosting/configuration
---
import { mdSections } from '../../../config.data.ts';
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.
@@ -73,10 +74,15 @@ Example of configuration files:
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)} />
Coming soon.

View File

@@ -12,7 +12,7 @@ This guide will show you how to setup a Cloudflare Email worker to receive email
<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.
For a simpler solution, you can use the official [OwlRelay integration](/guides/intake-emails-with-owlrelay) guide.
</Aside>
## Prerequisites
@@ -23,7 +23,7 @@ In order to follow this guide, you need:
- 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.
If you prefer a simpler solution, you can use the official [OwlRelay integration](/guides/intake-emails-with-owlrelay) guide.
## How it works

View File

@@ -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](/self-hosting/configuration), 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.

View File

@@ -0,0 +1,89 @@
---
title: CLI Documentation
description: Learn how to use the Papra CLI to interact with your Papra instance from the command line.
slug: resources/cli
---
The Papra CLI is a command-line interface tool that helps you interact with the Papra platform from your terminal.
## Installation
For the moment, the CLI is only available as an NPM package.
```bash
# using pnpm
pnpm i -g @papra/cli
# or using npm
npm i -g @papra/cli
# or using yarn
yarn add -g @papra/cli
```
The CLI will be installed globally, so you can use it from anywhere in your system with the `papra` command.
## Configuration
Before using the CLI, you need to configure it with your API credentials.
### Initial Setup
To initialize the configuration, run:
```bash
papra config init
```
This command will prompt you for:
- **Instance URL**: Your Papra instance URL (e.g., `https://api.papra.app`)
- **API Key**: Your personal API key (can be created in your User Settings)
### Managing Configuration
You can manage your configuration using the following commands:
- `papra config list`: View your current configuration
- `papra config set api-key`: Set or update your API key
- `papra config set api-url`: Set or update your instance URL
- `papra config set default-org-id`: Set a default organization ID
### Organization IDs
Since Papra supports multiple organizations, you may need to specify the organization ID when importing documents for example. If want, you can set a default organization ID in your configuration.
```bash
papra config set default-org-id <organization-id>
papra documents import <file-path>
# or
papra documents import -o <organization-id> <file-path>
```
## Available Commands
### Importing documents
The `import` command allows you to import a document into your Papra organization.
```bash
papra documents import -o <organization-id> <file-path>
```
## Getting Help
For more information about any command, you can use the `--help` flag:
```bash
papra --help
papra config --help
papra documents --help
```
## About the CLI
The CLI is built using the [citty](https://github.com/unjs/citty) framework and the [Papra TS SDK](https://github.com/papra-hq/papra/tree/main/packages/api-sdk).

View File

@@ -51,9 +51,9 @@ In today's digital world, managing countless important documents efficiently and
- **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.
- **Tagging Rules**: Automatically tag documents based on custom rules.
- **Folder ingestion**: Automatically import documents from a folder.
- *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.

View File

@@ -26,11 +26,19 @@ export const sidebar: StarlightUserConfig['sidebar'] = [
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: 'CLI Documentation',
slug: 'resources/cli',
},
{
label: 'Security Policy',
link: 'https://github.com/papra-hq/papra/blob/main/SECURITY.md',

View File

@@ -0,0 +1,23 @@
# @papra/app-client
## 0.4.0
### Minor Changes
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added webhook management
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added API keys support
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added document searchable content edit
### Patch Changes
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added tag creation button in document page
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Improved tag selector input wrapping
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Properly handle file names without extensions
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Wrap text in document preview
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Excluded deleted documents from doc count

View File

@@ -1,8 +1,9 @@
{
"name": "@papra/papra-app-client",
"name": "@papra/app-client",
"type": "module",
"version": "0.2.0",
"packageManager": "pnpm@9.15.4",
"version": "0.4.0",
"private": true,
"packageManager": "pnpm@10.9.0",
"description": "Papra frontend client",
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
"license": "AGPL-3.0-or-later",
@@ -25,8 +26,7 @@
"test:e2e": "playwright test",
"typecheck": "tsc --noEmit",
"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"
"script:generate-i18n-types": "tsx src/scripts/generate-i18n-types.script.ts"
},
"dependencies": {
"@corentinth/chisels": "^1.0.2",
@@ -64,6 +64,7 @@
"@types/node": "catalog:",
"eslint": "catalog:",
"jsdom": "^25.0.0",
"tinyglobby": "^0.2.13",
"tsx": "^4.19.1",
"typescript": "catalog:",
"unocss": "0.65.0-beta.2",

View File

@@ -1,35 +1,245 @@
auth:
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
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
auth.request-password-reset.title: Reset your password
auth.request-password-reset.description: Enter your email to reset your password.
auth.request-password-reset.requested: If an account exists for this email, we've sent you an email to reset your password.
auth.request-password-reset.back-to-login: Back to login
auth.request-password-reset.form.email.label: Email
auth.request-password-reset.form.email.placeholder: 'Example: ada@papra.app'
auth.request-password-reset.form.email.required: Please enter your email address
auth.request-password-reset.form.email.invalid: This email address is invalid
auth.request-password-reset.form.submit: Request password reset
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
auth.reset-password.title: Reset your password
auth.reset-password.description: Enter your new password to reset your password.
auth.reset-password.reset: Your password has been reset.
auth.reset-password.back-to-login: Back to login
auth.reset-password.form.new-password.label: New password
auth.reset-password.form.new-password.placeholder: 'Example: **********'
auth.reset-password.form.new-password.required: Please enter your new password
auth.reset-password.form.new-password.min-length: Password must be at least {{ minLength }} characters
auth.reset-password.form.new-password.max-length: Password must be less than {{ maxLength }} characters
auth.reset-password.form.submit: Reset password
layout:
menu:
home: Home
documents: Documents
tags: Tags
integrations: Integrations
deleted-documents: Deleted documents
organization-settings: Organization settings
auth.email-provider.open: Open {{ provider }}
demo:
popup:
description: This is a demo environment, all data is save to your browser local storage.
reset: Reset demo data
hide: Hide
auth.login.title: Login to Papra
auth.login.description: Enter your email or use social login to access your Papra account.
auth.login.login-with-provider: Login with {{ provider }}
auth.login.no-account: Don't have an account?
auth.login.register: Register
auth.login.form.email.label: Email
auth.login.form.email.placeholder: 'Example: ada@papra.app'
auth.login.form.email.required: Please enter your email address
auth.login.form.email.invalid: This email address is invalid
auth.login.form.password.label: Password
auth.login.form.password.placeholder: Set a password
auth.login.form.password.required: Please enter your password
auth.login.form.remember-me.label: Remember me
auth.login.form.forgot-password.label: Forgot password?
auth.login.form.submit: Login
auth.register.title: Register to Papra
auth.register.description: Enter your email or use social login to access your Papra account.
auth.register.register-with-email: Register with email
auth.register.register-with-provider: Register with {{ provider }}
auth.register.providers.google: Google
auth.register.providers.github: GitHub
auth.register.have-account: Already have an account?
auth.register.login: Login
auth.register.registration-disabled.title: Registration is disabled
auth.register.registration-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.
auth.register.form.email.label: Email
auth.register.form.email.placeholder: 'Example: ada@papra.app'
auth.register.form.email.required: Please enter your email address
auth.register.form.email.invalid: This email address is invalid
auth.register.form.password.label: Password
auth.register.form.password.placeholder: Set a password
auth.register.form.password.required: Please enter your password
auth.register.form.password.min-length: Password must be at least {{ minLength }} characters
auth.register.form.password.max-length: Password must be less than {{ maxLength }} characters
auth.register.form.name.label: Name
auth.register.form.name.placeholder: 'Example: Ada Lovelace'
auth.register.form.name.required: Please enter your name
auth.register.form.name.max-length: Name must be less than {{ maxLength }} characters
auth.register.form.submit: Register
auth.email-validation-required.title: Verify your email
auth.email-validation-required.description: A verification email has been sent to your email address. Please verify your email address by clicking the link in the email.
auth.legal-links.description: By continuing, you acknowledge that you understand and agree to the {{ terms }} and {{ privacy }}.
auth.legal-links.terms: Terms of Service
auth.legal-links.privacy: Privacy Policy
tags.no-tags.title: No tags yet
tags.no-tags.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.
tags.no-tags.create-tag: Create tag
layout.menu.home: Home
layout.menu.documents: Documents
layout.menu.tags: Tags
layout.menu.tagging-rules: Tagging rules
layout.menu.deleted-documents: Deleted documents
layout.menu.organization-settings: Settings
layout.menu.api-keys: API keys
layout.menu.settings: Settings
layout.menu.account: Account
layout.menu.general-settings: General settings
layout.menu.intake-emails: Intake emails
layout.menu.webhooks: Webhooks
tagging-rules.field.name: document name
tagging-rules.field.content: document content
tagging-rules.operator.equals: equals
tagging-rules.operator.not-equals: not equals
tagging-rules.operator.contains: contains
tagging-rules.operator.not-contains: not contains
tagging-rules.operator.starts-with: starts with
tagging-rules.operator.ends-with: ends with
tagging-rules.list.title: Tagging rules
tagging-rules.list.description: Manage your organization's tagging rules, to automatically tag documents based on conditions you define.
tagging-rules.list.demo-warning: 'Note: As this is a demo environment (with no server), tagging rules will not be applied to newly added documents.'
tagging-rules.list.no-tagging-rules.title: No tagging rules
tagging-rules.list.no-tagging-rules.description: Create a tagging rule to automatically tag your added documents based on conditions you define.
tagging-rules.list.no-tagging-rules.create-tagging-rule: Create tagging rule
tagging-rules.list.card.no-conditions: No conditions
tagging-rules.list.card.one-condition: 1 condition
tagging-rules.list.card.conditions: '{{ count }} conditions'
tagging-rules.list.card.delete: Delete rule
tagging-rules.list.card.edit: Edit rule
tagging-rules.create.title: Create tagging rule
tagging-rules.create.success: Tagging rule created successfully
tagging-rules.create.error: Failed to create tagging rule
tagging-rules.create.submit: Create rule
tagging-rules.form.name.label: Name
tagging-rules.form.name.placeholder: 'Example: Tag invoices'
tagging-rules.form.name.min-length: Please enter a name for the rule
tagging-rules.form.name.max-length: The name must be less than 64 characters
tagging-rules.form.description.label: Description
tagging-rules.form.description.placeholder: "Example: Tag documents with 'invoice' in the name"
tagging-rules.form.description.max-length: The description must be less than 256 characters
tagging-rules.form.conditions.label: Conditions
tagging-rules.form.conditions.description: Define the conditions that must be met for the rule to apply. All conditions must be met for the rule to apply.
tagging-rules.form.conditions.add-condition: Add condition
tagging-rules.form.conditions.no-conditions.title: No conditions
tagging-rules.form.conditions.no-conditions.description: You didn't add any conditions to this rule. This rule will apply its tags to all documents.
tagging-rules.form.conditions.no-conditions.confirm: Apply rule without conditions
tagging-rules.form.conditions.no-conditions.cancel: Cancel
tagging-rules.form.conditions.value.placeholder: 'Example: invoice'
tagging-rules.form.conditions.value.min-length: Please enter a value for the condition
tagging-rules.form.tags.label: Tags
tagging-rules.form.tags.description: Select the tags to apply to the added documents that match the conditions
tagging-rules.form.tags.min-length: At least one tag to apply is required
tagging-rules.form.tags.add-tag: Create tag
tagging-rules.form.submit: Create rule
tagging-rules.update.title: Update tagging rule
tagging-rules.update.error: Failed to update tagging rule
tagging-rules.update.submit: Update rule
tagging-rules.update.cancel: Cancel
demo.popup.description: This is a demo environment, all data is save to your browser local storage.
demo.popup.discord: Join the {{ discordLink }} to get support, propose features or just chat.
demo.popup.discord-link-label: Discord server
demo.popup.reset: Reset demo data
demo.popup.hide: Hide
trash.delete-all.button: Delete all
trash.delete-all.confirm.title: Permanently delete all documents?
trash.delete-all.confirm.description: Are you sure you want to permanently delete all documents from the trash? This action cannot be undone.
trash.delete-all.confirm.label: Delete
trash.delete-all.confirm.cancel: Cancel
trash.delete.button: Delete
trash.delete.confirm.title: Permanently delete document?
trash.delete.confirm.description: Are you sure you want to permanently delete this document from the trash? This action cannot be undone.
trash.delete.confirm.label: Delete
trash.delete.confirm.cancel: Cancel
trash.deleted.success.title: Document deleted
trash.deleted.success.description: The document has been permanently deleted.
import-documents.title.error: '{{ count }} documents failed'
import-documents.title.success: '{{ count }} documents imported'
import-documents.title.pending: '{{ count }} / {{ total }} documents imported'
import-documents.title.none: Import documents
import-documents.no-import-in-progress: No document import in progress
api-errors.document.already_exists: The document already exists
api-errors.document.file_too_big: The document file is too big
api-errors.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.
api-errors.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.
api-errors.default: An error occurred while processing your request.
api-keys.permissions.documents.title: Documents
api-keys.permissions.documents.documents:create: Create documents
api-keys.permissions.documents.documents:read: Read documents
api-keys.permissions.documents.documents:update: Update documents
api-keys.permissions.documents.documents:delete: Delete documents
api-keys.permissions.tags.title: Tags
api-keys.permissions.tags.tags:create: Create tags
api-keys.permissions.tags.tags:read: Read tags
api-keys.permissions.tags.tags:update: Update tags
api-keys.permissions.tags.tags:delete: Delete tags
api-keys.create.title: Create API key
api-keys.create.description: Create a new API key to access the Papra API.
api-keys.create.success: The API key has been created successfully.
api-keys.create.back: Back to API keys
api-keys.create.form.name.label: Name
api-keys.create.form.name.placeholder: 'Example: My API key'
api-keys.create.form.name.required: Please enter a name for the API key
api-keys.create.form.permissions.label: Permissions
api-keys.create.form.permissions.required: Please select at least one permission
api-keys.create.form.submit: Create API key
api-keys.create.created.title: API key created
api-keys.create.created.description: The API key has been created successfully. Save it in a secure location as it will not be displayed again.
api-keys.list.title: API keys
api-keys.list.description: Manage your API keys here.
api-keys.list.create: Create API key
api-keys.list.empty.title: No API keys
api-keys.list.empty.description: Create an API key to access the Papra API.
api-keys.list.card.last-used: Last used
api-keys.list.card.never: Never
api-keys.list.card.created: Created
api-keys.delete.success: The API key has been deleted successfully
api-keys.delete.confirm.title: Delete API key
api-keys.delete.confirm.message: Are you sure you want to delete this API key? This action cannot be undone.
api-keys.delete.confirm.confirm-button: Delete
api-keys.delete.confirm.cancel-button: Cancel
webhooks.list.title: Webhooks
webhooks.list.description: Manage your organization webhooks
webhooks.list.empty.title: No webhooks
webhooks.list.empty.description: Create your first webhook to start receiving events
webhooks.list.create: Create webhook
webhooks.list.card.last-triggered: Last triggered
webhooks.list.card.never: Never
webhooks.list.card.created: Created
webhooks.create.title: Create webhook
webhooks.create.description: Create a new webhook to receive events
webhooks.create.success: Webhook created successfully
webhooks.create.back: Back
webhooks.create.form.submit: Create webhook
webhooks.create.form.name.label: Webhook name
webhooks.create.form.name.placeholder: Enter webhook name
webhooks.create.form.name.required: Name is required
webhooks.create.form.url.label: Webhook URL
webhooks.create.form.url.placeholder: Enter webhook URL
webhooks.create.form.url.required: URL is required
webhooks.create.form.url.invalid: URL is invalid
webhooks.create.form.secret.label: Secret
webhooks.create.form.secret.placeholder: Enter webhook secret
webhooks.create.form.events.label: Events
webhooks.create.form.events.required: At least one event is required
webhooks.update.title: Edit webhook
webhooks.update.description: Update your webhook details
webhooks.update.success: Webhook updated successfully
webhooks.update.submit: Update webhook
webhooks.update.cancel: Cancel
webhooks.update.form.secret.placeholder: Enter new secret
webhooks.update.form.secret.placeholder-redacted: '[Redacted secret]'
webhooks.update.form.rotate-secret.button: Rotate secret
webhooks.delete.success: Webhook deleted successfully
webhooks.delete.confirm.title: Delete webhook
webhooks.delete.confirm.message: Are you sure you want to delete this webhook?
webhooks.delete.confirm.confirm-button: Delete
webhooks.delete.confirm.cancel-button: Cancel
webhooks.events.documents.document:created.description: Document created
webhooks.events.documents.document:deleted.description: Document deleted

View File

@@ -1,35 +1,202 @@
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é
auth.request-password-reset.title: Réinitialiser votre mot de passe
auth.request-password-reset.description: Entrez votre email pour réinitialiser votre mot de passe.
auth.request-password-reset.requested: Si un compte existe pour cet email, nous vous avons envoyé un email pour réinitialiser votre mot de passe.
auth.request-password-reset.back-to-login: Retour à la connexion
auth.request-password-reset.form.email.label: Email
auth.request-password-reset.form.email.placeholder: 'Exemple: ada@papra.app'
auth.request-password-reset.form.email.required: Veuillez entrer votre adresse email
auth.request-password-reset.form.email.invalid: Cette adresse email est invalide
auth.request-password-reset.form.submit: Réinitialiser le mot de passe
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
auth.reset-password.title: Réinitialiser votre mot de passe
auth.reset-password.description: Entrez votre nouveau mot de passe pour réinitialiser votre mot de passe.
auth.reset-password.reset: Votre mot de passe a été réinitialisé.
auth.reset-password.back-to-login: Retour à la connexion
auth.reset-password.form.new-password.label: Nouveau mot de passe
auth.reset-password.form.new-password.placeholder: 'Exemple: **********'
auth.reset-password.form.new-password.required: Veuillez entrer votre nouveau mot de passe
auth.reset-password.form.new-password.min-length: Le mot de passe doit contenir au moins {{ minLength }} caractères
auth.reset-password.form.new-password.max-length: Le mot de passe doit contenir moins de {{ maxLength }} caractères
auth.reset-password.form.submit: Réinitialiser le mot de passe
layout:
menu:
home: Accueil
documents: Documents
tags: Tags
integrations: Intégrations
deleted-documents: Documents supprimés
organization-settings: Paramètres de l'organisation
auth.email-provider.open: Ouvrir {{ provider }}
demo:
popup:
description: Ceci est un environnement de démo, toutes les données sont enregistrées dans le local storage de votre navigateur.
reset: Réinitialiser la démo
hide: Masquer
auth.login.title: Connexion à Papra
auth.login.description: Entrez votre email ou utilisez une connexion sociale pour accéder à votre compte Papra.
auth.login.login-with-provider: Connexion avec {{ provider }}
auth.login.no-account: Je n'ai pas de compte
auth.login.register: S'inscrire
auth.login.form.email.label: Email
auth.login.form.email.placeholder: 'Exemple: ada@papra.app'
auth.login.form.email.required: Veuillez entrer votre adresse email
auth.login.form.email.invalid: Cette adresse email est invalide
auth.login.form.password.label: Mot de passe
auth.login.form.password.placeholder: Définir un mot de passe
auth.login.form.password.required: Veuillez entrer votre mot de passe
auth.login.form.remember-me.label: Se souvenir de moi
auth.login.form.forgot-password.label: Mot de passe oublié ?
auth.login.form.submit: Connexion
auth.register.title: S'inscrire à Papra
auth.register.description: Entrez votre email ou utilisez une connexion sociale pour accéder à votre compte Papra.
auth.register.register-with-email: S'inscrire avec email
auth.register.register-with-provider: S'inscrire avec {{ provider }}
auth.register.providers.google: Google
auth.register.providers.github: GitHub
auth.register.have-account: Je possède déjà un compte
auth.register.login: Connexion
auth.register.registration-disabled.title: Inscription désactivée
auth.register.registration-disabled.description: La création de nouveaux comptes est actuellement désactivée sur cette instance de Papra. Seuls les utilisateurs avec un compte existant peuvent se connecter. Si vous pensez que c'est une erreur, veuillez contacter l'administrateur de cette instance.
auth.register.form.email.label: Email
auth.register.form.email.placeholder: 'Exemple: ada@papra.app'
auth.register.form.email.required: Veuillez entrer votre adresse email
auth.register.form.email.invalid: Cette adresse email est invalide
auth.register.form.password.label: Mot de passe
auth.register.form.password.placeholder: Définir un mot de passe
auth.register.form.password.required: Veuillez entrer votre mot de passe
auth.register.form.password.min-length: Le mot de passe doit contenir au moins {{ minLength }} caractères
auth.register.form.password.max-length: Le mot de passe doit contenir moins de {{ maxLength }} caractères
auth.register.form.name.label: Nom
auth.register.form.name.placeholder: 'Exemple: Ada Lovelace'
auth.register.form.name.required: Veuillez entrer votre nom
auth.register.form.name.max-length: Le nom doit contenir moins de {{ maxLength }} caractères
auth.register.form.submit: S'inscrire
auth.email-validation-required.title: Vérifier votre email
auth.email-validation-required.description: Un email de vérification a été envoyé à votre adresse email. Veuillez vérifier votre adresse email en cliquant sur le lien dans l'email.
auth.legal-links.description: En continuant, vous reconnaissez que vous comprenez et acceptez les {{ terms }} et {{ privacy }}.
auth.legal-links.terms: Conditions d'utilisation
auth.legal-links.privacy: Politique de confidentialité
tags.no-tags.title: Aucun tag
tags.no-tags.description: Cette organisation n'a pas 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.
tags.no-tags.create-tag: Créer un tag
layout.menu.home: Accueil
layout.menu.documents: Documents
layout.menu.tags: Tags
layout.menu.tagging-rules: Règles de catégorisation
layout.menu.deleted-documents: Documents supprimés
layout.menu.organization-settings: Paramètres
layout.menu.api-keys: API keys
layout.menu.settings: Paramètres
layout.menu.account: Compte
tagging-rules.field.name: nom du document
tagging-rules.field.content: contenu du document
tagging-rules.operator.equals: égal à
tagging-rules.operator.not-equals: différent de
tagging-rules.operator.contains: contient
tagging-rules.operator.not-contains: ne contient pas
tagging-rules.operator.starts-with: commence par
tagging-rules.operator.ends-with: finit par
tagging-rules.list.title: Règles de catégorisation
tagging-rules.list.description: Gérez vos règles de catégorisation, pour catégoriser automatiquement les documents en fonction de conditions que vous définissez.
tagging-rules.list.demo-warning: 'Note: Cette instance est une démo, les règles de catégorisation ne seront pas appliquées aux documents ajoutés.'
tagging-rules.list.no-tagging-rules.title: Aucune règle de catégorisation
tagging-rules.list.no-tagging-rules.description: Créez une règle de catégorisation pour catégoriser automatiquement vos documents en fonction de conditions que vous définissez.
tagging-rules.list.no-tagging-rules.create-tagging-rule: Créer une règle de catégorisation
tagging-rules.list.card.no-conditions: Aucune condition
tagging-rules.list.card.one-condition: 1 condition
tagging-rules.list.card.conditions: '{{ count }} conditions'
tagging-rules.list.card.delete: Supprimer la règle
tagging-rules.list.card.edit: Modifier la règle
tagging-rules.create.title: Créer une règle de catégorisation
tagging-rules.create.success: Règle de catégorisation créée avec succès
tagging-rules.create.error: Échec de la création de la règle de catégorisation
tagging-rules.create.submit: Créer la règle
tagging-rules.form.name.label: Nom
tagging-rules.form.name.placeholder: 'Exemple: Catégoriser les factures'
tagging-rules.form.name.min-length: Veuillez entrer un nom pour la règle
tagging-rules.form.name.max-length: Le nom doit contenir moins de 64 caractères
tagging-rules.form.description.label: Description
tagging-rules.form.description.placeholder: "Exemple: Catégoriser les documents avec 'facture' dans le nom"
tagging-rules.form.description.max-length: La description doit contenir moins de 256 caractères
tagging-rules.form.conditions.label: Conditions
tagging-rules.form.conditions.description: Définissez les conditions que doivent remplir la règle pour qu'elle s'applique. Toutes les conditions doivent être remplies pour que la règle s'applique.
tagging-rules.form.conditions.add-condition: Ajouter une condition
tagging-rules.form.conditions.no-conditions.title: Aucune condition
tagging-rules.form.conditions.no-conditions.description: Vous n'avez pas ajouté de conditions à cette règle. Cette règle appliquera ses tags à tous les documents.
tagging-rules.form.conditions.no-conditions.confirm: Appliquer la règle sans conditions
tagging-rules.form.conditions.no-conditions.cancel: Annuler
tagging-rules.form.conditions.value.placeholder: 'Exemple: facture'
tagging-rules.form.conditions.value.min-length: Veuillez entrer une valeur pour la condition
tagging-rules.form.tags.label: Tags
tagging-rules.form.tags.description: Sélectionnez les tags à appliquer aux documents ajoutés qui correspondent aux conditions
tagging-rules.form.tags.min-length: Au moins un tag à appliquer est requis
tagging-rules.form.tags.add-tag: Créer un tag
tagging-rules.form.submit: Créer la règle
tagging-rules.update.title: Mettre à jour la règle de catégorisation
tagging-rules.update.error: Échec de la mise à jour de la règle de catégorisation
tagging-rules.update.submit: Mettre à jour la règle
tagging-rules.update.cancel: Annuler
demo.popup.description: Cette instance est une démo, toutes les données sont sauvegardées dans le stockage local de votre navigateur.
demo.popup.discord: Rejoignez le {{ discordLink }} pour obtenir de l'aide, proposer des fonctionnalités ou simplement discuter.
demo.popup.discord-link-label: Serveur Discord
demo.popup.reset: Réinitialiser les données de la démo
demo.popup.hide: Masquer
trash.delete-all.button: Supprimer tous les documents
trash.delete-all.confirm.title: Supprimer définitivement tous les documents ?
trash.delete-all.confirm.description: Êtes-vous sûr de vouloir supprimer définitivement tous les documents de la corbeille ? Cette action est irréversible.
trash.delete-all.confirm.label: Supprimer
trash.delete-all.confirm.cancel: Annuler
trash.delete.button: Supprimer
trash.delete.confirm.title: Supprimer définitivement le document ?
trash.delete.confirm.description: Êtes-vous sûr de vouloir supprimer définitivement ce document de la corbeille ? Cette action est irréversible.
trash.delete.confirm.label: Supprimer
trash.delete.confirm.cancel: Annuler
trash.deleted.success.title: Document supprimé
trash.deleted.success.description: Le document a été supprimé définitivement.
import-documents.title.error: '{{ count }} documents ont échoué'
import-documents.title.success: '{{ count }} documents ont été importés'
import-documents.title.pending: '{{ count }} / {{ total }} documents importés'
import-documents.title.none: Importer des documents
import-documents.no-import-in-progress: Aucune importation de documents en cours
api-errors.document.already_exists: Le document existe déjà
api-errors.document.file_too_big: Le fichier du document est trop grand
api-errors.intake_email.limit_reached: Le nombre maximum d'emails de réception pour cette organisation a été atteint. Veuillez mettre à niveau votre plan pour créer plus d'emails de réception.
api-errors.user.max_organization_count_reached: Vous avez atteint le nombre maximum d'organisations que vous pouvez créer, si vous avez besoin de créer plus, veuillez contacter le support.
api-errors.default: Une erreur est survenue lors du traitement de votre requête.
api-keys.permissions.documents.title: Documents
api-keys.permissions.documents.documents:create: Créer des documents
api-keys.permissions.documents.documents:read: Lire des documents
api-keys.permissions.documents.documents:update: Mettre à jour des documents
api-keys.permissions.documents.documents:delete: Supprimer des documents
api-keys.permissions.tags.title: Tags
api-keys.permissions.tags.tags:create: Créer des tags
api-keys.permissions.tags.tags:read: Lire des tags
api-keys.permissions.tags.tags:update: Mettre à jour des tags
api-keys.permissions.tags.tags:delete: Supprimer des tags
api-keys.create.title: Créer une clé API
api-keys.create.description: Créer une nouvelle clé API pour accéder à l'API de Papra.
api-keys.create.success: La clé API a été créée avec succès.
api-keys.create.back: Retour aux clés API
api-keys.create.form.name.label: Nom
api-keys.create.form.name.placeholder: 'Exemple: Ma clé API'
api-keys.create.form.name.required: Veuillez entrer un nom pour la clé API
api-keys.create.form.permissions.label: Permissions
api-keys.create.form.permissions.required: Veuillez sélectionner au moins une permission
api-keys.create.form.submit: Créer la clé API
api-keys.create.created.title: Clé API créée
api-keys.create.created.description: La clé API a été créée avec succès. Enregistrez-la dans un endroit sûr car elle ne sera plus affichée.
api-keys.list.title: Clés API
api-keys.list.description: Gérez vos clés API ici.
api-keys.list.create: Créer une clé API
api-keys.list.empty.title: Aucune clé API
api-keys.list.empty.description: Créez une clé API pour accéder à l'API de Papra.
api-keys.list.card.last-used: Dernière utilisation
api-keys.list.card.never: Jamais
api-keys.list.card.created: Créée
api-keys.delete.success: La clé API a été supprimée avec succès
api-keys.delete.confirm.title: Supprimer la clé API
api-keys.delete.confirm.message: Êtes-vous sûr de vouloir supprimer cette clé API ? Cette action est irréversible.
api-keys.delete.confirm.confirm-button: Supprimer
api-keys.delete.confirm.cancel-button: Annuler

View File

@@ -0,0 +1,28 @@
// export const API_KEY_PERMISSIONS = {
// documents: {
// create: 'documents:create',
// },
// } as const;
export const API_KEY_PERMISSIONS = [
{
section: 'documents',
permissions: [
'documents:create',
'documents:read',
'documents:update',
'documents:delete',
],
},
{
section: 'tags',
permissions: [
'tags:create',
'tags:read',
'tags:update',
'tags:delete',
],
},
] as const;
export const API_KEY_PERMISSIONS_LIST = API_KEY_PERMISSIONS.flatMap(permission => permission.permissions);

View File

@@ -0,0 +1,56 @@
import type { ApiKey } from './api-keys.types';
import { apiClient } from '../shared/http/api-client';
import { coerceDates } from '../shared/http/http-client.models';
export async function createApiKey({
name,
permissions,
organizationIds,
allOrganizations,
expiresAt,
}: {
name: string;
permissions: string[];
organizationIds: string[];
allOrganizations: boolean;
expiresAt?: Date;
}) {
const { apiKey, token } = await apiClient<{
apiKey: ApiKey;
token: string;
}>({
path: '/api/api-keys',
method: 'POST',
body: {
name,
permissions,
organizationIds,
allOrganizations,
expiresAt,
},
});
return {
apiKey: coerceDates(apiKey),
token,
};
}
export async function fetchApiKeys() {
const { apiKeys } = await apiClient<{
apiKeys: ApiKey[];
}>({
path: '/api/api-keys',
});
return {
apiKeys: apiKeys.map(coerceDates),
};
}
export async function deleteApiKey({ apiKeyId }: { apiKeyId: string }) {
await apiClient({
path: `/api/api-keys/${apiKeyId}`,
method: 'DELETE',
});
}

View File

@@ -0,0 +1,12 @@
export type ApiKey = {
id: string;
name: string;
permissions: string[];
organizationIds: string[];
allOrganizations: boolean;
expiresAt?: Date;
prefix: string;
lastUsedAt?: Date;
createdAt: Date;
updatedAt: Date;
};

View File

@@ -0,0 +1,75 @@
import type { LocaleKeys } from '@/modules/i18n/locales.types';
import type { Component } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { Checkbox, CheckboxControl, CheckboxLabel } from '@/modules/ui/components/checkbox';
import { createSignal, For } from 'solid-js';
import { API_KEY_PERMISSIONS } from '../api-keys.constants';
export const ApiKeyPermissionsPicker: Component<{ permissions: string[]; onChange: (permissions: string[]) => void }> = (props) => {
const [permissions, setPermissions] = createSignal<string[]>(props.permissions);
const { t } = useI18n();
const getPermissionsSections = () => {
return API_KEY_PERMISSIONS.map(section => ({
...section,
title: t(`api-keys.permissions.${section.section}.title`),
permissions: section.permissions.map((permission) => {
const [prefix, suffix] = permission.split(':');
return {
name: permission,
prefix,
suffix,
description: t(`api-keys.permissions.${section.section}.${permission}` as LocaleKeys),
};
}),
}));
};
const isPermissionSelected = (permission: string) => {
return permissions().includes(permission);
};
const togglePermission = (permission: string) => {
setPermissions((prev) => {
if (prev.includes(permission)) {
return prev.filter(p => p !== permission);
}
return [...prev, permission];
});
props.onChange(permissions());
};
return (
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<For each={getPermissionsSections()}>
{section => (
<div>
<p class="text-muted-foreground text-xs">{section.title}</p>
<div class="pl-4 flex flex-col gap-4 mt-4">
<For each={section.permissions}>
{permission => (
<Checkbox
class="flex items-center gap-2"
checked={isPermissionSelected(permission.name)}
onChange={() => togglePermission(permission.name)}
>
<CheckboxControl />
<div class="flex flex-col gap-1">
<CheckboxLabel class="text-sm leading-none">
{permission.description}
</CheckboxLabel>
</div>
</Checkbox>
)}
</For>
</div>
</div>
)}
</For>
</div>
);
};

View File

@@ -0,0 +1,140 @@
import type { Component } from 'solid-js';
import type { ApiKey } from '../api-keys.types';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { useConfirmModal } from '@/modules/shared/confirm';
import { queryClient } from '@/modules/shared/query/query-client';
import { Button } from '@/modules/ui/components/button';
import { EmptyState } from '@/modules/ui/components/empty';
import { createToast } from '@/modules/ui/components/sonner';
import { A } from '@solidjs/router';
import { createMutation, createQuery } from '@tanstack/solid-query';
import { format } from 'date-fns';
import { For, Match, Show, Suspense, Switch } from 'solid-js';
import { deleteApiKey, fetchApiKeys } from '../api-keys.services';
export const ApiKeyCard: Component<{ apiKey: ApiKey }> = ({ apiKey }) => {
const { t } = useI18n();
const { confirm } = useConfirmModal();
const deleteApiKeyMutation = createMutation(() => ({
mutationFn: deleteApiKey,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['api-keys'] });
createToast({
message: t('api-keys.delete.success'),
});
},
}));
const handleDelete = async () => {
const confirmed = await confirm({
title: t('api-keys.delete.confirm.title'),
message: t('api-keys.delete.confirm.message'),
confirmButton: {
text: t('api-keys.delete.confirm.confirm-button'),
variant: 'destructive',
},
cancelButton: {
text: t('api-keys.delete.confirm.cancel-button'),
},
});
if (!confirmed) {
return;
}
deleteApiKeyMutation.mutate({ apiKeyId: apiKey.id });
};
return (
<div class="bg-card rounded-lg border p-4 flex items-center gap-4">
<div class="rounded-lg bg-muted p-2">
<div class="i-tabler-key text-muted-foreground size-5 text-primary" />
</div>
<div class="flex-1">
<h2 class="text-sm font-medium leading-tight">{apiKey.name}</h2>
<p class="text-muted-foreground text-xs font-mono">{`${apiKey.prefix}...`}</p>
</div>
<div>
<p class="text-muted-foreground text-xs">
{t('api-keys.list.card.last-used')}
{' '}
{apiKey.lastUsedAt ? format(apiKey.lastUsedAt, 'MMM d, yyyy') : t('api-keys.list.card.never')}
</p>
<p class="text-muted-foreground text-xs">
{t('api-keys.list.card.created')}
{' '}
{format(apiKey.createdAt, 'MMM d, yyyy')}
</p>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
isLoading={deleteApiKeyMutation.isPending}
onClick={handleDelete}
>
<div class="i-tabler-trash text-muted-foreground size-4" />
</Button>
</div>
</div>
);
};
export const ApiKeysPage: Component = () => {
const { t } = useI18n();
const query = createQuery(() => ({
queryKey: ['api-keys'],
queryFn: () => fetchApiKeys(),
}));
return (
<div class="p-6 mt-12 pb-32 mx-auto max-w-xl w-full">
<div class="border-b pb-4 flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold mb-1">{t('api-keys.list.title')}</h1>
<p class="text-muted-foreground">{t('api-keys.list.description')}</p>
</div>
<div>
<Show when={query.data?.apiKeys?.length}>
<Button as={A} href="/api-keys/create" class="gap-2">
<div class="i-tabler-plus size-4" />
{t('api-keys.list.create')}
</Button>
</Show>
</div>
</div>
<Suspense>
<Switch>
<Match when={query.data?.apiKeys?.length === 0}>
<EmptyState
title={t('api-keys.list.empty.title')}
description={t('api-keys.list.empty.description')}
icon="i-tabler-key"
cta={(
<Button as={A} href="/api-keys/create" class="gap-2">
<div class="i-tabler-plus size-4" />
{t('api-keys.list.create')}
</Button>
)}
/>
</Match>
<Match when={query.data?.apiKeys?.length}>
<div class="mt-6 flex flex-col gap-2">
<For each={query.data?.apiKeys}>
{apiKey => (
<ApiKeyCard apiKey={apiKey} />
)}
</For>
</div>
</Match>
</Switch>
</Suspense>
</div>
);
};

View File

@@ -0,0 +1,118 @@
import type { Component } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createForm } from '@/modules/shared/form/form';
import { queryClient } from '@/modules/shared/query/query-client';
import { CopyButton } from '@/modules/shared/utils/copy';
import { Button } from '@/modules/ui/components/button';
import { createToast } from '@/modules/ui/components/sonner';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { setValue } from '@modular-forms/solid';
import { A } from '@solidjs/router';
import { createSignal, Show } from 'solid-js';
import * as v from 'valibot';
import { API_KEY_PERMISSIONS_LIST } from '../api-keys.constants';
import { createApiKey } from '../api-keys.services';
import { ApiKeyPermissionsPicker } from '../components/api-key-permissions-picker.component';
export const CreateApiKeyPage: Component = () => {
const { t } = useI18n();
const [getToken, setToken] = createSignal<string | null>(null);
const { form, Form, Field } = createForm({
onSubmit: async ({ name, permissions }) => {
const { token } = await createApiKey({
name,
permissions,
organizationIds: [],
allOrganizations: false,
});
await queryClient.invalidateQueries({ queryKey: ['api-keys'] });
setToken(token);
createToast({
type: 'success',
message: t('api-keys.create.success'),
});
},
schema: v.object({
name: v.pipe(
v.string(),
v.nonEmpty(t('api-keys.create.form.name.required')),
),
permissions: v.pipe(
v.array(v.picklist(API_KEY_PERMISSIONS_LIST as string[])),
v.nonEmpty(t('api-keys.create.form.permissions.required')),
),
}),
initialValues: {
name: '',
permissions: API_KEY_PERMISSIONS_LIST,
},
});
return (
<div class="p-6 mt-12 pb-32 mx-auto max-w-xl w-full">
<div class="border-b pb-4 mb-6">
<h1 class="text-2xl font-bold">{t('api-keys.create.title')}</h1>
<p class="text-sm text-muted-foreground">{t('api-keys.create.description')}</p>
</div>
<Show when={getToken()}>
<div class="bg-card border p-6 rounded-md mt-6">
<h2 class="text-lg font-semibold mb-2">{t('api-keys.create.created.title')}</h2>
<p class="text-sm text-muted-foreground mb-4">{t('api-keys.create.created.description')}</p>
<TextFieldRoot class="flex items-center gap-2 space-y-0">
<TextField type="text" placeholder={t('api-keys.create.form.name.placeholder')} value={getToken() ?? ''} />
<CopyButton text={getToken() ?? ''} />
</TextFieldRoot>
</div>
<div class="flex justify-end mt-6">
<Button type="button" variant="secondary" as={A} href="/api-keys">
{t('api-keys.create.back')}
</Button>
</div>
</Show>
<Show when={!getToken()}>
<Form>
<Field name="name">
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col mb-6">
<TextFieldLabel for="name">{t('api-keys.create.form.name.label')}</TextFieldLabel>
<TextField type="text" id="name" placeholder={t('api-keys.create.form.name.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="permissions" type="string[]">
{field => (
<div>
<p class="text-sm font-bold">{t('api-keys.create.form.permissions.label')}</p>
<div class="p-6 pb-8 border rounded-md mt-2">
<ApiKeyPermissionsPicker permissions={field.value ?? []} onChange={permissions => setValue(form, 'permissions', permissions)} />
</div>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</div>
)}
</Field>
<div class="flex justify-end mt-6">
<Button type="submit" isLoading={form.submitting}>
{t('api-keys.create.form.submit')}
</Button>
</div>
</Form>
</Show>
</div>
);
};

View File

@@ -1,6 +1,8 @@
import type { Component, ComponentProps } from 'solid-js';
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';
import { splitProps } from 'solid-js';
const providers = [
{
@@ -246,6 +248,7 @@ export function getEmailProvider({ email }: { email?: string }) {
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 });
@@ -256,9 +259,7 @@ export const OpenEmailProvider: Component<{ email?: string } & ComponentProps<ty
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" />
Open
{' '}
{provider.name}
{t('auth.email-provider.open', { provider: provider.name })}
</Button>
);
};

View File

@@ -1,5 +1,6 @@
import type { Component } from 'solid-js';
import { Navigate } from '@solidjs/router';
import { type Component, Suspense } from 'solid-js';
import { Suspense } from 'solid-js';
import { Dynamic } from 'solid-js/web';
import { match } from 'ts-pattern';
import { useSession } from '../auth.services';

View File

@@ -1,3 +1,4 @@
import type { Component } from 'solid-js';
import type { SsoProviderKey } from '../auth.types';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
@@ -7,7 +8,7 @@ import { Checkbox, CheckboxControl, CheckboxLabel } from '@/modules/ui/component
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 { createSignal, For, Show } from 'solid-js';
import * as v from 'valibot';
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
import { getEnabledSsoProviderConfigs, isEmailVerificationRequiredError } from '../auth.models';
@@ -18,6 +19,7 @@ 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, rememberMe }) => {
@@ -35,12 +37,12 @@ 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(),
}),
@@ -54,8 +56,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>
)}
@@ -64,9 +66,9 @@ export const EmailLoginForm: Component = () => {
<Field name="password">
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col gap-1 mb-4">
<TextFieldLabel for="password">Password</TextFieldLabel>
<TextFieldLabel for="password">{t('auth.login.form.password.label')}</TextFieldLabel>
<TextField type="password" id="password" placeholder="Your password" {...inputProps} value={field.value} aria-invalid={Boolean(field.error)} />
<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>
)}
@@ -78,18 +80,18 @@ export const EmailLoginForm: Component = () => {
<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">
Remember me
{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">
Forgot password?
{t('auth.login.form.forgot-password.label')}
</Button>
</div>
<Button type="submit" class="w-full">Login</Button>
<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>

View File

@@ -1,11 +1,13 @@
import type { Component } from 'solid-js';
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 { createSignal, For, Show } from 'solid-js';
import * as v from 'valibot';
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
import { getEnabledSsoProviderConfigs } from '../auth.models';
@@ -16,7 +18,7 @@ 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({
@@ -41,19 +43,19 @@ export const EmailRegisterForm: Component = () => {
email: v.pipe(
v.string(),
v.trim(),
v.nonEmpty('Please enter an email address'),
v.email('This is not a valid email address'),
v.nonEmpty(t('auth.register.form.email.required')),
v.email(t('auth.register.form.email.invalid')),
),
password: v.pipe(
v.string('Password is required'),
v.nonEmpty('Please enter a password'),
v.minLength(8, 'Password must be at least 8 characters'),
v.maxLength(128, 'Password must be at most 128 characters'),
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('Name is required'),
v.nonEmpty('Please enter a name'),
v.maxLength(64, 'Name must be at most 64 characters'),
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 })),
),
}),
});
@@ -63,8 +65,8 @@ export const EmailRegisterForm: 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.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>
)}
@@ -73,8 +75,8 @@ export const EmailRegisterForm: Component = () => {
<Field name="name">
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col gap-1 mb-4">
<TextFieldLabel for="name">Your full name</TextFieldLabel>
<TextField type="text" id="name" placeholder="Eg. Ada Lovelace" {...inputProps} value={field.value} aria-invalid={Boolean(field.error)} />
<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>
)}
@@ -83,15 +85,15 @@ export const EmailRegisterForm: Component = () => {
<Field name="password">
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col gap-1 mb-4">
<TextFieldLabel for="password">Password</TextFieldLabel>
<TextFieldLabel for="password">{t('auth.register.form.password.label')}</TextFieldLabel>
<TextField type="password" id="password" placeholder="Your password" {...inputProps} value={field.value} aria-invalid={Boolean(field.error)} />
<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">Register</Button>
<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>
@@ -101,6 +103,7 @@ export const EmailRegisterForm: Component = () => {
export const RegisterPage: Component = () => {
const { config } = useConfig();
const { t } = useI18n();
if (!config.auth.isRegistrationEnabled) {
return (
@@ -108,17 +111,17 @@ export const RegisterPage: Component = () => {
<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">
Registration is disabled
{t('auth.register.registration-disabled.title')}
</h1>
<p class="text-muted-foreground mt-1 mb-4">
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.
{t('auth.register.registration-disabled.description')}
</p>
<p class="text-muted-foreground mt-4">
Already have an account?
{t('auth.register.have-account')}
{' '}
<Button variant="link" as={A} class="inline px-0" href="/login">
Login
{t('auth.register.login')}
</Button>
</p>
@@ -141,10 +144,10 @@ export const RegisterPage: Component = () => {
<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">
Register to Papra
{t('auth.register.title')}
</h1>
<p class="text-muted-foreground mt-1 mb-4">
Enter your email or use social login to create your Papra account.
{t('auth.register.description')}
</p>
{getShowEmailRegister() || !getHasSsoProviders()
@@ -152,7 +155,7 @@ export const RegisterPage: Component = () => {
: (
<Button onClick={() => setShowEmailRegister(true)} class="w-full">
<div class="i-tabler-mail mr-2 size-4.5" />
Register with email
{t('auth.register.register-with-email')}
</Button>
)}
@@ -162,17 +165,22 @@ export const RegisterPage: Component = () => {
<div class="flex flex-col gap-2">
<For each={getEnabledSsoProviderConfigs({ config })}>
{provider => (
<SsoProviderButton name={provider.name} icon={provider.icon} onClick={() => registerWithProvider(provider)} label={`Register with ${provider.name}`} />
<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">
Already have an account?
{t('auth.register.have-account')}
{' '}
<Button variant="link" as={A} class="inline px-0" href="/login">
Login
{t('auth.register.login')}
</Button>
</p>

View File

@@ -1,24 +1,28 @@
import type { Component } from 'solid-js';
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 { 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('Please enter your email address'),
v.email('This is not a valid email address'),
v.nonEmpty(t('auth.request-password-reset.form.email.required')),
v.email(t('auth.request-password-reset.form.email.invalid')),
),
}),
});
@@ -28,15 +32,15 @@ export const ResetPasswordForm: Component<{ onSubmit: (args: { email: string })
<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.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">
Request password reset
{t('auth.request-password-reset.form.submit')}
</Button>
<div class="text-red-500 text-sm mt-2">{form.response.message}</div>
@@ -49,6 +53,7 @@ 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();
@@ -80,14 +85,14 @@ export const RequestPasswordResetPage: Component = () => {
<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">
Reset your password
{t('auth.request-password-reset.title')}
</h1>
{getHasPasswordResetBeenRequested()
? (
<>
<div class="text-muted-foreground mt-1 mb-4">
If an account exists for this email, we've sent you an email to reset your password.
{t('auth.request-password-reset.requested')}
</div>
<OpenEmailProvider email={getEmail()} variant="secondary" class="w-full mb-4" />
@@ -96,7 +101,7 @@ export const RequestPasswordResetPage: Component = () => {
: (
<>
<p class="text-muted-foreground mt-1 mb-4">
Enter your email to reset your password.
{t('auth.request-password-reset.description')}
</p>
<ResetPasswordForm onSubmit={onPasswordResetRequested} />
@@ -105,7 +110,7 @@ export const RequestPasswordResetPage: Component = () => {
<Button as={A} href="/login" class="w-full" variant={getHasPasswordResetBeenRequested() ? 'default' : 'ghost'}>
<div class="i-tabler-arrow-left mr-2 size-4" />
Back to login
{t('auth.request-password-reset.back-to-login')}
</Button>
</div>
</div>

View File

@@ -1,23 +1,26 @@
import type { Component } from 'solid-js';
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 { createSignal, 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('Please enter your new password'),
v.minLength(8, 'Password must be at least 8 characters long'),
v.maxLength(128, 'Password must be at most 128 characters long'),
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 })),
),
}),
});
@@ -27,15 +30,15 @@ export const ResetPasswordForm: Component<{ onSubmit: (args: { newPassword: stri
<Field name="newPassword">
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col gap-1 mb-4">
<TextFieldLabel for="newPassword">New password</TextFieldLabel>
<TextField type="password" id="newPassword" placeholder="Your new password" {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
<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">
Reset password
{t('auth.reset-password.form.submit')}
</Button>
<div class="text-red-500 text-sm mt-2">{form.response.message}</div>
@@ -49,6 +52,8 @@ export const ResetPasswordPage: Component = () => {
const [searchParams] = useSearchParams();
const token = searchParams.token;
const { t } = useI18n();
if (!token || typeof token !== 'string') {
return <Navigate href="/login" />;
}
@@ -80,18 +85,18 @@ export const ResetPasswordPage: Component = () => {
<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">
Reset your password
{t('auth.reset-password.title')}
</h1>
{getHasPasswordBeenReset()
? (
<>
<div class="text-muted-foreground mt-1 mb-4">
Your password has been reset.
{t('auth.reset-password.reset')}
</div>
<Button as={A} href="/login" class="w-full">
Go to login
{t('auth.reset-password.back-to-login')}
<div class="i-tabler-login-2 ml-2 size-4" />
</Button>
</>
@@ -99,7 +104,7 @@ export const ResetPasswordPage: Component = () => {
: (
<>
<p class="text-muted-foreground mt-1 mb-4">
Enter your new password.
{t('auth.reset-password.description')}
</p>
<ResetPasswordForm onSubmit={onPasswordResetRequested} />

View File

@@ -1,11 +1,12 @@
import type { ParentComponent } from 'solid-js';
import type { Config, RuntimePublicConfig } from './config';
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 { buildTimeConfig } from './config';
import { fetchPublicConfig } from './config.services';
const ConfigContext = createContext<{

View File

@@ -1,10 +1,29 @@
import type { ApiKey } from '../api-keys/api-keys.types';
import { get } from 'lodash-es';
import { FetchError } from 'ofetch';
import { createRouter } from 'radix3';
import { defineHandler } from './demo-api-mock.models';
import { documentFileStorage, documentStorage, organizationStorage, tagDocumentStorage, tagStorage } from './demo.storage';
import {
apiKeyStorage,
documentFileStorage,
documentStorage,
organizationStorage,
tagDocumentStorage,
taggingRuleStorage,
tagStorage,
} from './demo.storage';
import { findMany, getValues } from './demo.storage.models';
const corpus = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
function randomString({ length = 10 }: { length?: number } = {}) {
return Array.from({ length }, () => corpus[Math.floor(Math.random() * corpus.length)]).join('');
}
function createId({ prefix }: { prefix: string }) {
return `${prefix}_${randomString({ length: 24 })}`;
}
function assert(condition: unknown, { message = 'Error', status }: { message?: string; status?: number } = {}): asserts condition {
if (!condition) {
throw Object.assign(new FetchError(message), { status });
@@ -114,12 +133,13 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
assert(file, { status: 400 });
const document = {
id: `doc_${Math.random().toString(36).slice(2)}`,
id: createId({ prefix: 'doc' }),
organizationId,
name: file.name,
originalName: file.name,
originalSize: file.size,
mimeType: file.type,
content: '',
createdAt: new Date(),
updatedAt: new Date(),
tags: [],
@@ -130,6 +150,9 @@ 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 };
},
}),
@@ -307,7 +330,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
assert(organization, { status: 403 });
const tag = {
id: `tag_${Math.random().toString(36).slice(2)}`,
id: createId({ prefix: 'tag' }),
organizationId,
name: get(body, 'name'),
color: get(body, 'color'),
@@ -369,7 +392,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
assert(tagId, { status: 400 });
const tagDocument = {
id: `tagDoc_${Math.random().toString(36).slice(2)}`,
id: createId({ prefix: 'tagDoc' }),
tagId,
documentId,
createdAt: new Date(),
@@ -408,7 +431,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
method: 'POST',
handler: async ({ body }) => {
const organization = {
id: `org_${Math.random().toString(36).slice(2)}`,
id: createId({ prefix: 'org' }),
name: get(body, 'name'),
createdAt: new Date(),
updatedAt: new Date(),
@@ -457,6 +480,132 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
},
}),
...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: createId({ prefix: 'tr' }),
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);
},
}),
...defineHandler({
path: '/api/api-keys',
method: 'GET',
handler: async () => {
const apiKeys = await getValues(apiKeyStorage);
return { apiKeys };
},
}),
...defineHandler({
path: '/api/api-keys',
method: 'POST',
handler: async ({ body }) => {
const token = `ppapi_${randomString({ length: 64 })}`;
const apiKey = {
id: createId({ prefix: 'apiKey' }),
name: get(body, 'name'),
permissions: get(body, 'permissions'),
organizationIds: get(body, 'organizationIds'),
allOrganizations: get(body, 'allOrganizations'),
expiresAt: get(body, 'expiresAt'),
createdAt: new Date(),
updatedAt: new Date(),
prefix: token.slice(0, 11),
} as ApiKey;
await apiKeyStorage.setItem(apiKey.id, apiKey);
return { apiKey, token };
},
}),
...defineHandler({
path: '/api/api-keys/:apiKeyId',
method: 'DELETE',
handler: async ({ params: { apiKeyId } }) => {
await apiKeyStorage.removeItem(apiKeyId);
},
}),
};
export const router = createRouter({ routes: inMemoryApiMock, strictTrailingSlash: false });

View File

@@ -4,7 +4,7 @@ import type { HttpClientOptions, ResponseType } from '../shared/http/http-client
import { joinUrlPaths } from '@corentinth/chisels';
import { router } from './demo-api-mock';
export async function demoHttpClient<A, R extends ResponseType = 'json'>(options: HttpClientOptions<R>): Promise<MappedResponseType< R, A>> {
export async function demoHttpClient<A, R extends ResponseType = 'json'>(options: HttpClientOptions<R>): Promise<MappedResponseType<R, A>> {
const path = `/${joinUrlPaths(options.method ?? 'GET', options.url)}`;
const matchedRoute = router.lookup(path);

View File

@@ -1,5 +1,6 @@
import { useNavigate } from '@solidjs/router';
import { type Component, createSignal } from 'solid-js';
import type { Component } from 'solid-js';
import { A, useNavigate } from '@solidjs/router';
import { createSignal } from 'solid-js';
import { Portal } from 'solid-js/web';
import { buildTimeConfig } from '../config/config';
import { useI18n } from '../i18n/i18n.provider';
@@ -9,7 +10,7 @@ import { clearDemoStorage } from './demo.storage';
export const DemoIndicator: Component = () => {
const [getIsMinified, setIsMinified] = createSignal(false);
const navigate = useNavigate();
const { t } = useI18n();
const { t, te } = useI18n();
const clearDemo = async () => {
await clearDemoStorage();
@@ -33,6 +34,9 @@ export const DemoIndicator: Component = () => {
<p class="text-sm">
{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">
{t('demo.popup.reset')}

View File

@@ -8,7 +8,7 @@ export async function getValues<T extends StorageValue>(storage: Storage<T>): Pr
return values;
}
export async function findOne<T extends StorageValue>(storage: Storage<T>, predicate: (value: T) => boolean): Promise< T | null> {
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);

View File

@@ -1,5 +1,7 @@
import type { ApiKey } from '../api-keys/api-keys.types';
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';
@@ -14,6 +16,8 @@ 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 const apiKeyStorage = prefixStorage<ApiKey>(storage, 'apiKeys');
export async function clearDemoStorage() {
await storage.clear();

View File

@@ -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>
);
};

View File

@@ -1,7 +1,8 @@
import type { Component } from 'solid-js';
import type { Document } from '../documents.types';
import { Card } from '@/modules/ui/components/card';
import { createQuery } from '@tanstack/solid-query';
import { type Component, createResource, Match, Suspense, Switch } from 'solid-js';
import { createResource, Match, Suspense, Switch } from 'solid-js';
import { fetchDocumentFile } from '../documents.services';
import { PdfViewer } from './pdf-viewer.component';
@@ -24,7 +25,7 @@ const TextFromBlob: Component<{ blob: Blob }> = (props) => {
return (
<Card class="p-6 overflow-auto max-h-800px max-w-full text-xs">
<Suspense>
<pre>{txt()}</pre>
<pre class="break-words whitespace-pre-wrap">{txt()}</pre>
</Suspense>
</Card>
);

View File

@@ -1,6 +1,7 @@
import type { Tag } from '@/modules/tags/tags.types';
import type { TooltipTriggerProps } from '@kobalte/core/tooltip';
import type { ColumnDef } from '@tanstack/solid-table';
import type { Accessor, Component, Setter } from 'solid-js';
import type { Document } from '../documents.types';
import { timeAgo } from '@/modules/shared/date/time-ago';
import { cn } from '@/modules/shared/style/cn';
@@ -12,8 +13,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/modules/ui/components
import { formatBytes } from '@corentinth/chisels';
import { A } from '@solidjs/router';
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
import { type Accessor, type Component, For, Match, type Setter, Show, Switch } from 'solid-js';
import { getDocumentIcon } from '../document.models';
import { For, Match, Show, Switch } from 'solid-js';
import { getDocumentIcon, getDocumentNameExtension, getDocumentNameWithoutExtension } from '../document.models';
import { DocumentManagementDropdown } from './document-management-dropdown.component';
type Pagination = {
@@ -83,15 +84,11 @@ 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('.').slice(0, -1).join('.')}
{getDocumentNameWithoutExtension({ name: data.row.original.name })}
</A>
<div class="text-xs text-muted-foreground lh-tight">
{formatBytes({ bytes: data.row.original.originalSize, base: 1000 })}
{' '}
-
{' '}
{data.row.original.name.split('.').pop()?.toUpperCase()}
{[formatBytes({ bytes: data.row.original.originalSize, base: 1000 }), getDocumentNameExtension({ name: data.row.original.name })].filter(Boolean).join(' - ')}
{' '}
-
{' '}

View File

@@ -1,7 +1,7 @@
import { icons as tablerIconSet } from '@iconify-json/tabler';
import { values } from 'lodash-es';
import { describe, expect, test } from 'vitest';
import { getDaysBeforePermanentDeletion, getDocumentIcon, iconByFileType } from './document.models';
import { getDaysBeforePermanentDeletion, getDocumentIcon, getDocumentNameExtension, getDocumentNameWithoutExtension, iconByFileType } from './document.models';
describe('files models', () => {
describe('iconByFileType', () => {
@@ -101,4 +101,24 @@ describe('files models', () => {
expect(daysBeforeDeletion).to.eql(undefined);
});
});
describe('getDocumentNameWithoutExtension', () => {
test('get the document name without the extension', () => {
expect(getDocumentNameWithoutExtension({ name: 'document.txt' })).to.eql('document');
expect(getDocumentNameWithoutExtension({ name: 'document' })).to.eql('document');
expect(getDocumentNameWithoutExtension({ name: '.document' })).to.eql('.document');
expect(getDocumentNameWithoutExtension({ name: '.document.txt' })).to.eql('.document');
expect(getDocumentNameWithoutExtension({ name: 'document.test.txt' })).to.eql('document.test');
});
});
describe('getDocumentNameExtension', () => {
test('get the document name extension', () => {
expect(getDocumentNameExtension({ name: 'document.txt' })).to.eql('txt');
expect(getDocumentNameExtension({ name: 'document' })).to.eql(undefined);
expect(getDocumentNameExtension({ name: '.document' })).to.eql(undefined);
expect(getDocumentNameExtension({ name: '.document.txt' })).to.eql('txt');
expect(getDocumentNameExtension({ name: 'document.test.txt' })).to.eql('txt');
});
});
});

View File

@@ -49,3 +49,33 @@ export function getDaysBeforePermanentDeletion({ document, deletedDocumentsReten
return daysBeforeDeletion;
}
export function getDocumentNameWithoutExtension({ name }: { name: string }) {
const dotSplittedName = name.split('.');
const dotCount = dotSplittedName.length - 1;
if (dotCount === 0) {
return name;
}
if (dotCount === 1 && name.startsWith('.')) {
return name;
}
return dotSplittedName.slice(0, -1).join('.');
}
export function getDocumentNameExtension({ name }: { name: string }) {
const dotSplittedName = name.split('.');
const dotCount = dotSplittedName.length - 1;
if (dotCount === 0) {
return undefined;
}
if (dotCount === 1 && name.startsWith('.')) {
return undefined;
}
return dotSplittedName[dotCount];
}

View File

@@ -9,7 +9,7 @@ import { queryClient } from '../shared/query/query-client';
import { createToast } from '../ui/components/sonner';
import { deleteDocument, restoreDocument, uploadDocument } from './documents.services';
function invalidateOrganizationDocumentsQuery({ organizationId }: { organizationId: string }) {
export function invalidateOrganizationDocumentsQuery({ organizationId }: { organizationId: string }) {
return queryClient.invalidateQueries({
queryKey: ['organizations', organizationId],
});
@@ -77,6 +77,34 @@ export function useRestoreDocument() {
};
}
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);
@@ -84,12 +112,8 @@ export function useUploadDocuments({ organizationId }: { organizationId: string
await Promise.all(files.map(async (file) => {
const [, error] = await safely(uploadDocument({ file, organizationId }));
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.`,
});
if (error) {
toastUploadError({ error, file });
}
await throttledInvalidateOrganizationDocumentsQuery({ organizationId });

View File

@@ -1,6 +1,7 @@
import type { AsDto } from '../shared/http/http-client.types';
import type { Document } from './documents.types';
import { apiClient } from '../shared/http/api-client';
import { getFormData } from '../shared/http/http-client.models';
import { coerceDates, getFormData } from '../shared/http/http-client.models';
export async function uploadDocument({
file,
@@ -9,18 +10,14 @@ export async function uploadDocument({
file: File;
organizationId: string;
}) {
const { document } = await apiClient<{ document: Document }>({
const { document } = await apiClient<{ document: AsDto<Document> }>({
method: 'POST',
path: `/api/organizations/${organizationId}/documents`,
body: getFormData({ file }),
});
return {
document: {
...document,
createdAt: new Date(document.createdAt),
updatedAt: document.updatedAt ? new Date(document.updatedAt) : undefined,
},
document: coerceDates(document),
};
}
@@ -40,7 +37,7 @@ export async function fetchOrganizationDocuments({
const {
documents,
documentsCount,
} = await apiClient<{ documents: Document[]; documentsCount: number }>({
} = await apiClient<{ documents: AsDto<Document>[]; documentsCount: number }>({
method: 'GET',
path: `/api/organizations/${organizationId}/documents`,
query: {
@@ -52,11 +49,7 @@ export async function fetchOrganizationDocuments({
return {
documentsCount,
documents: documents.map(document => ({
...document,
createdAt: new Date(document.createdAt),
updatedAt: document.updatedAt ? new Date(document.updatedAt) : undefined,
})),
documents: documents.map(coerceDates),
};
}
@@ -72,7 +65,7 @@ export async function fetchOrganizationDeletedDocuments({
const {
documents,
documentsCount,
} = await apiClient<{ documents: Document[]; documentsCount: number }>({
} = await apiClient<{ documents: AsDto<Document>[]; documentsCount: number }>({
method: 'GET',
path: `/api/organizations/${organizationId}/documents/deleted`,
query: {
@@ -83,11 +76,7 @@ export async function fetchOrganizationDeletedDocuments({
return {
documentsCount,
documents: documents.map(document => ({
...document,
createdAt: new Date(document.createdAt),
updatedAt: document.updatedAt ? new Date(document.updatedAt) : undefined,
})),
documents: documents.map(coerceDates),
};
}
@@ -124,17 +113,13 @@ export async function fetchDocument({
documentId: string;
organizationId: string;
}) {
const { document } = await apiClient<{ document: Document }>({
const { document } = await apiClient<{ document: AsDto<Document> }>({
method: 'GET',
path: `/api/organizations/${organizationId}/documents/${documentId}`,
});
return {
document: {
...document,
createdAt: new Date(document.createdAt),
updatedAt: document.updatedAt ? new Date(document.updatedAt) : undefined,
},
document: coerceDates(document),
};
}
@@ -167,7 +152,7 @@ export async function searchDocuments({
}) {
const {
documents,
} = await apiClient<{ documents: Document[] }>({
} = await apiClient<{ documents: AsDto<Document>[] }>({
method: 'GET',
path: `/api/organizations/${organizationId}/documents/search`,
query: {
@@ -178,11 +163,7 @@ export async function searchDocuments({
});
return {
documents: documents.map(document => ({
...document,
createdAt: new Date(document.createdAt),
updatedAt: document.updatedAt ? new Date(document.updatedAt) : undefined,
})),
documents: documents.map(coerceDates),
};
}
@@ -194,3 +175,37 @@ export async function getOrganizationDocumentsStats({ organizationId }: { organi
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}`,
});
}
export async function updateDocument({
documentId,
organizationId,
content,
}: {
documentId: string;
organizationId: string;
content: string;
}) {
const { document } = await apiClient<{ document: AsDto<Document> }>({
method: 'PATCH',
path: `/api/organizations/${organizationId}/documents/${documentId}`,
body: { content },
});
return {
document: coerceDates(document),
};
}

View File

@@ -11,5 +11,6 @@ export type Document = {
isDeleted?: boolean;
deletedAt?: Date;
deletedBy?: string;
content: string;
tags: Tag[];
};

View File

@@ -1,14 +1,19 @@
import type { Component } from 'solid-js';
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 { type Component, createSignal, Show, Suspense } from 'solid-js';
import { createMutation, createQuery, keepPreviousData } from '@tanstack/solid-query';
import { createSignal, Show, Suspense } from 'solid-js';
import { DocumentsPaginatedList } from '../components/documents-list.component';
import { useRestoreDocument } from '../documents.composables';
import { fetchOrganizationDeletedDocuments } from '../documents.services';
import { deleteAllTrashDocuments, deleteTrashDocument, fetchOrganizationDeletedDocuments } from '../documents.services';
const RestoreDocumentButton: Component<{ document: Document }> = (props) => {
const { getIsRestoring, restore } = useRestoreDocument();
@@ -32,6 +37,113 @@ 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();
@@ -77,6 +189,10 @@ export const DeletedDocumentsPage: Component = () => {
</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 +212,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>
),
},

View File

@@ -1,19 +1,27 @@
import type { Component, JSX } from 'solid-js';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { timeAgo } from '@/modules/shared/date/time-ago';
import { downloadFile } from '@/modules/shared/files/download';
import { queryClient } from '@/modules/shared/query/query-client';
import { DocumentTagPicker } from '@/modules/tags/components/tag-picker.component';
import { CreateTagModal } from '@/modules/tags/pages/tags.page';
import { addTagToDocument, removeTagFromDocument } from '@/modules/tags/tags.services';
import { Alert } from '@/modules/ui/components/alert';
import { Alert, AlertDescription } from '@/modules/ui/components/alert';
import { Button } from '@/modules/ui/components/button';
import { Separator } from '@/modules/ui/components/separator';
import { formatBytes } from '@corentinth/chisels';
import { createToast } from '@/modules/ui/components/sonner';
import { Tabs, TabsContent, TabsIndicator, TabsList, TabsTrigger } from '@/modules/ui/components/tabs';
import { TextArea } from '@/modules/ui/components/textarea';
import { TextFieldRoot } from '@/modules/ui/components/textfield';
import { formatBytes, safely } from '@corentinth/chisels';
import { useNavigate, useParams } from '@solidjs/router';
import { createQueries } from '@tanstack/solid-query';
import { type Component, For, type JSX, Show, Suspense } from 'solid-js';
import { createSignal, For, 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 { fetchDocument, fetchDocumentFile, updateDocument } from '../documents.services';
import '@pdfslick/solid/dist/pdf_viewer.css';
type KeyValueItem = {
@@ -43,6 +51,7 @@ const KeyValues: Component<{ data?: KeyValueItem[] }> = (props) => {
};
export const DocumentPage: Component = () => {
const { t } = useI18n();
const params = useParams();
const { deleteDocument } = useDeleteDocument();
const { restore, getIsRestoring } = useRestoreDocument();
@@ -82,6 +91,44 @@ export const DocumentPage: Component = () => {
const getDataUrl = () => queries[1].data ? URL.createObjectURL(queries[1].data) : undefined;
const [isEditing, setIsEditing] = createSignal(false);
const [editedContent, setEditedContent] = createSignal('');
const [isSaving, setIsSaving] = createSignal(false);
const handleEdit = () => {
setEditedContent(queries[0].data?.document.content ?? '');
setIsEditing(true);
};
const handleCancel = () => {
setIsEditing(false);
setEditedContent('');
};
const handleSave = async () => {
if (!queries[0].data?.document) {
return;
}
setIsSaving(true);
const [, error] = await safely(updateDocument({
documentId: queries[0].data.document.id,
organizationId: params.organizationId,
content: editedContent(),
}));
setIsSaving(false);
setIsEditing(false);
if (error) {
createToast({ type: 'error', message: 'Failed to update document content' });
return;
}
createToast({ type: 'success', message: 'Document content updated' });
await queryClient.invalidateQueries({
queryKey: ['organizations', params.organizationId, 'documents', params.documentId],
});
};
return (
<div class="p-6 flex gap-6 h-full flex-col md:flex-row max-w-7xl mx-auto">
<Suspense>
@@ -136,26 +183,38 @@ export const DocumentPage: Component = () => {
)}
</div>
<DocumentTagPicker
organizationId={params.organizationId}
documentId={params.documentId}
tags={getDocument().tags}
onTagAdded={async ({ tag }) => {
await addTagToDocument({
documentId: params.documentId,
organizationId: params.organizationId,
tagId: tag.id,
});
}}
<div class="flex gap-2 sm:items-center sm:flex-row flex-col">
<div class="flex-1">
<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,
});
}}
/>
onTagRemoved={async ({ tag }) => {
await removeTagFromDocument({
documentId: params.documentId,
organizationId: params.organizationId,
tagId: tag.id,
});
}}
/>
</div>
<CreateTagModal organizationId={params.organizationId}>
{params => (
<Button variant="outline" {...params}>
<div class="i-tabler-plus size-4 mr-2"></div>
{t('tagging-rules.form.tags.add-tag')}
</Button>
)}
</CreateTagModal>
</div>
{getDocument().isDeleted && (
<Alert variant="destructive" class="mt-6">
@@ -170,41 +229,95 @@ export const DocumentPage: Component = () => {
</Alert>
)}
<Separator class="my-6" />
<Separator class="my-3" />
<Tabs defaultValue="info" class="w-full">
<TabsList class="w-full h-8">
<TabsTrigger value="info">Info</TabsTrigger>
<TabsTrigger value="content">Content</TabsTrigger>
<TabsIndicator />
</TabsList>
<TabsContent value="info">
<KeyValues data={[
{
label: 'ID',
value: getDocument().id,
icon: 'i-tabler-id',
},
{
label: 'Name',
value: getDocument().name,
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',
value: timeAgo({ date: getDocument().createdAt }),
icon: 'i-tabler-calendar',
},
{
label: 'Updated At',
value: getDocument().updatedAt ? timeAgo({ date: getDocument().updatedAt! }) : <span class="text-muted-foreground">Never</span>,
icon: 'i-tabler-calendar',
},
]}
/>
</TabsContent>
<TabsContent value="content">
<Show
when={isEditing()}
fallback={(
<div class="flex flex-col gap-2">
<div class="whitespace-pre-wrap font-mono text-sm bg-muted p-4 rounded-md max-h-[400px] overflow-auto">
{queries[0].data?.document.content}
</div>
<div class="flex justify-end">
<Button variant="outline" onClick={handleEdit}>
<div class="i-tabler-edit size-4 mr-2" />
Edit
</Button>
</div>
<Alert variant="muted" class="my-4 flex items-center gap-2">
<div class="i-tabler-info-circle size-8 flex-shrink-0" />
<AlertDescription>
The content of the document is automatically extracted from the document on upload. It is only used for search and indexing purposes.
</AlertDescription>
</Alert>
</div>
)}
>
<div class="flex flex-col gap-2">
<TextFieldRoot>
<TextArea
value={editedContent()}
onInput={e => setEditedContent(e.currentTarget.value)}
class="font-mono min-h-[200px]"
/>
</TextFieldRoot>
<div class="flex justify-end gap-2">
<Button variant="outline" onClick={handleCancel} disabled={isSaving()}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isSaving()}>
{isSaving() ? 'Saving...' : 'Save'}
</Button>
</div>
</div>
</Show>
</TabsContent>
</Tabs>
<KeyValues data={[
{
label: 'ID',
value: getDocument().id,
icon: 'i-tabler-id',
},
{
label: 'Name',
value: getDocument().name,
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',
value: timeAgo({ date: getDocument().createdAt }),
icon: 'i-tabler-calendar',
},
{
label: 'Updated At',
value: getDocument().updatedAt ? timeAgo({ date: getDocument().updatedAt! }) : <span class="text-muted-foreground">Never</span>,
icon: 'i-tabler-calendar',
},
]}
/>
</div>
</div>
)}

View File

@@ -1,10 +1,11 @@
import type { Component } from 'solid-js';
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 { castArray } from 'lodash-es';
import { type Component, createSignal, For, Show, Suspense } from 'solid-js';
import { createSignal, For, Show, Suspense } from 'solid-js';
import { DocumentUploadArea } from '../components/document-upload-area.component';
import { createdAtColumn, DocumentsPaginatedList, standardActionsColumn, tagsColumn } from '../components/documents-list.component';
import { fetchOrganizationDocuments } from '../documents.services';

View File

@@ -1,4 +1,4 @@
export const locales = [
{ key: 'en', name: 'English' },
{ key: 'fr', name: 'Français' },
];
] as const;

View File

@@ -30,7 +30,13 @@ export function findMatchingLocale({
export function createTranslator<Dict extends Record<string, string>>({ getDictionary }: { getDictionary: () => Dict }) {
return (key: keyof Dict, args?: Record<string, string | number>) => {
let translation: string = getDictionary()[key] ?? key;
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)) {

View File

@@ -2,7 +2,7 @@ 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 defaultDict from '../../locales/en.yml';
import { locales } from './i18n.constants';
import { createFragmentTranslator, createTranslator, findMatchingLocale } from './i18n.models';
@@ -28,7 +28,7 @@ export function useI18n() {
}
async function fetchDictionary(locale: Locale): Promise<Dictionary> {
const { default: dict } = await import(`../../locales/${locale}.yml?flattened`);
const { default: dict } = await import(`../../locales/${locale}.yml`);
return {
...defaultDict,

View File

@@ -1,10 +1,14 @@
import { readFile } from 'node:fs/promises';
import { glob } from 'tinyglobby';
import { describe, expect, test } from 'vitest';
const rawLocales = import.meta.glob('../../locales/*.yml', { eager: true, query: '?flattened' });
const { en: defaultLocal, ...locales } = Object.fromEntries(
const rawLocales = import.meta.glob('../../locales/*.yml', { eager: true });
const locales = Object.fromEntries(
Object.entries(rawLocales).map(([key, value]: [string, any]) => [key.replace('../../locales/', '').replace('.yml', ''), value.default]),
);
const { en: defaultLocal } = locales;
describe('locales', () => {
for (const [locale, translations] of Object.entries(locales)) {
describe(locale, () => {
@@ -26,4 +30,38 @@ describe('locales', () => {
});
});
}
test('all keys in en.yml must be used in the app (dynamic keys are manually excluded)', async () => {
const srcFileNames = await glob(['src/**/*.{ts,tsx}', '!src/**/*.test.*', '!src/modules/i18n/locales.types.ts'], { cwd: process.cwd() });
// Exclude keys that are used in dynamic contexts
const dynamicKeysMatchers = [
/^api-errors\./, // api-errors.document.already_exists
/^auth\.register\.providers\.[a-z0-9:]+$/, // auth.register.providers.google
/^webhooks\.events\.documents\.[a-z0-9:]+.description$/, // webhooks.events.organization.organization:created
/^api-keys\.permissions\.[a-z0-9:]+\.[a-z0-9:]+$/, // api-keys.permissions.documents.documents:delete
];
const keys = new Set(
Object
.keys(defaultLocal)
.filter(key => !dynamicKeysMatchers.some(matcher => matcher.test(key))),
);
for (const srcFileName of srcFileNames) {
const fileContent = await readFile(srcFileName, 'utf-8');
for (const key of keys) {
if (fileContent.includes(key)) {
keys.delete(key);
}
}
if (keys.size === 0) {
break;
}
}
expect([...keys]).to.eql([], 'Unused keys found in en.yml, please remove them (or add them to the dynamic keys matchers in locales.test.ts if they are used in dynamic contexts)');
});
});

View File

@@ -1,2 +1,234 @@
// Dynamically generated file. Use "pnpm script:generate-i18n-types" to update.
export type LocaleKeys = 'auth.login.title' | 'auth.login.description' | 'auth.login.login-with-provider' | 'auth.login.no-account' | 'auth.login.register' | 'auth.email-validation-required.title' | 'auth.email-validation-required.description' | 'auth.legal-links.description' | 'auth.legal-links.terms' | 'auth.legal-links.privacy' | 'tags.no-tags.title' | 'tags.no-tags.description' | 'tags.no-tags.create-tag' | 'layout.menu.home' | 'layout.menu.documents' | 'layout.menu.tags' | 'layout.menu.integrations' | 'layout.menu.deleted-documents' | 'layout.menu.organization-settings' | 'demo.popup.description' | 'demo.popup.reset' | 'demo.popup.hide';
// Do not manually edit this file.
// This file is dynamically generated when the dev server runs (or using the `pnpm script:generate-i18n-types` command).
// Keys are extracted from the en.yml file.
// Source code : src/plugins/i18n-types/i18n-types.services.ts
export type LocaleKeys =
| 'auth.request-password-reset.title'
| 'auth.request-password-reset.description'
| 'auth.request-password-reset.requested'
| 'auth.request-password-reset.back-to-login'
| 'auth.request-password-reset.form.email.label'
| 'auth.request-password-reset.form.email.placeholder'
| 'auth.request-password-reset.form.email.required'
| 'auth.request-password-reset.form.email.invalid'
| 'auth.request-password-reset.form.submit'
| 'auth.reset-password.title'
| 'auth.reset-password.description'
| 'auth.reset-password.reset'
| 'auth.reset-password.back-to-login'
| 'auth.reset-password.form.new-password.label'
| 'auth.reset-password.form.new-password.placeholder'
| 'auth.reset-password.form.new-password.required'
| 'auth.reset-password.form.new-password.min-length'
| 'auth.reset-password.form.new-password.max-length'
| 'auth.reset-password.form.submit'
| 'auth.email-provider.open'
| 'auth.login.title'
| 'auth.login.description'
| 'auth.login.login-with-provider'
| 'auth.login.no-account'
| 'auth.login.register'
| 'auth.login.form.email.label'
| 'auth.login.form.email.placeholder'
| 'auth.login.form.email.required'
| 'auth.login.form.email.invalid'
| 'auth.login.form.password.label'
| 'auth.login.form.password.placeholder'
| 'auth.login.form.password.required'
| 'auth.login.form.remember-me.label'
| 'auth.login.form.forgot-password.label'
| 'auth.login.form.submit'
| 'auth.register.title'
| 'auth.register.description'
| 'auth.register.register-with-email'
| 'auth.register.register-with-provider'
| 'auth.register.providers.google'
| 'auth.register.providers.github'
| 'auth.register.have-account'
| 'auth.register.login'
| 'auth.register.registration-disabled.title'
| 'auth.register.registration-disabled.description'
| 'auth.register.form.email.label'
| 'auth.register.form.email.placeholder'
| 'auth.register.form.email.required'
| 'auth.register.form.email.invalid'
| 'auth.register.form.password.label'
| 'auth.register.form.password.placeholder'
| 'auth.register.form.password.required'
| 'auth.register.form.password.min-length'
| 'auth.register.form.password.max-length'
| 'auth.register.form.name.label'
| 'auth.register.form.name.placeholder'
| 'auth.register.form.name.required'
| 'auth.register.form.name.max-length'
| 'auth.register.form.submit'
| 'auth.email-validation-required.title'
| 'auth.email-validation-required.description'
| 'auth.legal-links.description'
| 'auth.legal-links.terms'
| 'auth.legal-links.privacy'
| 'tags.no-tags.title'
| 'tags.no-tags.description'
| 'tags.no-tags.create-tag'
| 'layout.menu.home'
| 'layout.menu.documents'
| 'layout.menu.tags'
| 'layout.menu.tagging-rules'
| 'layout.menu.deleted-documents'
| 'layout.menu.organization-settings'
| 'layout.menu.api-keys'
| 'layout.menu.settings'
| 'layout.menu.account'
| 'layout.menu.general-settings'
| 'layout.menu.intake-emails'
| 'layout.menu.webhooks'
| 'tagging-rules.field.name'
| 'tagging-rules.field.content'
| 'tagging-rules.operator.equals'
| 'tagging-rules.operator.not-equals'
| 'tagging-rules.operator.contains'
| 'tagging-rules.operator.not-contains'
| 'tagging-rules.operator.starts-with'
| 'tagging-rules.operator.ends-with'
| 'tagging-rules.list.title'
| 'tagging-rules.list.description'
| 'tagging-rules.list.demo-warning'
| 'tagging-rules.list.no-tagging-rules.title'
| 'tagging-rules.list.no-tagging-rules.description'
| 'tagging-rules.list.no-tagging-rules.create-tagging-rule'
| 'tagging-rules.list.card.no-conditions'
| 'tagging-rules.list.card.one-condition'
| 'tagging-rules.list.card.conditions'
| 'tagging-rules.list.card.delete'
| 'tagging-rules.list.card.edit'
| 'tagging-rules.create.title'
| 'tagging-rules.create.success'
| 'tagging-rules.create.error'
| 'tagging-rules.create.submit'
| 'tagging-rules.form.name.label'
| 'tagging-rules.form.name.placeholder'
| 'tagging-rules.form.name.min-length'
| 'tagging-rules.form.name.max-length'
| 'tagging-rules.form.description.label'
| 'tagging-rules.form.description.placeholder'
| 'tagging-rules.form.description.max-length'
| 'tagging-rules.form.conditions.label'
| 'tagging-rules.form.conditions.description'
| 'tagging-rules.form.conditions.add-condition'
| 'tagging-rules.form.conditions.no-conditions.title'
| 'tagging-rules.form.conditions.no-conditions.description'
| 'tagging-rules.form.conditions.no-conditions.confirm'
| 'tagging-rules.form.conditions.no-conditions.cancel'
| 'tagging-rules.form.conditions.value.placeholder'
| 'tagging-rules.form.conditions.value.min-length'
| 'tagging-rules.form.tags.label'
| 'tagging-rules.form.tags.description'
| 'tagging-rules.form.tags.min-length'
| 'tagging-rules.form.tags.add-tag'
| 'tagging-rules.form.submit'
| 'tagging-rules.update.title'
| 'tagging-rules.update.error'
| 'tagging-rules.update.submit'
| 'tagging-rules.update.cancel'
| 'demo.popup.description'
| 'demo.popup.discord'
| 'demo.popup.discord-link-label'
| 'demo.popup.reset'
| 'demo.popup.hide'
| 'trash.delete-all.button'
| 'trash.delete-all.confirm.title'
| 'trash.delete-all.confirm.description'
| 'trash.delete-all.confirm.label'
| 'trash.delete-all.confirm.cancel'
| 'trash.delete.button'
| 'trash.delete.confirm.title'
| 'trash.delete.confirm.description'
| 'trash.delete.confirm.label'
| 'trash.delete.confirm.cancel'
| 'trash.deleted.success.title'
| 'trash.deleted.success.description'
| 'import-documents.title.error'
| 'import-documents.title.success'
| 'import-documents.title.pending'
| 'import-documents.title.none'
| 'import-documents.no-import-in-progress'
| 'api-errors.document.already_exists'
| 'api-errors.document.file_too_big'
| 'api-errors.intake_email.limit_reached'
| 'api-errors.user.max_organization_count_reached'
| 'api-errors.default'
| 'api-keys.permissions.documents.title'
| 'api-keys.permissions.documents.documents:create'
| 'api-keys.permissions.documents.documents:read'
| 'api-keys.permissions.documents.documents:update'
| 'api-keys.permissions.documents.documents:delete'
| 'api-keys.permissions.tags.title'
| 'api-keys.permissions.tags.tags:create'
| 'api-keys.permissions.tags.tags:read'
| 'api-keys.permissions.tags.tags:update'
| 'api-keys.permissions.tags.tags:delete'
| 'api-keys.create.title'
| 'api-keys.create.description'
| 'api-keys.create.success'
| 'api-keys.create.back'
| 'api-keys.create.form.name.label'
| 'api-keys.create.form.name.placeholder'
| 'api-keys.create.form.name.required'
| 'api-keys.create.form.permissions.label'
| 'api-keys.create.form.permissions.required'
| 'api-keys.create.form.submit'
| 'api-keys.create.created.title'
| 'api-keys.create.created.description'
| 'api-keys.list.title'
| 'api-keys.list.description'
| 'api-keys.list.create'
| 'api-keys.list.empty.title'
| 'api-keys.list.empty.description'
| 'api-keys.list.card.last-used'
| 'api-keys.list.card.never'
| 'api-keys.list.card.created'
| 'api-keys.delete.success'
| 'api-keys.delete.confirm.title'
| 'api-keys.delete.confirm.message'
| 'api-keys.delete.confirm.confirm-button'
| 'api-keys.delete.confirm.cancel-button'
| 'webhooks.list.title'
| 'webhooks.list.description'
| 'webhooks.list.empty.title'
| 'webhooks.list.empty.description'
| 'webhooks.list.create'
| 'webhooks.list.card.last-triggered'
| 'webhooks.list.card.never'
| 'webhooks.list.card.created'
| 'webhooks.create.title'
| 'webhooks.create.description'
| 'webhooks.create.success'
| 'webhooks.create.back'
| 'webhooks.create.form.submit'
| 'webhooks.create.form.name.label'
| 'webhooks.create.form.name.placeholder'
| 'webhooks.create.form.name.required'
| 'webhooks.create.form.url.label'
| 'webhooks.create.form.url.placeholder'
| 'webhooks.create.form.url.required'
| 'webhooks.create.form.url.invalid'
| 'webhooks.create.form.secret.label'
| 'webhooks.create.form.secret.placeholder'
| 'webhooks.create.form.events.label'
| 'webhooks.create.form.events.required'
| 'webhooks.update.title'
| 'webhooks.update.description'
| 'webhooks.update.success'
| 'webhooks.update.submit'
| 'webhooks.update.cancel'
| 'webhooks.update.form.secret.placeholder'
| 'webhooks.update.form.secret.placeholder-redacted'
| 'webhooks.update.form.rotate-secret.button'
| 'webhooks.delete.success'
| 'webhooks.delete.confirm.title'
| 'webhooks.delete.confirm.message'
| 'webhooks.delete.confirm.confirm-button'
| 'webhooks.delete.confirm.cancel-button'
| 'webhooks.events.documents.document:created.description'
| 'webhooks.events.documents.document:deleted.description';

View File

@@ -1,9 +1,10 @@
import type { AsDto } from '../shared/http/http-client.types';
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[] }>({
const { intakeEmails } = await apiClient<{ intakeEmails: AsDto<IntakeEmail>[] }>({
path: `/api/organizations/${organizationId}/intake-emails`,
method: 'GET',
});
@@ -14,7 +15,7 @@ export async function fetchIntakeEmails({ organizationId }: { organizationId: st
}
export async function createIntakeEmail({ organizationId }: { organizationId: string }) {
const { intakeEmail } = await apiClient<{ intakeEmail: IntakeEmail }>({
const { intakeEmail } = await apiClient<{ intakeEmail: AsDto<IntakeEmail> }>({
path: `/api/organizations/${organizationId}/intake-emails`,
method: 'POST',
});
@@ -42,7 +43,7 @@ export async function updateIntakeEmail({
isEnabled?: boolean;
allowedOrigins?: string[];
}) {
const { intakeEmail } = await apiClient<{ intakeEmail: IntakeEmail }>({
const { intakeEmail } = await apiClient<{ intakeEmail: AsDto<IntakeEmail> }>({
path: `/api/organizations/${organizationId}/intake-emails/${intakeEmailId}`,
method: 'PUT',
body: {

View File

@@ -6,5 +6,5 @@ export type IntakeEmail = {
allowedOrigins: string[];
createdAt: Date;
updatedAt: Date | undefined;
updatedAt: Date;
};

View File

@@ -1,4 +1,5 @@
import type { DialogTriggerProps } from '@kobalte/core/dialog';
import type { Component, JSX } from 'solid-js';
import type { IntakeEmail } from '../intake-emails.types';
import { useConfig } from '@/modules/config/config.provider';
import { useConfirmModal } from '@/modules/shared/confirm';
@@ -16,8 +17,7 @@ import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/component
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 { createSignal, For, Show, Suspense } from 'solid-js';
import * as v from 'valibot';
import { createIntakeEmail, deleteIntakeEmail, fetchIntakeEmails, updateIntakeEmail } from '../intake-emails.services';
@@ -211,9 +211,9 @@ export const IntakeEmailsPage: Component = () => {
};
return (
<Card class="p-6">
<div class="p-6 max-w-screen-md mx-auto mt-10">
<h2 class="text-base font-bold">Intake Emails</h2>
<h1 class="text-xl font-semibold">Intake Emails</h1>
<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.
@@ -264,7 +264,7 @@ export const IntakeEmailsPage: Component = () => {
<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 justify-between border rounded-lg p-4 bg-card">
<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')} />
@@ -342,6 +342,6 @@ export const IntakeEmailsPage: Component = () => {
)}
</Show>
</Suspense>
</Card>
</div>
);
};

View File

@@ -1,65 +1,51 @@
import type { AsDto } from '../shared/http/http-client.types';
import type { Organization } from './organizations.types';
import { apiClient } from '../shared/http/api-client';
import { coerceDates } from '../shared/http/http-client.models';
export async function fetchOrganizations() {
const { organizations } = await apiClient<{ organizations: Organization[] }>({
const { organizations } = await apiClient<{ organizations: AsDto<Organization>[] }>({
path: '/api/organizations',
method: 'GET',
});
return {
organizations: organizations.map(organization => ({
...organization,
createdAt: new Date(organization.createdAt),
updatedAt: organization.updatedAt ? new Date(organization.updatedAt) : undefined,
})),
organizations: organizations.map(coerceDates),
};
}
export async function createOrganization({ name }: { name: string }) {
const { organization } = await apiClient<{ organization: Organization }>({
const { organization } = await apiClient<{ organization: AsDto<Organization> }>({
path: '/api/organizations',
method: 'POST',
body: { name },
});
return {
organization: {
...organization,
createdAt: new Date(organization.createdAt),
updatedAt: organization.updatedAt ? new Date(organization.updatedAt) : undefined,
},
organization: coerceDates(organization),
};
}
export async function updateOrganization({ organizationId, name }: { organizationId: string; name: string }) {
const { organization } = await apiClient<{ organization: Organization }>({
const { organization } = await apiClient<{ organization: AsDto<Organization> }>({
path: `/api/organizations/${organizationId}`,
method: 'PUT',
body: { name },
});
return {
organization: {
...organization,
createdAt: new Date(organization.createdAt),
updatedAt: organization.updatedAt ? new Date(organization.updatedAt) : undefined,
},
organization: coerceDates(organization),
};
}
export async function fetchOrganization({ organizationId }: { organizationId: string }) {
const { organization } = await apiClient<{ organization: Organization }>({
const { organization } = await apiClient<{ organization: AsDto<Organization> }>({
path: `/api/organizations/${organizationId}`,
method: 'GET',
});
return {
organization: {
...organization,
createdAt: new Date(organization.createdAt),
updatedAt: organization.updatedAt ? new Date(organization.updatedAt) : undefined,
},
organization: coerceDates(organization),
};
}

View File

@@ -2,5 +2,5 @@ export type Organization = {
id: string;
name: string;
createdAt: Date;
updatedAt?: Date;
updatedAt: Date;
};

View File

@@ -1,7 +1,8 @@
import type { Component } from 'solid-js';
import { useCurrentUser } from '@/modules/users/composables/useCurrentUser';
import { useNavigate } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { type Component, createEffect, on } from 'solid-js';
import { createEffect, on } from 'solid-js';
import { CreateOrganizationForm } from '../components/create-organization-form.component';
import { useCreateOrganization } from '../organizations.composables';
import { fetchOrganizations } from '../organizations.services';

View File

@@ -1,3 +1,4 @@
import type { Component } from 'solid-js';
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';
@@ -6,7 +7,7 @@ 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';
import { createSignal, Show, Suspense } from 'solid-js';
export const OrganizationPage: Component = () => {
const params = useParams();

View File

@@ -1,3 +1,4 @@
import type { Component } from 'solid-js';
import type { Organization } from '../organizations.types';
import { buildTimeConfig } from '@/modules/config/config';
import { useConfirmModal } from '@/modules/shared/confirm';
@@ -10,7 +11,7 @@ import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/component
import { safely } from '@corentinth/chisels';
import { useParams } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { type Component, createSignal, Show, Suspense } from 'solid-js';
import { createSignal, Show, Suspense } from 'solid-js';
import * as v from 'valibot';
import { useDeleteOrganization, useUpdateOrganization } from '../organizations.composables';
import { organizationNameSchema } from '../organizations.schemas';
@@ -164,7 +165,7 @@ export const OrganizationsSettingsPage: Component = () => {
}));
return (
<div class="p-6 mt-4 pb-32 mx-auto max-w-xl">
<div class="p-6 mt-10 pb-32 mx-auto max-w-screen-md w-full">
<Suspense>
<Show when={query.data?.organization}>
{ getOrganization => (

View File

@@ -1,6 +1,7 @@
import type { Component } from 'solid-js';
import { A, useNavigate } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { type Component, createEffect, For, on } from 'solid-js';
import { createEffect, For, on } from 'solid-js';
import { fetchOrganizations } from '../organizations.services';
export const OrganizationsPage: Component = () => {

View File

@@ -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),
};
}

View File

@@ -1,6 +1,7 @@
import type { HttpClientOptions, ResponseType } from './http-client';
import { buildTimeConfig } from '@/modules/config/config';
import { safely } from '@corentinth/chisels';
import { httpClient, type HttpClientOptions, type ResponseType } from './http-client';
import { httpClient } from './http-client';
import { isHttpErrorWithStatusCode } from './http-errors';
export async function apiClient<T, R extends ResponseType = 'json'>({

View File

@@ -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 });
},
};
}

View File

@@ -24,6 +24,8 @@ describe('http-client models', () => {
createdAt: '2021-01-01T00:00:00.000Z',
updatedAt: '2021-01-02T00:00:00.000Z',
deletedAt: '2021-01-03T00:00:00.000Z',
expiresAt: '2021-01-04T00:00:00.000Z',
lastUsedAt: '2021-01-05T00:00:00.000Z',
foo: 'bar',
baz: 'qux',
};
@@ -34,6 +36,8 @@ describe('http-client models', () => {
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'),
expiresAt: new Date('2021-01-04T00:00:00.000Z'),
lastUsedAt: new Date('2021-01-05T00:00:00.000Z'),
foo: 'bar',
baz: 'qux',
});

View File

@@ -24,5 +24,7 @@ export function coerceDates<T extends Record<string, any>>(obj: T): CoerceDates<
...('createdAt' in obj ? { createdAt: toDate(obj.createdAt) } : {}),
...('updatedAt' in obj ? { updatedAt: toDate(obj.updatedAt) } : {}),
...('deletedAt' in obj ? { deletedAt: toDate(obj.deletedAt) } : {}),
...('expiresAt' in obj ? { expiresAt: toDate(obj.expiresAt) } : {}),
...('lastUsedAt' in obj ? { lastUsedAt: toDate(obj.lastUsedAt) } : {}),
};
}

View File

@@ -0,0 +1,5 @@
import type { Expand } from '@corentinth/chisels';
export type AsDto<T> = Expand<{
[K in keyof T]: T[K] extends Date ? string : T[K];
}>;

View File

@@ -0,0 +1,254 @@
import type { Component } from 'solid-js';
import type { TaggingRule, TaggingRuleForCreation } from '../tagging-rules.types';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { useConfirmModal } from '@/modules/shared/confirm';
import { createForm } from '@/modules/shared/form/form';
import { DocumentTagPicker } from '@/modules/tags/components/tag-picker.component';
import { CreateTagModal } from '@/modules/tags/pages/tags.page';
import { Button } from '@/modules/ui/components/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/modules/ui/components/select';
import { Separator } from '@/modules/ui/components/separator';
import { TextArea } from '@/modules/ui/components/textarea';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { insert, remove, setValue } from '@modular-forms/solid';
import { A } from '@solidjs/router';
import { For, Show } from 'solid-js';
import * as v from 'valibot';
import { TAGGING_RULE_FIELDS, TAGGING_RULE_FIELDS_LOCALIZATION_KEYS, TAGGING_RULE_OPERATORS, TAGGING_RULE_OPERATORS_LOCALIZATION_KEYS } from '../tagging-rules.constants';
export const TaggingRuleForm: Component<{
onSubmit: (args: { taggingRule: TaggingRuleForCreation }) => Promise<void> | void;
organizationId: string;
taggingRule?: TaggingRule;
submitButtonText?: string;
}> = (props) => {
const { t } = useI18n();
const { confirm } = useConfirmModal();
const { form, Form, Field, FieldArray } = createForm({
onSubmit: async ({ name, conditions = [], tagIds, description }) => {
if (conditions.length === 0) {
const confirmed = await confirm({
title: t('tagging-rules.form.conditions.no-conditions.title'),
message: t('tagging-rules.form.conditions.no-conditions.description'),
confirmButton: {
variant: 'default',
text: t('tagging-rules.form.conditions.no-conditions.confirm'),
},
cancelButton: {
text: t('tagging-rules.form.conditions.no-conditions.cancel'),
},
});
if (!confirmed) {
return;
}
}
props.onSubmit({ taggingRule: { name, conditions, tagIds, description } });
},
schema: v.object({
name: v.pipe(
v.string(),
v.minLength(1, t('tagging-rules.form.name.min-length')),
v.maxLength(64, t('tagging-rules.form.name.max-length')),
),
description: v.pipe(
v.string(),
v.maxLength(256, t('tagging-rules.form.description.max-length')),
),
conditions: v.optional(
v.array(v.object({
field: v.picklist(Object.values(TAGGING_RULE_FIELDS)),
operator: v.picklist(Object.values(TAGGING_RULE_OPERATORS)),
value: v.pipe(
v.string(),
v.minLength(1, t('tagging-rules.form.conditions.value.min-length')),
),
})),
),
tagIds: v.pipe(
v.array(v.string()),
v.minLength(1, t('tagging-rules.form.tags.min-length')),
),
}),
initialValues: {
conditions: props.taggingRule?.conditions ?? [],
tagIds: props.taggingRule?.actions.map(action => action.tagId) ?? [],
name: props.taggingRule?.name,
description: props.taggingRule?.description,
},
});
const getOperatorLabel = (operator: string) => {
return t(TAGGING_RULE_OPERATORS_LOCALIZATION_KEYS[operator as keyof typeof TAGGING_RULE_OPERATORS_LOCALIZATION_KEYS]);
};
const getFieldLabel = (field: string) => {
return t(TAGGING_RULE_FIELDS_LOCALIZATION_KEYS[field as keyof typeof TAGGING_RULE_FIELDS_LOCALIZATION_KEYS]);
};
return (
<Form>
<Field name="name">
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col gap-1">
<TextFieldLabel for="name">{t('tagging-rules.form.name.label')}</TextFieldLabel>
<TextField
type="text"
id="name"
placeholder={t('tagging-rules.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="description">
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col gap-1 mt-6">
<TextFieldLabel for="description">{t('tagging-rules.form.description.label')}</TextFieldLabel>
<TextArea
id="description"
placeholder={t('tagging-rules.form.description.placeholder')}
{...inputProps}
value={field.value}
/>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</TextFieldRoot>
)}
</Field>
<Separator class="my-6" />
<p class="mb-1 font-medium">{t('tagging-rules.form.conditions.label')}</p>
<p class="mb-2 text-sm text-muted-foreground">{t('tagging-rules.form.conditions.description')}</p>
<FieldArray name="conditions">
{fieldArray => (
<div>
<For each={fieldArray.items}>
{(_, index) => (
<div class="px-4 py-4 mb-1 flex gap-2 items-center bg-card border rounded-md">
<div>When</div>
<Field name={`conditions.${index()}.field`}>
{field => (
<Select
id="field"
defaultValue={field.value}
onChange={value => value && setValue(form, `conditions.${index()}.field`, value)}
options={Object.values(TAGGING_RULE_FIELDS)}
itemComponent={props => (
<SelectItem item={props.item}>{getFieldLabel(props.item.rawValue)}</SelectItem>
)}
>
<SelectTrigger class="w-[180px]">
<SelectValue<string>>{state => getFieldLabel(state.selectedOption())}</SelectValue>
</SelectTrigger>
<SelectContent />
</Select>
)}
</Field>
<Field name={`conditions.${index()}.operator`}>
{field => (
<Select
id="operator"
defaultValue={field.value}
onChange={value => value && setValue(form, `conditions.${index()}.operator`, value)}
options={Object.values(TAGGING_RULE_OPERATORS)}
itemComponent={props => (
<SelectItem item={props.item}>{getOperatorLabel(props.item.rawValue)}</SelectItem>
)}
>
<SelectTrigger class="w-[140px]">
<SelectValue<string>>{state => getOperatorLabel(state.selectedOption())}</SelectValue>
</SelectTrigger>
<SelectContent />
</Select>
)}
</Field>
<Field name={`conditions.${index()}.value`}>
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col gap-1 flex-1">
<TextField
id="value"
{...inputProps}
value={field.value}
placeholder={t('tagging-rules.form.conditions.value.placeholder')}
/>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</TextFieldRoot>
)}
</Field>
<Button variant="outline" size="icon" onClick={() => remove(form, 'conditions', { at: index() })}>
<div class="i-tabler-x size-4"></div>
</Button>
</div>
)}
</For>
{fieldArray.error && <div class="text-red-500 text-sm">{fieldArray.error}</div>}
</div>
)}
</FieldArray>
<Button
variant="outline"
onClick={() => insert(form, 'conditions', { value: { field: 'name', operator: 'contains', value: '' } })}
class="gap-2 mt-2"
>
<div class="i-tabler-plus size-4"></div>
{t('tagging-rules.form.conditions.add-condition')}
</Button>
<Separator class="my-6" />
<p class="mb-1 font-medium">{t('tagging-rules.form.tags.label')}</p>
<p class="mb-2 text-sm text-muted-foreground">{t('tagging-rules.form.tags.description')}</p>
<Field name="tagIds" type="string[]">
{field => (
<>
<div class="flex gap-2 sm:items-center sm:flex-row flex-col">
<div class="flex-1">
<DocumentTagPicker
organizationId={props.organizationId}
tagIds={field.value ?? []}
onTagsChange={({ tags }) => setValue(form, 'tagIds', tags.map(tag => tag.id))}
/>
</div>
<CreateTagModal organizationId={props.organizationId}>
{props => (
<Button variant="outline" {...props}>
<div class="i-tabler-plus size-4 mr-2"></div>
{t('tagging-rules.form.tags.add-tag')}
</Button>
)}
</CreateTagModal>
</div>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</>
)}
</Field>
<div class="flex justify-end mt-6 gap-2">
<Show when={props.taggingRule}>
<Button variant="outline" as={A} href={`/organizations/${props.organizationId}/tagging-rules`}>
{t('tagging-rules.update.cancel')}
</Button>
</Show>
<Button type="submit">{props.submitButtonText ?? t('tagging-rules.form.submit')}</Button>
</div>
</Form>
);
};

View File

@@ -0,0 +1,49 @@
import type { Component } from 'solid-js';
import type { TaggingRuleForCreation } from '../tagging-rules.types';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createToast } from '@/modules/ui/components/sonner';
import { useNavigate, useParams } from '@solidjs/router';
import { createMutation } from '@tanstack/solid-query';
import { TaggingRuleForm } from '../components/tagging-rule-form.component';
import { createTaggingRule } from '../tagging-rules.services';
export const CreateTaggingRulePage: Component = () => {
const { t } = useI18n();
const params = useParams();
const navigate = useNavigate();
const createTaggingRuleMutation = createMutation(() => ({
mutationFn: async ({ taggingRule }: { taggingRule: TaggingRuleForCreation }) => {
await createTaggingRule({ taggingRule, organizationId: params.organizationId });
},
onSuccess: () => {
createToast({
message: t('tagging-rules.create.success'),
type: 'success',
});
navigate(`/organizations/${params.organizationId}/tagging-rules`);
},
onError: () => {
createToast({
message: t('tagging-rules.create.error'),
type: 'error',
});
},
}));
return (
<div class="p-6 max-w-screen-md mx-auto mt-4">
<div class="border-b mb-6 pb-4">
<h1 class="text-xl font-bold">
{t('tagging-rules.create.title')}
</h1>
</div>
<TaggingRuleForm
onSubmit={({ taggingRule }) => createTaggingRuleMutation.mutate({ taggingRule })}
organizationId={params.organizationId}
submitButtonText={t('tagging-rules.create.submit')}
/>
</div>
);
};

View File

@@ -0,0 +1,147 @@
import type { Component } from 'solid-js';
import type { TaggingRule } from '../tagging-rules.types';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { queryClient } from '@/modules/shared/query/query-client';
import { Alert } from '@/modules/ui/components/alert';
import { Button } from '@/modules/ui/components/button';
import { EmptyState } from '@/modules/ui/components/empty';
import { A, useParams } from '@solidjs/router';
import { createMutation, createQuery } from '@tanstack/solid-query';
import { For, Match, Show, Switch } from 'solid-js';
import { deleteTaggingRule, fetchTaggingRules } from '../tagging-rules.services';
const TaggingRuleCard: Component<{ taggingRule: TaggingRule }> = (props) => {
const { t } = useI18n();
const getConditionsLabel = () => {
const count = props.taggingRule.conditions.length;
if (count === 0) {
return t('tagging-rules.list.card.no-conditions');
}
if (count === 1) {
return t('tagging-rules.list.card.one-condition');
}
return t('tagging-rules.list.card.conditions', { count });
};
const deleteTaggingRuleMutation = createMutation(() => ({
mutationFn: async () => {
await deleteTaggingRule({ organizationId: props.taggingRule.organizationId, taggingRuleId: props.taggingRule.id });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['organizations', props.taggingRule.organizationId, 'tagging-rules'] });
},
}));
return (
<div class="flex items-center gap-2 bg-card py-4 px-6 rounded-md border">
<A href={`/organizations/${props.taggingRule.organizationId}/tagging-rules/${props.taggingRule.id}`}>
<div class="i-tabler-list-check size-8 opacity-30 mr-2" />
</A>
<div class="flex-1">
<A href={`/organizations/${props.taggingRule.organizationId}/tagging-rules/${props.taggingRule.id}`} class="text-base font-bold">{props.taggingRule.name}</A>
<p class="text-xs text-muted-foreground">
{[getConditionsLabel(), props.taggingRule.description].filter(Boolean).join(' - ')}
</p>
</div>
<div class="flex items-center gap-2">
<Button
as={A}
href={`/organizations/${props.taggingRule.organizationId}/tagging-rules/${props.taggingRule.id}`}
variant="outline"
size="icon"
aria-label={t('tagging-rules.list.card.edit')}
>
<div class="i-tabler-edit size-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => deleteTaggingRuleMutation.mutate()}
disabled={deleteTaggingRuleMutation.isPending}
aria-label={t('tagging-rules.list.card.delete')}
>
<div class="i-tabler-trash size-4" />
</Button>
</div>
</div>
);
};
export const TaggingRulesPage: Component = () => {
const { t } = useI18n();
const { config } = useConfig();
const params = useParams();
const query = createQuery(() => ({
queryKey: ['organizations', params.organizationId, 'tagging-rules'],
queryFn: () => fetchTaggingRules({ organizationId: params.organizationId }),
}));
return (
<div class="p-6 max-w-screen-lg mx-auto mt-4">
<div class="border-b mb-6 pb-4 flex items-center justify-between gap-4 sm:flex-row flex-col">
<div>
<h1 class="text-xl font-bold">
{t('tagging-rules.list.title')}
</h1>
<p class="text-muted-foreground mt-1">
{t('tagging-rules.list.description')}
</p>
</div>
<Show when={query.data?.taggingRules.length}>
<Button as={A} href={`/organizations/${params.organizationId}/tagging-rules/create`} class="flex items-center gap-2 flex-shrink-0 sm:w-auto w-full">
<div class="i-tabler-plus size-4" />
{t('tagging-rules.list.no-tagging-rules.create-tagging-rule')}
</Button>
</Show>
</div>
<Show when={config.isDemoMode}>
<Alert class="bg-primary text-primary-foreground mb-4">
{t('tagging-rules.list.demo-warning')}
</Alert>
</Show>
<Switch>
<Match when={query.data?.taggingRules.length === 0}>
<div class="mt-16">
<EmptyState
title={t('tagging-rules.list.no-tagging-rules.title')}
description={t('tagging-rules.list.no-tagging-rules.description')}
class="pt-0"
icon="i-tabler-list-check"
cta={(
<Button as={A} href={`/organizations/${params.organizationId}/tagging-rules/create`}>
<div class="i-tabler-plus size-4 mr-2" />
{t('tagging-rules.list.no-tagging-rules.create-tagging-rule')}
</Button>
)}
/>
</div>
</Match>
<Match when={query.data?.taggingRules.length}>
<div class="flex flex-col gap-2">
<For each={query.data?.taggingRules}>
{taggingRule => <TaggingRuleCard taggingRule={taggingRule} />}
</For>
</div>
</Match>
</Switch>
</div>
);
};

View File

@@ -0,0 +1,64 @@
import type { Component } from 'solid-js';
import type { TaggingRuleForCreation } from '../tagging-rules.types';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { queryClient } from '@/modules/shared/query/query-client';
import { createToast } from '@/modules/ui/components/sonner';
import { useNavigate, useParams } from '@solidjs/router';
import { createMutation, createQuery } from '@tanstack/solid-query';
import { Show } from 'solid-js';
import { TaggingRuleForm } from '../components/tagging-rule-form.component';
import { getTaggingRule, updateTaggingRule } from '../tagging-rules.services';
export const UpdateTaggingRulePage: Component = () => {
const { t } = useI18n();
const params = useParams();
const navigate = useNavigate();
const query = createQuery(() => ({
queryKey: ['organizations', params.organizationId, 'tagging-rules', params.taggingRuleId],
queryFn: () => getTaggingRule({ organizationId: params.organizationId, taggingRuleId: params.taggingRuleId }),
}));
const updateTaggingRuleMutation = createMutation(() => ({
mutationFn: async ({ taggingRule }: { taggingRule: TaggingRuleForCreation }) => {
await updateTaggingRule({ organizationId: params.organizationId, taggingRuleId: params.taggingRuleId, taggingRule });
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['organizations', params.organizationId, 'tagging-rules'] });
createToast({
message: t('tagging-rules.create.success'),
type: 'success',
});
navigate(`/organizations/${params.organizationId}/tagging-rules`);
},
onError: () => {
createToast({
message: t('tagging-rules.update.error'),
type: 'error',
});
},
}));
return (
<div class="p-6 max-w-screen-md mx-auto mt-4">
<div class="border-b mb-6 pb-4">
<h1 class="text-xl font-bold">
{t('tagging-rules.update.title')}
</h1>
</div>
<Show when={query.data?.taggingRule}>
{getTaggingRule => (
<TaggingRuleForm
onSubmit={({ taggingRule }) => updateTaggingRuleMutation.mutate({ taggingRule })}
organizationId={params.organizationId}
taggingRule={getTaggingRule()}
submitButtonText={t('tagging-rules.update.submit')}
/>
)}
</Show>
</div>
);
};

View File

@@ -0,0 +1,29 @@
import type { LocaleKeys } from '../i18n/locales.types';
export const TAGGING_RULE_OPERATORS = {
EQUAL: 'equal',
NOT_EQUAL: 'not_equal',
CONTAINS: 'contains',
NOT_CONTAINS: 'not_contains',
STARTS_WITH: 'starts_with',
ENDS_WITH: 'ends_with',
} as const;
export const TAGGING_RULE_FIELDS = {
DOCUMENT_NAME: 'name',
DOCUMENT_CONTENT: 'content',
} as const;
export const TAGGING_RULE_OPERATORS_LOCALIZATION_KEYS: Record<(typeof TAGGING_RULE_OPERATORS)[keyof typeof TAGGING_RULE_OPERATORS], LocaleKeys> = {
[TAGGING_RULE_OPERATORS.EQUAL]: 'tagging-rules.operator.equals',
[TAGGING_RULE_OPERATORS.NOT_EQUAL]: 'tagging-rules.operator.not-equals',
[TAGGING_RULE_OPERATORS.CONTAINS]: 'tagging-rules.operator.contains',
[TAGGING_RULE_OPERATORS.NOT_CONTAINS]: 'tagging-rules.operator.not-contains',
[TAGGING_RULE_OPERATORS.STARTS_WITH]: 'tagging-rules.operator.starts-with',
[TAGGING_RULE_OPERATORS.ENDS_WITH]: 'tagging-rules.operator.ends-with',
} as const;
export const TAGGING_RULE_FIELDS_LOCALIZATION_KEYS: Record<(typeof TAGGING_RULE_FIELDS)[keyof typeof TAGGING_RULE_FIELDS], LocaleKeys> = {
[TAGGING_RULE_FIELDS.DOCUMENT_NAME]: 'tagging-rules.field.name',
[TAGGING_RULE_FIELDS.DOCUMENT_CONTENT]: 'tagging-rules.field.content',
} as const;

View File

@@ -0,0 +1,45 @@
import type { AsDto } from '../shared/http/http-client.types';
import type { TaggingRule, TaggingRuleForCreation } from './tagging-rules.types';
import { apiClient } from '../shared/http/api-client';
import { coerceDates } from '../shared/http/http-client.models';
export async function fetchTaggingRules({ organizationId }: { organizationId: string }) {
const { taggingRules } = await apiClient<{ taggingRules: AsDto<TaggingRule>[] }>({
path: `/api/organizations/${organizationId}/tagging-rules`,
method: 'GET',
});
return { taggingRules: taggingRules.map(coerceDates) };
}
export async function createTaggingRule({ taggingRule, organizationId }: { taggingRule: TaggingRuleForCreation; organizationId: string }) {
await apiClient({
path: `/api/organizations/${organizationId}/tagging-rules`,
method: 'POST',
body: taggingRule,
});
}
export async function deleteTaggingRule({ organizationId, taggingRuleId }: { organizationId: string; taggingRuleId: string }) {
await apiClient({
path: `/api/organizations/${organizationId}/tagging-rules/${taggingRuleId}`,
method: 'DELETE',
});
}
export async function getTaggingRule({ organizationId, taggingRuleId }: { organizationId: string; taggingRuleId: string }) {
const { taggingRule } = await apiClient<{ taggingRule: AsDto<TaggingRule> }>({
path: `/api/organizations/${organizationId}/tagging-rules/${taggingRuleId}`,
method: 'GET',
});
return { taggingRule: coerceDates(taggingRule) };
}
export async function updateTaggingRule({ organizationId, taggingRuleId, taggingRule }: { organizationId: string; taggingRuleId: string; taggingRule: TaggingRuleForCreation }) {
await apiClient({
path: `/api/organizations/${organizationId}/tagging-rules/${taggingRuleId}`,
method: 'PUT',
body: taggingRule,
});
}

View File

@@ -0,0 +1,25 @@
import type { TAGGING_RULE_FIELDS, TAGGING_RULE_OPERATORS } from './tagging-rules.constants';
export type TaggingRuleForCreation = {
name: string;
description: string;
conditions: TaggingRuleCondition[];
tagIds: string[];
};
export type TaggingRuleCondition = {
field: (typeof TAGGING_RULE_FIELDS)[keyof typeof TAGGING_RULE_FIELDS];
operator: (typeof TAGGING_RULE_OPERATORS)[keyof typeof TAGGING_RULE_OPERATORS];
value: string;
};
export type TaggingRule = {
id: string;
name: string;
description: string;
conditions: TaggingRuleCondition[];
actions: { tagId: string }[];
organizationId: string;
createdAt: Date;
updatedAt: Date;
};

View File

@@ -1,19 +1,19 @@
import type { Component } from 'solid-js';
import type { Tag } from '../tags.types';
import { Combobox, ComboboxContent, ComboboxInput, ComboboxItem, ComboboxTrigger } from '@/modules/ui/components/combobox';
import { createQuery } from '@tanstack/solid-query';
import { type Component, createSignal, For } from 'solid-js';
import { createSignal, For } from 'solid-js';
import { fetchTags } from '../tags.services';
import { Tag as TagComponent } from './tag.component';
export const DocumentTagPicker: Component<{
organizationId: string;
tags: Tag[];
documentId: string;
tagIds: string[];
onTagsChange?: (args: { tags: Tag[] }) => void;
onTagAdded?: (args: { tag: Tag }) => void;
onTagRemoved?: (args: { tag: Tag }) => void;
}> = (props) => {
const [getSelectedTags, setSelectedTags] = createSignal<Tag[]>(props.tags);
const [getSelectedTagIds, setSelectedTagIds] = createSignal<string[]>(props.tagIds);
const query = createQuery(() => ({
queryKey: ['organizations', props.organizationId, 'tags'],
@@ -22,6 +22,9 @@ export const DocumentTagPicker: Component<{
const options = () => query.data?.tags || [];
const getSelectedTags = () => query.data?.tags.filter(tag => getSelectedTagIds().includes(tag.id)) ?? [];
const setSelectedTags = (tags: Tag[]) => setSelectedTagIds(tags.map(tag => tag.id));
return (
<Combobox<Tag>
options={options()}
@@ -47,13 +50,13 @@ export const DocumentTagPicker: Component<{
>
<ComboboxTrigger displayMultipleState={state => (
<span class="flex flex-wrap items-center gap-1">
<span class="flex flex-wrap items-center gap-1 flex-1">
<For each={state.selectedOptions() as Tag[]}>
{tag => (
<TagComponent name={tag.name} color={tag.color} class="text-xs my-1" closable onClose={() => state.remove(tag)} />
)}
</For>
<ComboboxInput />
<ComboboxInput class="py-2" />
</span>

View File

@@ -1,6 +1,7 @@
import type { Component, ComponentProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { A } from '@solidjs/router';
import { type Component, type ComponentProps, splitProps } from 'solid-js';
import { splitProps } from 'solid-js';
type TagProps = {
name?: string;

View File

@@ -1,4 +1,5 @@
import type { DialogTriggerProps } from '@kobalte/core/dialog';
import type { Component, JSX } from 'solid-js';
import type { Tag as TagType } from '../tags.types';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { useConfirmModal } from '@/modules/shared/confirm';
@@ -15,7 +16,7 @@ import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/component
import { getValues } from '@modular-forms/solid';
import { A, useParams } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { type Component, createSignal, For, type JSX, Show, Suspense } from 'solid-js';
import { createSignal, For, Show, Suspense } from 'solid-js';
import * as v from 'valibot';
import { Tag } from '../components/tag.component';
import { createTag, deleteTag, fetchTags, updateTag } from '../tags.services';
@@ -104,7 +105,7 @@ const TagForm: Component<{
);
};
const CreateTagModal: Component<{
export const CreateTagModal: Component<{
children: (props: DialogTriggerProps) => JSX.Element;
organizationId: string;
}> = (props) => {

View File

@@ -1,9 +1,10 @@
import type { AsDto } from '../shared/http/http-client.types';
import type { Tag } from './tags.types';
import { apiClient } from '../shared/http/api-client';
import { coerceDates } from '../shared/http/http-client.models';
export async function fetchTags({ organizationId }: { organizationId: string }) {
const { tags } = await apiClient<{ tags: Tag[] }>({
const { tags } = await apiClient<{ tags: AsDto<Tag>[] }>({
path: `/api/organizations/${organizationId}/tags`,
method: 'GET',
});
@@ -14,7 +15,7 @@ export async function fetchTags({ organizationId }: { organizationId: string })
}
export async function createTag({ organizationId, name, color, description }: { organizationId: string; name: string; color: string; description: string }) {
const { tag } = await apiClient<{ tag: Tag }>({
const { tag } = await apiClient<{ tag: AsDto<Tag> }>({
path: `/api/organizations/${organizationId}/tags`,
method: 'POST',
body: { name, color, description },
@@ -26,7 +27,7 @@ export async function createTag({ organizationId, name, color, description }: {
}
export async function updateTag({ organizationId, tagId, name, color, description }: { organizationId: string; tagId: string; name: string; color: string; description: string }) {
const { tag } = await apiClient<{ tag: Tag }>({
const { tag } = await apiClient<{ tag: AsDto<Tag> }>({
path: `/api/organizations/${organizationId}/tags/${tagId}`,
method: 'PUT',
body: { name, color, description },

View File

@@ -1,6 +1,7 @@
import type { Component } from 'solid-js';
import { useSession } from '@/modules/auth/auth.services';
import { buildTimeConfig } from '@/modules/config/config';
import { type Component, createEffect } from 'solid-js';
import { createEffect } from 'solid-js';
import { trackingServices } from '../tracking.services';
export const IdentifyUser: Component = () => {

View File

@@ -1,5 +1,6 @@
import type { Component } from 'solid-js';
import { useCurrentMatches } from '@solidjs/router';
import { type Component, createEffect, on } from 'solid-js';
import { createEffect, on } from 'solid-js';
import { trackingServices } from '../tracking.services';
export const PageViewTracker: Component = () => {

View File

@@ -1,7 +1,8 @@
import type { VariantProps } from 'class-variance-authority';
import type { ComponentProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { cva } from 'class-variance-authority';
import { type ComponentProps, splitProps } from 'solid-js';
import { splitProps } from 'solid-js';
export const badgeVariants = cva(
'inline-flex items-center rounded-md px-2.5 py-0.5 text-xs font-semibold transition-shadow focus-visible:(outline-none ring-1.5 ring-ring)',

View File

@@ -140,7 +140,7 @@ export function DropdownMenuShortcut(props: ComponentProps<'span'>) {
);
}
type dropdownMenuSubTriggerProps<T extends ValidComponent = 'div'> = ParentProps< DropdownMenuSubTriggerProps<T> & { class?: string }>;
type dropdownMenuSubTriggerProps<T extends ValidComponent = 'div'> = ParentProps<DropdownMenuSubTriggerProps<T> & { class?: string }>;
export function DropdownMenuSubTrigger<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, dropdownMenuSubTriggerProps<T>>) {
const [local, rest] = splitProps(props as dropdownMenuSubTriggerProps, [
@@ -201,7 +201,7 @@ export function DropdownMenuSubContent<T extends ValidComponent = 'div'>(props:
);
}
type dropdownMenuCheckboxItemProps<T extends ValidComponent = 'div'> = ParentProps< DropdownMenuCheckboxItemProps<T> & { class?: string } >;
type dropdownMenuCheckboxItemProps<T extends ValidComponent = 'div'> = ParentProps<DropdownMenuCheckboxItemProps<T> & { class?: string }>;
export function DropdownMenuCheckboxItem<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, dropdownMenuCheckboxItemProps<T>>) {
const [local, rest] = splitProps(props as dropdownMenuCheckboxItemProps, [

View File

@@ -1,5 +1,6 @@
import type { Component, ComponentProps, JSX } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { type Component, type ComponentProps, type JSX, splitProps } from 'solid-js';
import { splitProps } from 'solid-js';
export const EmptyState: Component<{
title: JSX.Element;

View File

@@ -138,9 +138,9 @@ export function NumberFieldDecrementTrigger<T extends ValidComponent = 'button',
);
}
type numberFieldIncrementTriggerProps<T extends ValidComponent = 'button'> = VoidProps< NumberFieldIncrementTriggerProps<T> & { class?: string }>;
type numberFieldIncrementTriggerProps<T extends ValidComponent = 'button'> = VoidProps<NumberFieldIncrementTriggerProps<T> & { class?: string }>;
export function NumberFieldIncrementTrigger< T extends ValidComponent = 'button',>(props: PolymorphicProps<T, numberFieldIncrementTriggerProps<T>>) {
export function NumberFieldIncrementTrigger<T extends ValidComponent = 'button',>(props: PolymorphicProps<T, numberFieldIncrementTriggerProps<T>>) {
const [local, rest] = splitProps(props as numberFieldIncrementTriggerProps, ['class']);
return (

View File

@@ -1,5 +1,6 @@
import type { ComponentProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { type ComponentProps, splitProps } from 'solid-js';
import { splitProps } from 'solid-js';
export function Skeleton(props: ComponentProps<'div'>) {
const [local, rest] = splitProps(props, ['class']);

Some files were not shown because too many files have changed in this diff Show More