mirror of
https://github.com/papra-hq/papra.git
synced 2025-12-17 12:15:22 -06:00
Compare commits
53 Commits
v0.2.0
...
@papra/doc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
249b3bcfd2 | ||
|
|
d7838b5d57 | ||
|
|
f170ddd817 | ||
|
|
4f53c70854 | ||
|
|
85fa5c4342 | ||
|
|
c5d984a3a0 | ||
|
|
565bd8d7fd | ||
|
|
9b72aa886c | ||
|
|
7410455093 | ||
|
|
dd8f194fd0 | ||
|
|
803c39cbc8 | ||
|
|
096331a4ee | ||
|
|
59ba9465f6 | ||
|
|
a1056702af | ||
|
|
fd44897bca | ||
|
|
332d836d11 | ||
|
|
f613198cbd | ||
|
|
80491a5a58 | ||
|
|
605e21a410 | ||
|
|
dec589b6ed | ||
|
|
c0bd6e2ae4 | ||
|
|
6287aaa973 | ||
|
|
cc2edc59b0 | ||
|
|
9cba84e38b | ||
|
|
5fe401778d | ||
|
|
38aa1ea7f1 | ||
|
|
ab98c1b255 | ||
|
|
265f06f8b7 | ||
|
|
a787b7915c | ||
|
|
0ba6a09923 | ||
|
|
6880bfd41c | ||
|
|
21a2c95e56 | ||
|
|
19e2083a71 | ||
|
|
e6b2d9fb2d | ||
|
|
5140a64c40 | ||
|
|
9ddb7d545d | ||
|
|
2a73551ca4 | ||
|
|
7be56455b0 | ||
|
|
1085bf079c | ||
|
|
b13986e1e3 | ||
|
|
d4462f942b | ||
|
|
2f2ad90fd3 | ||
|
|
2bbb68aa17 | ||
|
|
2b2827cdb3 | ||
|
|
4b4621e4d0 | ||
|
|
fd0f79feb0 | ||
|
|
b9c2448805 | ||
|
|
542225fabc | ||
|
|
e4af2653ea | ||
|
|
4dd15527c0 | ||
|
|
ae0f69043d | ||
|
|
79eafdb3ee | ||
|
|
979df5dad8 |
8
.changeset/README.md
Normal file
8
.changeset/README.md
Normal 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
19
.changeset/config.json
Normal 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
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: 💬 Discord Community
|
||||
url: https://papra.app/discord
|
||||
about: Join the Papra Discord community to get help, share your feedback, and stay updated on the project.
|
||||
BIN
.github/papra-screenshot.png
vendored
BIN
.github/papra-screenshot.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 151 KiB |
11
.github/workflows/ci-apps-papra-client.yaml
vendored
11
.github/workflows/ci-apps-papra-client.yaml
vendored
@@ -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
|
||||
|
||||
|
||||
4
.github/workflows/ci-apps-papra-server.yaml
vendored
4
.github/workflows/ci-apps-papra-server.yaml
vendored
@@ -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
|
||||
|
||||
41
.github/workflows/ci-packages-api-sdk.yaml
vendored
Normal file
41
.github/workflows/ci-packages-api-sdk.yaml
vendored
Normal 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
44
.github/workflows/ci-packages-cli.yaml
vendored
Normal 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
|
||||
41
.github/workflows/ci-packages-webhook.yaml
vendored
Normal file
41
.github/workflows/ci-packages-webhook.yaml
vendored
Normal 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
|
||||
9
.github/workflows/release-docker.yaml
vendored
9
.github/workflows/release-docker.yaml
vendored
@@ -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
43
.github/workflows/release.yml
vendored
Normal 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
1
.gitignore
vendored
@@ -37,5 +37,6 @@ cache
|
||||
*.sqlite
|
||||
|
||||
local-documents
|
||||
ingestion
|
||||
.cursorrules
|
||||
*.traineddata
|
||||
1
CODEOWNERS
Normal file
1
CODEOWNERS
Normal file
@@ -0,0 +1 @@
|
||||
* @papra-hq/papra-maintainers
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<span> • </span>
|
||||
<a href="https://github.com/orgs/papra-hq/projects/2">Roadmap</a>
|
||||
<span> • </span>
|
||||
<a href="https://discord.gg/8UPjzsrBNF">Discord</a>
|
||||
<a href="https://papra.app/discord">Discord</a>
|
||||
<!-- <span> • </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
7
apps/docs/CHANGELOG.md
Normal 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
|
||||
@@ -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'],
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
```
|
||||
111
apps/docs/src/content/docs/02-self-hosting/01-using-docker.mdx
Normal file
111
apps/docs/src/content/docs/02-self-hosting/01-using-docker.mdx
Normal file
@@ -0,0 +1,111 @@
|
||||
---
|
||||
title: Installing Papra using Docker
|
||||
description: Self-host Papra using Docker.
|
||||
slug: self-hosting/using-docker
|
||||
---
|
||||
import { Steps } from '@astrojs/starlight/components';
|
||||
|
||||
Papra provides optimized Docker images for streamlined deployment. This method is recommended for users seeking a production-ready setup with minimal maintenance overhead.
|
||||
|
||||
- **Simplified management**: Single container handles all components
|
||||
- **Lightweight**: Optimized image sizes across architectures
|
||||
- **Cross-platform support**: Compatible with `arm64`, `arm/v7`, and `x86_64` systems
|
||||
- **Security options**: Supports both rootless (recommended) and rootful configurations
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Ensure Docker is installed on your host system. Official installation guides are available at:
|
||||
[docker.com/get-started](https://www.docker.com/get-started)
|
||||
|
||||
Verify Docker installation with:
|
||||
|
||||
```bash
|
||||
docker --version
|
||||
```
|
||||
|
||||
## Quick Deployment
|
||||
|
||||
Launch Papra with default configuration using:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name papra \
|
||||
--restart unless-stopped \
|
||||
-p 1221:1221 \
|
||||
ghcr.io/papra-hq/papra:latest
|
||||
```
|
||||
|
||||
This command will:
|
||||
1. Pull the latest rootless image from GitHub Container Registry
|
||||
2. Expose the web interface on [http://localhost:1221](http://localhost:1221)
|
||||
3. Configure automatic restarts for service continuity
|
||||
|
||||
## Image Variants
|
||||
|
||||
Choose between two security models based on your requirements:
|
||||
|
||||
- **Rootless**: Tagged as `latest`, `latest-rootless` or `<version>-rootless` (like `0.2.1-rootless`). Recommended for most users.
|
||||
- **Root**: Tagged as `latest-root` or `<version>-root` (like `0.2.1-root`). Only use if you need to run Papra as the root user.
|
||||
|
||||
The `:latest` tag always references the latest rootless build.
|
||||
|
||||
## Persistent Data Configuration
|
||||
|
||||
For production deployments, mount host directories to preserve application data between container updates.
|
||||
|
||||
<Steps>
|
||||
|
||||
1. Create Storage Directories
|
||||
|
||||
Create a directory for Papra data `./papra-data`, with `./papra-data/db` and `./papra-data/documents` subdirectories:
|
||||
|
||||
```bash
|
||||
mkdir -p ./papra-data/{db,documents}
|
||||
```
|
||||
|
||||
2. Launch Container with Volume Binding
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name papra \
|
||||
--restart unless-stopped \
|
||||
-p 1221:1221 \
|
||||
-v $(pwd)/papra-data:/app/app-data \
|
||||
--user $(id -u):$(id -g) \
|
||||
ghcr.io/papra-hq/papra:latest
|
||||
```
|
||||
|
||||
This configuration:
|
||||
- Maintains data integrity across container lifecycle events
|
||||
- Enforces proper file ownership without manual permission adjustments
|
||||
- Stores both database files and document assets persistently
|
||||
|
||||
</Steps>
|
||||
|
||||
## Image Registries
|
||||
|
||||
Papra images are distributed through multiple channels:
|
||||
|
||||
**Primary Source (GHCR):**
|
||||
```bash
|
||||
docker pull ghcr.io/papra-hq/papra:latest
|
||||
docker pull ghcr.io/papra-hq/papra:latest-rootless
|
||||
docker pull ghcr.io/papra-hq/papra:latest-root
|
||||
```
|
||||
|
||||
**Community Mirror (Docker Hub):**
|
||||
```bash
|
||||
docker pull corentinth/papra:latest
|
||||
docker pull corentinth/papra:latest-rootless
|
||||
docker pull corentinth/papra:latest-root
|
||||
```
|
||||
|
||||
## Updating Papra
|
||||
|
||||
Regularly pull updated images and recreate containers to receive security patches and feature updates.
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/papra-hq/papra:latest
|
||||
# Or
|
||||
docker pull corentinth/papra:latest
|
||||
```
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
89
apps/docs/src/content/docs/04-resources/01-cli.mdx
Normal file
89
apps/docs/src/content/docs/04-resources/01-cli.mdx
Normal 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).
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
23
apps/papra-client/CHANGELOG.md
Normal file
23
apps/papra-client/CHANGELOG.md
Normal 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
28
apps/papra-client/src/modules/api-keys/api-keys.constants.ts
Normal file
28
apps/papra-client/src/modules/api-keys/api-keys.constants.ts
Normal 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);
|
||||
56
apps/papra-client/src/modules/api-keys/api-keys.services.ts
Normal file
56
apps/papra-client/src/modules/api-keys/api-keys.services.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
12
apps/papra-client/src/modules/api-keys/api-keys.types.ts
Normal file
12
apps/papra-client/src/modules/api-keys/api-keys.types.ts
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
140
apps/papra-client/src/modules/api-keys/pages/api-keys.page.tsx
Normal file
140
apps/papra-client/src/modules/api-keys/pages/api-keys.page.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { ParentComponent } from 'solid-js';
|
||||
import type { Document } from '../documents.types';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { promptUploadFiles } from '@/modules/shared/files/upload';
|
||||
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { A } from '@solidjs/router';
|
||||
import { throttle } from 'lodash-es';
|
||||
import { createContext, createSignal, For, Match, Show, Switch, useContext } from 'solid-js';
|
||||
import { Portal } from 'solid-js/web';
|
||||
import { invalidateOrganizationDocumentsQuery } from '../documents.composables';
|
||||
import { uploadDocument } from '../documents.services';
|
||||
|
||||
const DocumentUploadContext = createContext<{
|
||||
uploadDocuments: (args: { files: File[]; organizationId: string }) => Promise<void>;
|
||||
}>();
|
||||
|
||||
export function useDocumentUpload({ organizationId }: { organizationId: string }) {
|
||||
const context = useContext(DocumentUploadContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('DocumentUploadContext not found');
|
||||
}
|
||||
|
||||
const { uploadDocuments } = context;
|
||||
|
||||
return {
|
||||
uploadDocuments: async ({ files }: { files: File[] }) => uploadDocuments({ files, organizationId }),
|
||||
promptImport: async () => {
|
||||
const { files } = await promptUploadFiles();
|
||||
|
||||
await uploadDocuments({ files, organizationId });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type TaskSuccess = {
|
||||
file: File;
|
||||
status: 'success';
|
||||
document: Document;
|
||||
};
|
||||
|
||||
type TaskError = {
|
||||
file: File;
|
||||
status: 'error';
|
||||
error: Error;
|
||||
};
|
||||
|
||||
type Task = TaskSuccess | TaskError | {
|
||||
file: File;
|
||||
status: 'pending' | 'uploading';
|
||||
};
|
||||
|
||||
export const DocumentUploadProvider: ParentComponent = (props) => {
|
||||
const throttledInvalidateOrganizationDocumentsQuery = throttle(invalidateOrganizationDocumentsQuery, 500);
|
||||
const { getErrorMessage } = useI18nApiErrors();
|
||||
const { t } = useI18n();
|
||||
|
||||
const [getState, setState] = createSignal<'open' | 'closed' | 'collapsed'>('closed');
|
||||
const [getTasks, setTasks] = createSignal<Task[]>([]);
|
||||
|
||||
const updateTaskStatus = (args: { file: File; status: 'success'; document: Document } | { file: File; status: 'error'; error: Error } | { file: File; status: 'pending' | 'uploading' }) => {
|
||||
setTasks(tasks => tasks.map(task => task.file === args.file ? { ...task, ...args } : task));
|
||||
};
|
||||
|
||||
const uploadDocuments = async ({ files, organizationId }: { files: File[]; organizationId: string }) => {
|
||||
setTasks(tasks => [...tasks, ...files.map(file => ({ file, status: 'pending' } as const))]);
|
||||
setState('open');
|
||||
|
||||
await Promise.all(files.map(async (file) => {
|
||||
updateTaskStatus({ file, status: 'uploading' });
|
||||
|
||||
const [result, error] = await safely(uploadDocument({ file, organizationId }));
|
||||
|
||||
if (error) {
|
||||
updateTaskStatus({ file, status: 'error', error });
|
||||
} else {
|
||||
const { document } = result;
|
||||
|
||||
updateTaskStatus({ file, status: 'success', document });
|
||||
}
|
||||
|
||||
await throttledInvalidateOrganizationDocumentsQuery({ organizationId });
|
||||
}));
|
||||
};
|
||||
|
||||
const getTitle = () => {
|
||||
if (getTasks().length === 0) {
|
||||
return t('import-documents.title.none');
|
||||
}
|
||||
|
||||
const successCount = getTasks().filter(task => task.status === 'success').length;
|
||||
const errorCount = getTasks().filter(task => task.status === 'error').length;
|
||||
const totalCount = getTasks().length;
|
||||
|
||||
if (errorCount > 0) {
|
||||
return t('import-documents.title.error', { count: errorCount });
|
||||
}
|
||||
|
||||
if (successCount === totalCount) {
|
||||
return t('import-documents.title.success', { count: successCount });
|
||||
}
|
||||
|
||||
return t('import-documents.title.pending', { count: successCount, total: totalCount });
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setState('closed');
|
||||
setTasks([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<DocumentUploadContext.Provider value={{ uploadDocuments }}>
|
||||
{props.children}
|
||||
|
||||
<Portal>
|
||||
<Show when={getState() !== 'closed'}>
|
||||
<div class="fixed bottom-0 right-0 sm:right-20px w-full sm:w-400px bg-card border-l border-t border-r sm:rounded-t-xl shadow-lg">
|
||||
<div class="flex items-center gap-1 pl-6 pr-4 py-3 border-b">
|
||||
<h2 class="text-base font-bold flex-1">{getTitle()}</h2>
|
||||
|
||||
<Button variant="ghost" size="icon" onClick={() => setState(state => state === 'open' ? 'collapsed' : 'open')}>
|
||||
<div class={cn('i-tabler-chevron-down size-5 transition-transform', getState() === 'collapsed' && 'rotate-180')} />
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="icon" onClick={close}>
|
||||
<div class="i-tabler-x size-5"></div>
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
|
||||
<Show when={getState() === 'open'}>
|
||||
<div class="flex flex-col overflow-y-auto h-[450px] pb-4">
|
||||
<For each={getTasks()}>
|
||||
{task => (
|
||||
|
||||
<Switch>
|
||||
<Match when={task.status === 'success'}>
|
||||
<A
|
||||
href={`/organizations/${(task as TaskSuccess).document.organizationId}/documents/${(task as TaskSuccess).document.id}`}
|
||||
class="text-sm truncate min-w-0 flex items-center gap-4 min-h-48px group hover:bg-muted/50 transition-colors px-6 border-b border-border/80"
|
||||
>
|
||||
<div class="flex-1 truncate">
|
||||
{task.file.name}
|
||||
</div>
|
||||
|
||||
<div class="flex-none">
|
||||
<div class="i-tabler-circle-check text-primary size-5.5 group-hover:hidden"></div>
|
||||
<div class="i-tabler-arrow-right text-muted-foreground size-5.5 hidden group-hover:block"></div>
|
||||
</div>
|
||||
</A>
|
||||
</Match>
|
||||
|
||||
<Match when={task.status === 'error'}>
|
||||
<div class="text-sm truncate min-w-0 flex items-center gap-4 min-h-48px px-6 border-b border-border/80">
|
||||
<div class="flex-1 truncate">
|
||||
<div class="flex-1 truncate">{task.file.name}</div>
|
||||
|
||||
<div class="text-xs text-muted-foreground truncate text-red-500">
|
||||
{getErrorMessage({ error: (task as TaskError).error })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-none">
|
||||
<div class="i-tabler-circle-x text-red-500 size-5.5"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
|
||||
<Match when={['pending', 'uploading'].includes(task.status)}>
|
||||
<div class="text-sm truncate min-w-0 flex items-center gap-4 min-h-48px px-6 border-b border-border/80">
|
||||
<div class="flex-1 truncate">
|
||||
{task.file.name}
|
||||
</div>
|
||||
|
||||
<div class="flex-none">
|
||||
<div class="i-tabler-loader-2 animate-spin text-muted-foreground size-5.5"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show when={getTasks().length === 0}>
|
||||
<div class="flex flex-col items-center justify-center gap-2 h-full mb-10">
|
||||
<div class="flex flex-col items-center justify-center gap-2 ">
|
||||
<div class="i-tabler-file-import size-10 text-muted-foreground"></div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-muted-foreground text-center mt-2">
|
||||
{t('import-documents.no-import-in-progress')}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
</Show>
|
||||
|
||||
</div>
|
||||
</Show>
|
||||
</Portal>
|
||||
</DocumentUploadContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,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>
|
||||
);
|
||||
|
||||
@@ -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(' - ')}
|
||||
{' '}
|
||||
-
|
||||
{' '}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,5 +11,6 @@ export type Document = {
|
||||
isDeleted?: boolean;
|
||||
deletedAt?: Date;
|
||||
deletedBy?: string;
|
||||
content: string;
|
||||
tags: Tag[];
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const locales = [
|
||||
{ key: 'en', name: 'English' },
|
||||
{ key: 'fr', name: 'Français' },
|
||||
];
|
||||
] as const;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -6,5 +6,5 @@ export type IntakeEmail = {
|
||||
allowedOrigins: string[];
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date | undefined;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@ export type Organization = {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
updatedAt?: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FormProps, PartialValues } from '@modular-forms/solid';
|
||||
import type { FormErrors, FormProps, PartialValues } from '@modular-forms/solid';
|
||||
import type * as v from 'valibot';
|
||||
import { createForm as createModularForm, valiForm } from '@modular-forms/solid';
|
||||
import { createForm as createModularForm, FormError, valiForm } from '@modular-forms/solid';
|
||||
import { createHook } from '../hooks/hooks';
|
||||
|
||||
export function createForm<Schema extends v.ObjectSchema<any, any>>({
|
||||
@@ -18,7 +18,7 @@ export function createForm<Schema extends v.ObjectSchema<any, any>>({
|
||||
submitHook.on(onSubmit);
|
||||
}
|
||||
|
||||
const [form, { Form, Field }] = createModularForm<v.InferInput<Schema>>({
|
||||
const [form, { Form, Field, FieldArray }] = createModularForm<v.InferInput<Schema>>({
|
||||
validate: valiForm(schema),
|
||||
initialValues,
|
||||
});
|
||||
@@ -27,7 +27,9 @@ export function createForm<Schema extends v.ObjectSchema<any, any>>({
|
||||
form,
|
||||
Form: (props: Omit<FormProps<v.InferInput<Schema>, undefined>, 'of'>) => Form({ ...props, onSubmit: submitHook.trigger }),
|
||||
Field,
|
||||
FieldArray,
|
||||
onSubmit: submitHook.on,
|
||||
submit: submitHook.trigger,
|
||||
createFormError: ({ message, fields }: { message: string; fields?: FormErrors<v.InferInput<Schema>> }) => new FormError<v.InferInput<Schema>>(message, fields),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,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'>({
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}>;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user