mirror of
https://github.com/papra-hq/papra.git
synced 2025-12-17 03:51:45 -06:00
Compare commits
47 Commits
@papra/app
...
@papra/app
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62b7f0382c | ||
|
|
57c6a26657 | ||
|
|
b8c2bd70e3 | ||
|
|
0c2cf698d1 | ||
|
|
585c53cd9d | ||
|
|
f035458e16 | ||
|
|
556fd8b167 | ||
|
|
81e85295ba | ||
|
|
1c574b8305 | ||
|
|
ff830c234a | ||
|
|
451564f354 | ||
|
|
ecd6af45c8 | ||
|
|
cb652c7166 | ||
|
|
17ca8f8f81 | ||
|
|
f54b8e162a | ||
|
|
6b435bba79 | ||
|
|
8ccdb74834 | ||
|
|
60059c895c | ||
|
|
6e22a93dff | ||
|
|
79c1d3206b | ||
|
|
48a953a584 | ||
|
|
fdb90fa164 | ||
|
|
e9a205c0a3 | ||
|
|
278db63fc8 | ||
|
|
e5ef40f36c | ||
|
|
27c9e39422 | ||
|
|
91d2e236d0 | ||
|
|
d4f72e889a | ||
|
|
759a3ff713 | ||
|
|
34862991fb | ||
|
|
f0876fdc63 | ||
|
|
cb38d66485 | ||
|
|
c28af1407f | ||
|
|
b62ddf2bc4 | ||
|
|
fa7909c62d | ||
|
|
1996b51b4d | ||
|
|
734027f00c | ||
|
|
557cde940c | ||
|
|
26a83052bd | ||
|
|
5aac3f7ba6 | ||
|
|
0ddc2340f0 | ||
|
|
438a31171c | ||
|
|
53bf93f128 | ||
|
|
b400b3f18d | ||
|
|
0627ec25a4 | ||
|
|
72e5a9a4de | ||
|
|
268ac8e358 |
20
.github/workflows/release-docker.yaml
vendored
20
.github/workflows/release-docker.yaml
vendored
@@ -1,9 +1,12 @@
|
||||
name: Release new versions
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '@papra/app-server@*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to release'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -14,9 +17,6 @@ jobs:
|
||||
name: Release Docker images
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get release version from tag
|
||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/@papra/app-server@}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
@@ -48,9 +48,9 @@ jobs:
|
||||
push: true
|
||||
tags: |
|
||||
corentinth/papra:latest-root
|
||||
corentinth/papra:${{ env.RELEASE_VERSION }}-root
|
||||
corentinth/papra:${{ inputs.version }}-root
|
||||
ghcr.io/papra-hq/papra:latest-root
|
||||
ghcr.io/papra-hq/papra:${{ env.RELEASE_VERSION }}-root
|
||||
ghcr.io/papra-hq/papra:${{ inputs.version }}-root
|
||||
|
||||
- name: Build and push rootless Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
tags: |
|
||||
corentinth/papra:latest
|
||||
corentinth/papra:latest-rootless
|
||||
corentinth/papra:${{ env.RELEASE_VERSION }}-rootless
|
||||
corentinth/papra:${{ inputs.version }}-rootless
|
||||
ghcr.io/papra-hq/papra:latest
|
||||
ghcr.io/papra-hq/papra:latest-rootless
|
||||
ghcr.io/papra-hq/papra:${{ env.RELEASE_VERSION }}-rootless
|
||||
ghcr.io/papra-hq/papra:${{ inputs.version }}-rootless
|
||||
12
.github/workflows/release.yml
vendored
12
.github/workflows/release.yml
vendored
@@ -15,6 +15,7 @@ jobs:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
actions: write
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
@@ -40,4 +41,13 @@ jobs:
|
||||
title: "chore(release): update versions"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.CHANGESET_GITHUB_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Trigger Docker build
|
||||
if: steps.changesets.outputs.published == 'true' && contains(fromJson(steps.changesets.outputs.publishedPackages).*.name, '@papra/app-server')
|
||||
run: |
|
||||
VERSION=$(echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[] | select(.name=="@papra/app-server") | .version')
|
||||
echo "VERSION: $VERSION"
|
||||
gh workflow run release-docker.yaml -f version="$VERSION"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -60,7 +60,10 @@ 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
> [!TIP]
|
||||
> You can use the command `pnpm script:sync-i18n-key-order` to sync the order of the keys in the i18n files, it'll also add the missing keys as comments.
|
||||
|
||||
## Development Setup
|
||||
|
||||
|
||||
19
README.md
19
README.md
@@ -39,12 +39,9 @@ A live demo of the platform is available at [demo.papra.app](https://demo.papra.
|
||||
|
||||
## Project Status
|
||||
|
||||
Papra is currently in **beta**. The core functionality is stable and usable, but you may encounter occasional bugs or limitations. The project is actively developed, with new features being added regularly.
|
||||
Papra is under active development, the core functionalities are stable and usable. With lots of features and improvements added regularly.
|
||||
|
||||
- ✅ Core document management features are stable
|
||||
- ✅ Self-hosting is fully supported
|
||||
- 🚧 Some advanced features are still in development
|
||||
- 📝 Feedback and bug reports are highly appreciated
|
||||
Feedback and bug reports are highly appreciated to help us improve the platform.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -61,13 +58,19 @@ Papra is currently in **beta**. The core functionality is stable and usable, but
|
||||
- **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:* **SDK and API**: Build your own applications on top of Papra.
|
||||
- *Coming soon:* **CLI**: Manage your documents from the command line.
|
||||
- **CLI**: Manage your documents from the command line.
|
||||
- **API, SDK and webhooks**: Build your own applications on top of Papra.
|
||||
- **i18n**: Support for multiple languages.
|
||||
- *Coming soon:* **Document sharing**: Share documents with others.
|
||||
- *Coming soon:* **Document requests**: Generate upload links for people to add documents.
|
||||
- *Coming maybe one day:* **Mobile app**: Access and upload documents on the go.
|
||||
- *Coming maybe one day:* **Desktop app**: Access and upload documents from your computer.
|
||||
- *Coming maybe one day:* **Browser extension**: Upload documents from your browser.
|
||||
- *Coming maybe one day:* **AI**: Use AI to help you manage or tag your documents.
|
||||
|
||||
## Sponsors
|
||||
|
||||
Papra is a free and open-source project, but it is not free to run and develop. If you want to support the project, you can become a sponsor on [GitHub Sponsors](https://github.com/sponsors/corentinth) or [Buy me a coffee](https://buymeacoffee.com/cthmsst). If you are a company, you can also contact me to discuss a sponsorship.
|
||||
|
||||
## Self-hosting
|
||||
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
# @papra/docs
|
||||
|
||||
## 0.5.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#357](https://github.com/papra-hq/papra/pull/357) [`585c53c`](https://github.com/papra-hq/papra/commit/585c53cd9d0d7dbd517dbb1adddfd9e7b70f9fe5) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added a /llms.txt on main website
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#337](https://github.com/papra-hq/papra/pull/337) [`1c574b8`](https://github.com/papra-hq/papra/commit/1c574b8305eb7bde4f1b75ac38a610ca0120a613) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added troubleshooting page
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#337](https://github.com/papra-hq/papra/pull/337) [`1c574b8`](https://github.com/papra-hq/papra/commit/1c574b8305eb7bde4f1b75ac38a610ca0120a613) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added `docker compose up` command in dc generator
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#322](https://github.com/papra-hq/papra/pull/322) [`f54b8e1`](https://github.com/papra-hq/papra/commit/f54b8e162acd6cfe92241aaa649847fc03ca5852) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Auto computes urls from the provided port
|
||||
|
||||
## 0.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#320](https://github.com/papra-hq/papra/pull/320) [`8ccdb74`](https://github.com/papra-hq/papra/commit/8ccdb748349a3cacf38f032fd4d3beebce202487) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added base url configuration in docker compose generator
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#295](https://github.com/papra-hq/papra/pull/295) [`438a311`](https://github.com/papra-hq/papra/commit/438a31171c606138c4b7fa299fdd58dcbeaaf298) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added support for custom oauth2 providers
|
||||
|
||||
- [#293](https://github.com/papra-hq/papra/pull/293) [`53bf93f`](https://github.com/papra-hq/papra/commit/53bf93f128b54ad1d3553e18680c87ab23155f8d) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added a papra docker-compose.yml generator
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -3,7 +3,9 @@ import starlight from '@astrojs/starlight';
|
||||
import { defineConfig } from 'astro/config';
|
||||
import starlightLinksValidator from 'starlight-links-validator';
|
||||
import starlightThemeRapide from 'starlight-theme-rapide';
|
||||
import UnoCSS from 'unocss/astro';
|
||||
import { sidebar } from './src/content/navigation';
|
||||
|
||||
import posthogRawScript from './src/scripts/posthog.script.js?raw';
|
||||
|
||||
const posthogApiKey = env.POSTHOG_API_KEY;
|
||||
@@ -16,6 +18,7 @@ const posthogScript = posthogRawScript.replace('[POSTHOG-API-KEY]', posthogApiKe
|
||||
export default defineConfig({
|
||||
site: 'https://docs.papra.app',
|
||||
integrations: [
|
||||
UnoCSS(),
|
||||
starlight({
|
||||
plugins: [starlightThemeRapide(), starlightLinksValidator({ exclude: ['http://localhost:1221'] })],
|
||||
title: 'Papra Docs',
|
||||
@@ -38,7 +41,7 @@ export default defineConfig({
|
||||
sidebar,
|
||||
favicon: '/favicon.svg',
|
||||
head: [
|
||||
// Add ICO favicon fallback for Safari.
|
||||
// Add ICO favicon fallback for Safari.
|
||||
{
|
||||
tag: 'link',
|
||||
attrs: {
|
||||
|
||||
1382
apps/docs/package-lock.json
generated
Normal file
1382
apps/docs/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@papra/docs",
|
||||
"type": "module",
|
||||
"version": "0.3.1",
|
||||
"version": "0.5.1",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.9.0",
|
||||
"description": "Papra documentation website",
|
||||
@@ -18,11 +18,16 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/starlight": "^0.34.2",
|
||||
"astro": "^5.7.10",
|
||||
"@astrojs/solid-js": "^5.1.0",
|
||||
"@astrojs/starlight": "^0.34.3",
|
||||
"astro": "^5.8.0",
|
||||
"sharp": "^0.32.5",
|
||||
"shiki": "^3.4.2",
|
||||
"starlight-links-validator": "^0.16.0",
|
||||
"starlight-theme-rapide": "^0.5.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"unocss-preset-animations": "^1.2.1",
|
||||
"yaml": "^2.8.0",
|
||||
"zod-to-json-schema": "^3.24.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -35,6 +40,7 @@
|
||||
"figue": "^2.2.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
"marked": "^15.0.6",
|
||||
"typescript": "^5.7.3"
|
||||
"typescript": "^5.7.3",
|
||||
"unocss": "0.65.0-beta.2"
|
||||
}
|
||||
}
|
||||
|
||||
3
apps/docs/public/_headers
Normal file
3
apps/docs/public/_headers
Normal file
@@ -0,0 +1,3 @@
|
||||
/*
|
||||
X-Frame-Options: DENY
|
||||
X-Content-Type-Options: nosniff
|
||||
@@ -1,4 +1,38 @@
|
||||
:root[data-theme='dark'] {
|
||||
--background: 240 4% 10%;
|
||||
--foreground: 0 0% 98%;
|
||||
|
||||
--card: 240 4% 8%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
|
||||
--popover: 240 4% 8%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
--primary: 77 100% 74%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--warning: 31 98% 50%;
|
||||
--warning-foreground: 0 0% 98%;
|
||||
|
||||
--border: 345 4% 17%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
|
||||
|
||||
--background-color: #0c0d0f!important;
|
||||
--accent-color: #fff!important;
|
||||
--foreground-color: #9ea3a2!important;
|
||||
@@ -55,4 +89,8 @@
|
||||
|
||||
.site-title img {
|
||||
width: 1.8rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
pre.shiki {
|
||||
border-radius: 0.5rem!important;
|
||||
}
|
||||
@@ -33,11 +33,14 @@ const rows = configDetails
|
||||
|
||||
const rawDocumentation = formatDoc(doc);
|
||||
|
||||
// The client baseUrl default value is overridden in the Dockerfiles
|
||||
const defaultOverride = path.join('.') === 'client.baseUrl' ? 'http://localhost:1221' : undefined;
|
||||
|
||||
return {
|
||||
path,
|
||||
env,
|
||||
documentation: rawDocumentation,
|
||||
defaultValue: isEmptyDefaultValue ? undefined : defaultValue,
|
||||
defaultValue: defaultOverride ?? (isEmptyDefaultValue ? undefined : defaultValue),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
title: Using Docker Compose
|
||||
slug: self-hosting/using-docker-compose
|
||||
description: Self-host Papra using Docker Compose.
|
||||
---
|
||||
|
||||
import { Steps } from '@astrojs/starlight/components';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Configuration
|
||||
slug: self-hosting/configuration
|
||||
|
||||
description: Configure your self-hosted Papra instance.
|
||||
---
|
||||
|
||||
import { mdSections, fullDotEnv } from '../../../config.data.ts';
|
||||
|
||||
181
apps/docs/src/content/docs/03-guides/04-setup-custom-oauth2.mdx
Normal file
181
apps/docs/src/content/docs/03-guides/04-setup-custom-oauth2.mdx
Normal file
@@ -0,0 +1,181 @@
|
||||
---
|
||||
title: Setup Custom OAuth2 Providers
|
||||
description: Step-by-step guide to setup custom OAuth2 providers for authentication in your Papra instance.
|
||||
slug: guides/setup-custom-oauth2-providers
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
import { Steps } from '@astrojs/starlight/components';
|
||||
|
||||
This guide will show you how to configure custom OAuth2 providers for authentication in your Papra instance.
|
||||
|
||||
<Aside type="note">
|
||||
Papra's OAuth2 implementation is based on the [Better Auth Generic OAuth plugin](https://www.better-auth.com/docs/plugins/generic-oauth). For more detailed information about the configuration options and advanced usage, please refer to their documentation.
|
||||
</Aside>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
In order to follow this guide, you need:
|
||||
- A custom OAuth2 provider
|
||||
- An accessible Papra instance
|
||||
- Basic understanding of OAuth2 flows
|
||||
|
||||
## Configuration
|
||||
|
||||
To set up custom OAuth2 providers, you'll need to configure the `AUTH_PROVIDERS_CUSTOMS` environment variable with an array of provider configurations. Here's an example:
|
||||
|
||||
```bash
|
||||
AUTH_PROVIDERS_CUSTOMS='[
|
||||
{
|
||||
"providerId": "custom-oauth2",
|
||||
"providerName": "Custom OAuth2",
|
||||
"providerIconUrl": "https://api.iconify.design/tabler:login-2.svg",
|
||||
"clientId": "your-client-id",
|
||||
"clientSecret": "your-client-secret",
|
||||
"type": "oidc",
|
||||
"discoveryUrl": "https://your-provider.tld/.well-known/openid-configuration",
|
||||
"scopes": ["openid", "profile", "email"]
|
||||
}
|
||||
]'
|
||||
```
|
||||
|
||||
Each provider configuration supports the following fields:
|
||||
|
||||
- `providerId`: A unique identifier for the OAuth provider
|
||||
- `providerName`: The display name of the provider
|
||||
- `providerIconUrl`: URL of the icon to display (optional) you can use a base64 encoded image or an url to a remote image.
|
||||
- `clientId`: OAuth client ID
|
||||
- `clientSecret`: OAuth client secret
|
||||
- `type`: Type of OAuth flow ("oauth2" or "oidc")
|
||||
- `discoveryUrl`: URL to fetch OAuth 2.0 configuration (recommended for OIDC providers)
|
||||
- `authorizationUrl`: URL for the authorization endpoint (required for OAuth2 if not using discoveryUrl)
|
||||
- `tokenUrl`: URL for the token endpoint (required for OAuth2 if not using discoveryUrl)
|
||||
- `userInfoUrl`: URL for the user info endpoint (required for OAuth2 if not using discoveryUrl)
|
||||
- `scopes`: Array of OAuth scopes to request
|
||||
- `redirectURI`: Custom redirect URI (optional)
|
||||
- `responseType`: OAuth response type (defaults to "code")
|
||||
- `prompt`: Controls the authentication experience ("select_account", "consent", "login", "none")
|
||||
- `pkce`: Whether to use PKCE (Proof Key for Code Exchange)
|
||||
- `accessType`: Access type for the authorization request
|
||||
|
||||
## Setup
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Configure your OAuth2 Provider**
|
||||
|
||||
First, you'll need to register your application with your OAuth2 provider. This typically involves:
|
||||
- Creating a new application in your provider's dashboard
|
||||
- Setting up the redirect URI (usually `https://<your-papra-instance>/api/auth/oauth2/callback/:providerId`)
|
||||
- Obtaining the client ID and client secret
|
||||
- Configuring the required scopes
|
||||
|
||||
2. **Configure Papra**
|
||||
|
||||
Add the `AUTH_PROVIDERS_CUSTOMS` environment variable to your Papra instance. Here are some examples:
|
||||
|
||||
For OIDC providers:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"providerId": "custom-oauth2",
|
||||
"providerName": "Custom OAuth2",
|
||||
"providerIconUrl": "https://api.iconify.design/tabler:login-2.svg",
|
||||
"clientId": "your-client-id",
|
||||
"clientSecret": "your-client-secret",
|
||||
"type": "oidc",
|
||||
"discoveryUrl": "https://your-provider.tld/.well-known/openid-configuration",
|
||||
"scopes": ["openid", "profile", "email"]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
For standard OAuth2 providers:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"providerId": "custom-oauth2",
|
||||
"providerName": "Custom OAuth2",
|
||||
"providerIconUrl": "https://api.iconify.design/tabler:login-2.svg",
|
||||
"clientId": "your-client-id",
|
||||
"clientSecret": "your-client-secret",
|
||||
"type": "oauth2",
|
||||
"authorizationUrl": "https://your-provider.tld/oauth2/authorize",
|
||||
"tokenUrl": "https://your-provider.tld/oauth2/token",
|
||||
"userInfoUrl": "https://your-provider.tld/oauth2/userinfo",
|
||||
"scopes": ["profile", "email"]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
<Aside type="note">
|
||||
The `discoveryUrl` is recommended for OIDC providers as it automatically configures all the necessary endpoints.
|
||||
For standard OAuth2 providers, you'll need to specify the endpoints manually.
|
||||
</Aside>
|
||||
|
||||
3. **Test the Configuration**
|
||||
|
||||
- Restart your Papra instance to apply the changes
|
||||
- Go to the login page
|
||||
- You should see your custom OAuth2 providers as login options
|
||||
- Try logging in with a test account
|
||||
|
||||
</Steps>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Providers Not Showing Up
|
||||
|
||||
If your OAuth2 providers are not showing up on the login page:
|
||||
- Check that the JSON configuration in `AUTH_PROVIDERS_CUSTOMS` is valid
|
||||
- Ensure all required fields are provided
|
||||
- Verify that the provider IDs are unique
|
||||
|
||||
### Authentication Fails
|
||||
|
||||
If authentication fails:
|
||||
- Verify that the redirect URI is correctly configured in your OAuth2 provider
|
||||
- Check that the client ID and client secret are correct
|
||||
- Ensure the required scopes are properly configured
|
||||
- Check the Papra logs for any error messages
|
||||
|
||||
### OIDC Discovery Issues
|
||||
|
||||
If you're using OIDC and experiencing issues:
|
||||
- Verify that the `discoveryUrl` is accessible
|
||||
- Check that the provider supports OIDC discovery
|
||||
- Ensure the provider's configuration is properly exposed through the discovery endpoint
|
||||
|
||||
## Security Considerations
|
||||
|
||||
<Aside type="caution">
|
||||
Always use HTTPS for your OAuth2 endpoints and ensure your client secret is kept secure.
|
||||
Consider using PKCE (Proof Key for Code Exchange) for additional security by setting `pkce: true` in your configuration.
|
||||
</Aside>
|
||||
|
||||
## Multiple Providers
|
||||
|
||||
You can configure multiple custom OAuth2 providers by adding them to the array:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"providerId": "custom-oauth2-1",
|
||||
"providerName": "Custom OAuth2 Provider 1",
|
||||
"type": "oidc",
|
||||
"discoveryUrl": "https://provider1.tld/.well-known/openid-configuration",
|
||||
"clientId": "client-id-1",
|
||||
"clientSecret": "client-secret-1",
|
||||
"scopes": ["openid", "profile", "email"]
|
||||
},
|
||||
{
|
||||
"providerId": "custom-oauth2-2",
|
||||
"providerName": "Custom OAuth2 Provider 2",
|
||||
"type": "oidc",
|
||||
"discoveryUrl": "https://provider2.tld/.well-known/openid-configuration",
|
||||
"clientId": "client-id-2",
|
||||
"clientSecret": "client-secret-2",
|
||||
"scopes": ["openid", "profile", "email"]
|
||||
}
|
||||
]
|
||||
```
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
title: Troubleshooting
|
||||
description: Troubleshooting guide for Papra
|
||||
slug: resources/troubleshooting
|
||||
---
|
||||
|
||||
You can find here some common issues and how to fix them. If you encounter an issue that is not listed here, please [open an issue](https://github.com/papra-hq/papra/issues/new/choose) or [join our Discord](https://papra.app/discord).
|
||||
|
||||
## Failed to ensure that the database directory exists
|
||||
|
||||
Upon starting the server or a script, you may encounter this error
|
||||
|
||||
```
|
||||
Failed to ensure that the database directory exists, error while creating the directory
|
||||
Error: EACCES: permission denied, mkdir './app-data/db'
|
||||
|
||||
```
|
||||
|
||||
Before accessing the DB sqlite file, the server will try to ensure that the database directory exists, and if it doesn't, it try will create it. But in case of insufficient permissions, it will fail.
|
||||
|
||||
To fix this, you can either:
|
||||
|
||||
- Create the directory manually `mkdir -p <your-app-data-dir>/db`
|
||||
- Ensure that the directory is owned by the user running the container
|
||||
- Run the server as root (not recommended)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Papra documentation
|
||||
description: Papra documentation.
|
||||
description: Documentation for Papra, the minimalistic document archiving platform.
|
||||
hero:
|
||||
title: Papra Docs
|
||||
tagline: Documentation for Papra, the minimalistic document archiving platform.
|
||||
@@ -53,9 +53,9 @@ In today's digital world, managing countless important documents efficiently and
|
||||
- **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:* **SDK and API**: Build your own applications on top of Papra.
|
||||
- *Coming soon:* **CLI**: Manage your documents from the command line.
|
||||
- **API, SDK and webhooks**: Build your own applications on top of Papra.
|
||||
- **CLI**: Manage your documents from the command line.
|
||||
- **i18n**: Support for multiple languages.
|
||||
- *Coming soon:* **Document sharing**: Share documents with others.
|
||||
- *Coming soon:* **Document requests**: Generate upload links for people to add documents.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { StarlightUserConfig } from '@astrojs/starlight/types';
|
||||
|
||||
export const sidebar: StarlightUserConfig['sidebar'] = [
|
||||
export const sidebar = [
|
||||
{
|
||||
label: 'Getting Started',
|
||||
items: [
|
||||
@@ -12,6 +12,7 @@ export const sidebar: StarlightUserConfig['sidebar'] = [
|
||||
items: [
|
||||
{ label: 'Using Docker', slug: 'self-hosting/using-docker' },
|
||||
{ label: 'Using Docker Compose', slug: 'self-hosting/using-docker-compose' },
|
||||
{ label: 'Docker Compose Generator', link: '/docker-compose-generator', badge: { text: 'new', variant: 'note' } },
|
||||
{ label: 'Configuration', slug: 'self-hosting/configuration' },
|
||||
],
|
||||
},
|
||||
@@ -30,11 +31,19 @@ export const sidebar: StarlightUserConfig['sidebar'] = [
|
||||
label: 'Setup Ingestion Folder',
|
||||
slug: 'guides/setup-ingestion-folder',
|
||||
},
|
||||
{
|
||||
label: 'Setup Custom OAuth2 Providers',
|
||||
slug: 'guides/setup-custom-oauth2-providers',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Resources',
|
||||
items: [
|
||||
{
|
||||
label: 'Troubleshooting',
|
||||
slug: 'resources/troubleshooting',
|
||||
},
|
||||
{
|
||||
label: 'CLI Documentation',
|
||||
slug: 'resources/cli',
|
||||
@@ -46,6 +55,7 @@ export const sidebar: StarlightUserConfig['sidebar'] = [
|
||||
target: '_blank',
|
||||
},
|
||||
},
|
||||
|
||||
],
|
||||
},
|
||||
];
|
||||
] satisfies StarlightUserConfig['sidebar'];
|
||||
|
||||
493
apps/docs/src/docker-compose-generator/dc-generator.astro
Normal file
493
apps/docs/src/docker-compose-generator/dc-generator.astro
Normal file
@@ -0,0 +1,493 @@
|
||||
---
|
||||
import { codeToHtml } from 'shiki';
|
||||
|
||||
const images = {
|
||||
GitHub: 'ghcr.io/papra-hq/papra',
|
||||
DockerHub: 'corentinth/papra',
|
||||
};
|
||||
|
||||
const defaultDockerCompose = `
|
||||
services:
|
||||
papra:
|
||||
image: ghcr.io/papra-hq/papra:latest
|
||||
container_name: papra
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 1221:1221
|
||||
environment:
|
||||
- AUTH_SECRET=change-me
|
||||
- CLIENT_BASE_URL=http://localhost:1221
|
||||
- SERVER_BASE_URL=http://localhost:1221
|
||||
volumes:
|
||||
- ./app-data:/app/app-data
|
||||
user: 1000:1000
|
||||
`.trim();
|
||||
|
||||
const dcHtml = await codeToHtml(defaultDockerCompose, { theme: 'vitesse-black', lang: 'yaml' });
|
||||
const defaultCommand = `mkdir -p ./app-data/{db,documents} && docker compose up -d`;
|
||||
---
|
||||
|
||||
|
||||
<h2 class="mt-8 mb-2">General settings</h2>
|
||||
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<label for="port" class="min-w-32">External port</label>
|
||||
<input id="port" class="input-field" value="1221" type="number" min="1024" max="65535" placeholder="eg: 1221" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<label for="app-base-url" class="min-w-32">App base URL</label>
|
||||
<input id="app-base-url" class="input-field" type="text" placeholder="eg: https://papra.example.com" value="http://localhost:1221" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<label for="source" class="min-w-32">Image source</label>
|
||||
<select class="input-field mt-0" id="source">
|
||||
{Object.entries(images).map(([registry, imageName]) => <option class="bg-background" value={imageName}>{`${registry} - ${imageName}`}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<label for="service-name" class="min-w-32">Service Name</label>
|
||||
<input id="service-name" class="input-field" value="papra" type="text" placeholder="eg: papra" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<label
|
||||
for="auth-secret"
|
||||
class="min-w-32"
|
||||
>
|
||||
Auth secret
|
||||
</label>
|
||||
<div class="flex items-center gap-2 mt-0 w-full">
|
||||
<input class="input-field font-mono" id="auth-secret" type="text" placeholder="eg: 1234567890" />
|
||||
<button class="btn bg-muted" id="refresh-secret"> Refresh </button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<label for="volume-path" class="min-w-32">Volume path</label>
|
||||
<input id="volume-path" class="input-field" value="./app-data" type="text" placeholder="eg: ./app-data" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<label for="privileged-mode" class="min-w-32">Privileged mode</label>
|
||||
<div class="flex items-center gap-2 mt-0 w-full">
|
||||
<select class="input-field mt-0" id="privileged-mode">
|
||||
<option value="false" class="bg-background">Rootless</option>
|
||||
<option value="true" class="bg-background">Root</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="mt-8 mb-2">Ingestion folder</h2>
|
||||
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<label for="ingestion-enabled" class="min-w-32">Enable ingestion</label>
|
||||
<div class="flex items-center gap-2 mt-0 w-full">
|
||||
<select class="input-field mt-0" id="ingestion-enabled">
|
||||
<option value="false" class="bg-background">Disabled</option>
|
||||
<option value="true" class="bg-background">Enabled</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mt-1" id="ingestion-path-container" style="display: none;">
|
||||
<label for="ingestion-path" class="min-w-32">Ingestion path</label>
|
||||
<input id="ingestion-path" class="input-field" value="./ingestion" type="text" placeholder="eg: ./ingestion" />
|
||||
</div>
|
||||
|
||||
<h2 class="mt-8 mb-2">Intake emails</h2>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<label for="intake-email-enabled" class="min-w-32">Enabled</label>
|
||||
<div class="flex items-center gap-2 mt-0 w-full">
|
||||
<select class="input-field mt-0" id="intake-email-enabled">
|
||||
<option value="false" class="bg-background">Disabled</option>
|
||||
<option value="true" class="bg-background">Enabled</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mt-1" id="intake-email-driver-container" style="display: none;">
|
||||
<label for="intake-email-driver" class="min-w-32">Driver</label>
|
||||
<div class="flex items-center gap-2 mt-0 w-full">
|
||||
<select class="input-field mt-0" id="intake-email-driver">
|
||||
<option value="owlrelay" class="bg-background">OwlRelay</option>
|
||||
<option value="random-username" class="bg-background">Cloudflare Email Worker</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="intake-email-owlrelay-config" style="display: none;" class="mt-1">
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<label for="intake-email-owlrelay-api-key" class="min-w-32">API Key</label>
|
||||
<input id="intake-email-owlrelay-api-key" class="input-field" type="text" placeholder="owrl_*****" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<label for="intake-email-owlrelay-webhook-url" class="min-w-32">Webhook URL</label>
|
||||
<input id="intake-email-owlrelay-webhook-url" class="input-field" type="text" placeholder="https://your-instance.com/api/intake-emails/ingest" value="https://localhost:1221/api/intake-emails/ingest" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="intake-email-cf-worker-config" style="display: none;" class="mt-1">
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<label for="intake-email-cf-email-domain" class="min-w-32">Email domain</label>
|
||||
<input id="intake-email-cf-email-domain" class="input-field" type="text" placeholder="papra.email" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mt-1" id="intake-email-webhook-secret-container" style="display: none;">
|
||||
<label for="intake-email-webhook-secret" class="min-w-32">Webhook secret</label>
|
||||
<div class="flex items-center gap-2 mt-0 w-full">
|
||||
<input class="input-field font-mono" id="intake-email-webhook-secret" type="text" placeholder="a-random-key" />
|
||||
<button class="btn bg-muted" id="refresh-webhook-secret">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="docker-compose-output" class="mt-12" set:html={dcHtml} />
|
||||
|
||||
<pre id="command-output" class="bg-card p-4 rounded-md text-muted-foreground text-sm font-mono overflow-x-auto">{defaultCommand}</pre>
|
||||
|
||||
<div class="flex items-center gap-2 mt-4">
|
||||
<button class="btn bg-muted mt-0" id="download-button">Download docker-compose.yml</button>
|
||||
<button class="btn bg-muted mt-0" id="copy-button">Copy docker compose to clipboard</button>
|
||||
<button class="btn bg-muted mt-0" id="copy-command-button">Copy command</button>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
import { codeToHtml } from 'shiki';
|
||||
import { stringify } from 'yaml';
|
||||
|
||||
const portInput = document.getElementById('port') as HTMLInputElement;
|
||||
const sourceSelect = document.getElementById('source') as HTMLSelectElement;
|
||||
const serviceNameInput = document.getElementById('service-name') as HTMLInputElement;
|
||||
const authSecretInput = document.getElementById('auth-secret') as HTMLInputElement;
|
||||
const appBaseUrlInput = document.getElementById('app-base-url') as HTMLInputElement;
|
||||
const refreshSecretButton = document.getElementById('refresh-secret');
|
||||
const copyButton = document.getElementById('copy-button');
|
||||
const dockerComposeOutput = document.getElementById('docker-compose-output');
|
||||
const downloadButton = document.getElementById('download-button');
|
||||
const volumePathInput = document.getElementById('volume-path') as HTMLInputElement;
|
||||
const privilegedModeSelect = document.getElementById('privileged-mode') as HTMLSelectElement;
|
||||
const ingestionEnabledSelect = document.getElementById('ingestion-enabled') as HTMLSelectElement;
|
||||
const ingestionPathInput = document.getElementById('ingestion-path') as HTMLInputElement;
|
||||
const ingestionPathContainer = document.getElementById('ingestion-path-container') as HTMLDivElement;
|
||||
const intakeEmailEnabledSelect = document.getElementById('intake-email-enabled') as HTMLSelectElement;
|
||||
const intakeDriverSelect = document.getElementById('intake-email-driver') as HTMLSelectElement;
|
||||
const owlrelayConfig = document.getElementById('intake-email-owlrelay-config') as HTMLDivElement;
|
||||
const cfWorkerConfig = document.getElementById('intake-email-cf-worker-config') as HTMLDivElement;
|
||||
const owlrelayApiKeyInput = document.getElementById('intake-email-owlrelay-api-key') as HTMLInputElement;
|
||||
const owlrelayWebhookUrlInput = document.getElementById('intake-email-owlrelay-webhook-url') as HTMLInputElement;
|
||||
const cfEmailDomainInput = document.getElementById('intake-email-cf-email-domain') as HTMLInputElement;
|
||||
const webhookSecretInput = document.getElementById('intake-email-webhook-secret') as HTMLInputElement;
|
||||
const refreshWebhookSecretButton = document.getElementById('refresh-webhook-secret');
|
||||
const commandOutput = document.getElementById('command-output');
|
||||
const copyCommandButton = document.getElementById('copy-command-button');
|
||||
|
||||
// Track whether the app base URL has been customized by the user
|
||||
let isAppBaseUrlCustomized = false;
|
||||
// Track whether the webhook URL has been customized by the user
|
||||
let isWebhookUrlCustomized = false;
|
||||
|
||||
function getRandomString() {
|
||||
const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
return Array.from({ length: 48 }, () => alphabet[Math.floor(Math.random() * alphabet.length)]).join('');
|
||||
}
|
||||
|
||||
function copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
function isDefaultAppBaseUrl(url: string, port: string): boolean {
|
||||
return url === `http://localhost:${port}`;
|
||||
}
|
||||
|
||||
function generateDefaultWebhookUrl(baseUrl: string): string {
|
||||
// Remove trailing slash if present
|
||||
const cleanBaseUrl = baseUrl.replace(/\/$/, '');
|
||||
return `${cleanBaseUrl}/api/intake-emails/ingest`;
|
||||
}
|
||||
|
||||
function isDefaultWebhookUrl(webhookUrl: string, baseUrl: string): boolean {
|
||||
return webhookUrl === generateDefaultWebhookUrl(baseUrl);
|
||||
}
|
||||
|
||||
function refreshIsWebhookUrlCustomized() {
|
||||
const currentBaseUrl = appBaseUrlInput.value.trim();
|
||||
const currentWebhookUrl = owlrelayWebhookUrlInput.value.trim();
|
||||
|
||||
if (isDefaultWebhookUrl(currentWebhookUrl, currentBaseUrl)) {
|
||||
isWebhookUrlCustomized = false;
|
||||
} else {
|
||||
isWebhookUrlCustomized = true;
|
||||
}
|
||||
}
|
||||
|
||||
function refreshIsAppBaseUrlCustomized() {
|
||||
const currentPort = portInput.value;
|
||||
const currentUrl = appBaseUrlInput.value.trim();
|
||||
|
||||
if (isDefaultAppBaseUrl(currentUrl, currentPort)) {
|
||||
isAppBaseUrlCustomized = false;
|
||||
} else {
|
||||
isAppBaseUrlCustomized = true;
|
||||
}
|
||||
}
|
||||
|
||||
function updateWebhookUrlFromBaseUrl() {
|
||||
if (!isWebhookUrlCustomized) {
|
||||
const baseUrl = appBaseUrlInput.value.trim();
|
||||
if (baseUrl) {
|
||||
owlrelayWebhookUrlInput.value = generateDefaultWebhookUrl(baseUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateAppBaseUrlFromPort() {
|
||||
if (!isAppBaseUrlCustomized) {
|
||||
const port = portInput.value;
|
||||
appBaseUrlInput.value = `http://localhost:${port}`;
|
||||
// Also update webhook URL when app base URL changes
|
||||
updateWebhookUrlFromBaseUrl();
|
||||
}
|
||||
}
|
||||
|
||||
function handlePortChange() {
|
||||
updateAppBaseUrlFromPort();
|
||||
updateDockerCompose();
|
||||
}
|
||||
|
||||
function handleAppBaseUrlChange() {
|
||||
refreshIsAppBaseUrlCustomized();
|
||||
updateWebhookUrlFromBaseUrl();
|
||||
updateDockerCompose();
|
||||
}
|
||||
|
||||
function handleWebhookUrlChange() {
|
||||
refreshIsWebhookUrlCustomized();
|
||||
updateDockerCompose();
|
||||
}
|
||||
|
||||
function getDockerComposeYml() {
|
||||
const serviceName = serviceNameInput.value;
|
||||
const isRootless = privilegedModeSelect.value === 'false';
|
||||
const image = sourceSelect.value;
|
||||
const port = portInput.value;
|
||||
const authSecret = authSecretInput.value;
|
||||
const volumePath = volumePathInput.value;
|
||||
const isIngestionEnabled = ingestionEnabledSelect.value === 'true';
|
||||
const ingestionPath = ingestionPathInput.value;
|
||||
const intakeEmailEnabled = intakeEmailEnabledSelect.value === 'true';
|
||||
const intakeDriver = intakeDriverSelect.value;
|
||||
const webhookSecret = webhookSecretInput.value;
|
||||
const appBaseUrl = appBaseUrlInput.value.trim();
|
||||
|
||||
const version = isRootless ? 'latest' : 'latest-root';
|
||||
const fullImage = `${image}:${version}`;
|
||||
|
||||
// Determine base URLs
|
||||
const clientBaseUrl = appBaseUrl || `http://localhost:${port}`;
|
||||
const serverBaseUrl = appBaseUrl || `http://localhost:${port}`;
|
||||
|
||||
const environment = [
|
||||
`AUTH_SECRET=${authSecret}`,
|
||||
`CLIENT_BASE_URL=${clientBaseUrl}`,
|
||||
`SERVER_BASE_URL=${serverBaseUrl}`,
|
||||
isIngestionEnabled && 'INGESTION_FOLDER_IS_ENABLED=true',
|
||||
intakeEmailEnabled && 'INTAKE_EMAILS_IS_ENABLED=true',
|
||||
intakeEmailEnabled && `INTAKE_EMAILS_DRIVER=${intakeDriver}`,
|
||||
intakeEmailEnabled && `INTAKE_EMAILS_WEBHOOK_SECRET=${webhookSecret}`,
|
||||
intakeEmailEnabled && intakeDriver === 'owlrelay' && owlrelayApiKeyInput.value && `OWLRELAY_API_KEY=${owlrelayApiKeyInput.value}`,
|
||||
intakeEmailEnabled && intakeDriver === 'owlrelay' && owlrelayWebhookUrlInput.value && `OWLRELAY_WEBHOOK_URL=${owlrelayWebhookUrlInput.value}`,
|
||||
intakeEmailEnabled && intakeDriver === 'random-username' && cfEmailDomainInput.value && `INTAKE_EMAILS_EMAIL_GENERATION_DOMAIN=${cfEmailDomainInput.value}`,
|
||||
].flat().filter(Boolean);
|
||||
|
||||
const volumes = [
|
||||
`${volumePath}:/app/app-data`,
|
||||
isIngestionEnabled && `${ingestionPath}:/app/ingestion`,
|
||||
].filter(Boolean);
|
||||
|
||||
const dc = {
|
||||
services: {
|
||||
[serviceName]: {
|
||||
image: fullImage,
|
||||
container_name: serviceName,
|
||||
restart: 'unless-stopped',
|
||||
ports: [`${port}:1221`],
|
||||
environment,
|
||||
volumes,
|
||||
...(isRootless && {
|
||||
user: '1000:1000',
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return stringify(dc);
|
||||
}
|
||||
|
||||
function getStartCommand() {
|
||||
const volumePath = volumePathInput.value;
|
||||
const volumePathNormalized = volumePath.replace(/\/$/, '');
|
||||
const volumeWithSubdirs = `${volumePathNormalized}/{db,documents}`;
|
||||
|
||||
const mkdirCommand = `mkdir -p ${volumeWithSubdirs}`;
|
||||
|
||||
const dockerCommand = 'docker compose up -d';
|
||||
|
||||
return `${mkdirCommand} && ${dockerCommand}`;
|
||||
}
|
||||
|
||||
async function updateDockerCompose() {
|
||||
const dockerCompose = getDockerComposeYml();
|
||||
const command = getStartCommand();
|
||||
|
||||
const html = await codeToHtml(dockerCompose, { theme: 'vitesse-black', lang: 'yaml' });
|
||||
|
||||
if (dockerComposeOutput) {
|
||||
dockerComposeOutput.innerHTML = html;
|
||||
}
|
||||
|
||||
if (commandOutput) {
|
||||
commandOutput.textContent = command;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopy() {
|
||||
const dockerCompose = getDockerComposeYml();
|
||||
|
||||
copyToClipboard(dockerCompose);
|
||||
|
||||
if (copyButton) {
|
||||
copyButton.textContent = 'Copied!';
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (copyButton) {
|
||||
copyButton.textContent = 'Copy to clipboard';
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function handleRefreshSecret() {
|
||||
authSecretInput.value = getRandomString();
|
||||
updateDockerCompose();
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
const dockerCompose = getDockerComposeYml();
|
||||
|
||||
const blob = new Blob([dockerCompose], { type: 'text/yaml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'docker-compose.yml';
|
||||
a.click();
|
||||
}
|
||||
|
||||
function handleIngestionEnabledChange() {
|
||||
const isEnabled = ingestionEnabledSelect.value === 'true';
|
||||
ingestionPathContainer.style.display = isEnabled ? 'flex' : 'none';
|
||||
updateDockerCompose();
|
||||
}
|
||||
|
||||
function handleIntakeEmailEnabledChange() {
|
||||
const isEnabled = intakeEmailEnabledSelect.value === 'true';
|
||||
const driverContainer = document.getElementById('intake-email-driver-container');
|
||||
const webhookSecretContainer = document.getElementById('intake-email-webhook-secret-container');
|
||||
|
||||
if (driverContainer) {
|
||||
driverContainer.style.display = isEnabled ? 'flex' : 'none';
|
||||
}
|
||||
if (webhookSecretContainer) {
|
||||
webhookSecretContainer.style.display = isEnabled ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
if (!isEnabled) {
|
||||
// Reset driver-specific configs when disabled
|
||||
if (owlrelayConfig) {
|
||||
owlrelayConfig.style.display = 'none';
|
||||
}
|
||||
if (cfWorkerConfig) {
|
||||
cfWorkerConfig.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
// Show the appropriate driver config
|
||||
handleIntakeDriverChange();
|
||||
}
|
||||
|
||||
updateDockerCompose();
|
||||
}
|
||||
|
||||
function handleIntakeDriverChange() {
|
||||
const driver = intakeDriverSelect.value;
|
||||
const isEnabled = intakeEmailEnabledSelect.value === 'true';
|
||||
|
||||
if (!isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (owlrelayConfig) {
|
||||
owlrelayConfig.style.display = driver === 'owlrelay' ? 'block' : 'none';
|
||||
}
|
||||
if (cfWorkerConfig) {
|
||||
cfWorkerConfig.style.display = driver === 'random-username' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
updateDockerCompose();
|
||||
}
|
||||
|
||||
function handleRefreshWebhookSecret() {
|
||||
webhookSecretInput.value = getRandomString();
|
||||
updateDockerCompose();
|
||||
}
|
||||
|
||||
function handleCopyCommand() {
|
||||
const command = getStartCommand();
|
||||
|
||||
copyToClipboard(command);
|
||||
|
||||
if (copyCommandButton) {
|
||||
copyCommandButton.textContent = 'Copied!';
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (copyCommandButton) {
|
||||
copyCommandButton.textContent = 'Copy command';
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Add event listeners
|
||||
portInput.addEventListener('input', handlePortChange);
|
||||
sourceSelect.addEventListener('change', updateDockerCompose);
|
||||
serviceNameInput.addEventListener('input', updateDockerCompose);
|
||||
authSecretInput.addEventListener('input', updateDockerCompose);
|
||||
appBaseUrlInput.addEventListener('input', handleAppBaseUrlChange);
|
||||
refreshSecretButton?.addEventListener('click', handleRefreshSecret);
|
||||
copyButton?.addEventListener('click', handleCopy);
|
||||
downloadButton?.addEventListener('click', handleDownload);
|
||||
volumePathInput.addEventListener('input', updateDockerCompose);
|
||||
privilegedModeSelect.addEventListener('change', updateDockerCompose);
|
||||
ingestionEnabledSelect.addEventListener('change', handleIngestionEnabledChange);
|
||||
ingestionPathInput.addEventListener('input', updateDockerCompose);
|
||||
intakeEmailEnabledSelect.addEventListener('change', handleIntakeEmailEnabledChange);
|
||||
intakeDriverSelect.addEventListener('change', handleIntakeDriverChange);
|
||||
owlrelayApiKeyInput.addEventListener('input', updateDockerCompose);
|
||||
owlrelayWebhookUrlInput.addEventListener('input', handleWebhookUrlChange);
|
||||
cfEmailDomainInput.addEventListener('input', updateDockerCompose);
|
||||
webhookSecretInput.addEventListener('input', updateDockerCompose);
|
||||
refreshWebhookSecretButton?.addEventListener('click', handleRefreshWebhookSecret);
|
||||
copyCommandButton?.addEventListener('click', handleCopyCommand);
|
||||
|
||||
authSecretInput.value = getRandomString();
|
||||
|
||||
// Initial render
|
||||
updateDockerCompose();
|
||||
|
||||
// Initial setup
|
||||
handleIngestionEnabledChange();
|
||||
handleIntakeEmailEnabledChange();
|
||||
webhookSecretInput.value = getRandomString();
|
||||
</script>
|
||||
16
apps/docs/src/pages/docker-compose-generator.astro
Normal file
16
apps/docs/src/pages/docker-compose-generator.astro
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
|
||||
import DockerComposeGeneratorComp from '../docker-compose-generator/dc-generator.astro';
|
||||
---
|
||||
|
||||
<StarlightPage
|
||||
frontmatter={{
|
||||
title: 'Papra docker-compose.yml generator',
|
||||
description: 'Generate a custom docker-compose.yml file for Papra, tailored to your needs.',
|
||||
tableOfContents: false,
|
||||
}}
|
||||
>
|
||||
<p>This tool will help you generate a custom docker-compose.yml file for Papra, tailored to your needs. You can personalize the service name, the port, the auth secret, and the source image.</p>
|
||||
<p>For more configuration options, you can use the <a href="/self-hosting/configuration">configuration reference</a>.</p>
|
||||
<DockerComposeGeneratorComp />
|
||||
</StarlightPage>
|
||||
28
apps/docs/src/pages/docs-navigation.json.ts
Normal file
28
apps/docs/src/pages/docs-navigation.json.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { sidebar } from '../content/navigation';
|
||||
|
||||
export const GET: APIRoute = async ({ site }) => {
|
||||
const docs = await getCollection('docs');
|
||||
|
||||
const sections = sidebar.map((section) => {
|
||||
return {
|
||||
label: section.label,
|
||||
items: section
|
||||
.items
|
||||
.filter(item => item.slug !== undefined || (item.link && !item.link.startsWith('http')))
|
||||
.map((item) => {
|
||||
const slug = item.slug ?? item.link?.replace(/^\//, '');
|
||||
|
||||
return {
|
||||
label: item.label,
|
||||
slug,
|
||||
url: new URL(slug, site).toString(),
|
||||
description: docs.find(doc => (doc.id === slug || (slug === '' && doc.id === 'index')))?.data.description,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify(sections));
|
||||
};
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
"include": [
|
||||
".astro/types.d.ts",
|
||||
"**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
|
||||
97
apps/docs/uno.config.ts
Normal file
97
apps/docs/uno.config.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
defineConfig,
|
||||
presetUno,
|
||||
transformerDirectives,
|
||||
transformerVariantGroup,
|
||||
} from 'unocss';
|
||||
import presetAnimations from 'unocss-preset-animations';
|
||||
|
||||
export default defineConfig({
|
||||
presets: [
|
||||
presetUno({
|
||||
dark: {
|
||||
dark: '[data-kb-theme="dark"]',
|
||||
light: '[data-kb-theme="light"]',
|
||||
},
|
||||
prefix: '',
|
||||
}),
|
||||
presetAnimations(),
|
||||
],
|
||||
transformers: [transformerVariantGroup(), transformerDirectives()],
|
||||
theme: {
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
animation: {
|
||||
keyframes: {
|
||||
'accordion-down':
|
||||
'{ from { height: 0 } to { height: var(--kb-accordion-content-height) } }',
|
||||
'accordion-up':
|
||||
'{ from { height: var(--kb-accordion-content-height) } to { height: 0 } }',
|
||||
'collapsible-down':
|
||||
'{ from { height: 0 } to { height: var(--kb-collapsible-content-height) } }',
|
||||
'collapsible-up':
|
||||
'{ from { height: var(--kb-collapsible-content-height) } to { height: 0 } }',
|
||||
'caret-blink': '{ 0%,70%,100% { opacity: 1 } 20%,50% { opacity: 0 } }',
|
||||
},
|
||||
timingFns: {
|
||||
'accordion-down': 'ease-out',
|
||||
'accordion-up': 'ease-out',
|
||||
'collapsible-down': 'ease-out',
|
||||
'collapsible-up': 'ease-out',
|
||||
'caret-blink': 'ease-out',
|
||||
},
|
||||
durations: {
|
||||
'accordion-down': '0.2s',
|
||||
'accordion-up': '0.2s',
|
||||
'collapsible-down': '0.2s',
|
||||
'collapsible-up': '0.2s',
|
||||
'caret-blink': '1.25s',
|
||||
},
|
||||
counts: {
|
||||
'caret-blink': 'infinite',
|
||||
},
|
||||
},
|
||||
},
|
||||
shortcuts: {
|
||||
'input-field': 'flex h-9 w-full bg-none outline-none rounded-lg border border-border border-solid bg-inherit px-3 py-1 text-sm shadow-none placeholder:text-muted-foreground focus-visible:(outline-none ring-1.5 ring-ring) disabled:(cursor-not-allowed opacity-50) transition-shadow',
|
||||
'btn': 'text-sm font-medium hover:opacity-80 rounded-lg transition-all px-4 py-2 bg-none outline-none border-none cursor-pointer',
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,51 @@
|
||||
# @papra/app-client
|
||||
|
||||
## 0.6.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#357](https://github.com/papra-hq/papra/pull/357) [`585c53c`](https://github.com/papra-hq/papra/commit/585c53cd9d0d7dbd517dbb1adddfd9e7b70f9fe5) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added a /llms.txt on main website
|
||||
|
||||
- [#359](https://github.com/papra-hq/papra/pull/359) [`0c2cf69`](https://github.com/papra-hq/papra/commit/0c2cf698d1a9e9a3cea023920b10cfcd5d83be14) Thanks [@Mavv3006](https://github.com/Mavv3006)! - Add German translation
|
||||
|
||||
## 0.6.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#333](https://github.com/papra-hq/papra/pull/333) [`ff830c2`](https://github.com/papra-hq/papra/commit/ff830c234a02ddb4cbc480cf77ef49b8de35fbae) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fixed version release link
|
||||
|
||||
## 0.6.1
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#317](https://github.com/papra-hq/papra/pull/317) [`79c1d32`](https://github.com/papra-hq/papra/commit/79c1d3206b140cf8b3d33ef8bda6098dcf4c9c9c) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added document activity log
|
||||
|
||||
- [#319](https://github.com/papra-hq/papra/pull/319) [`60059c8`](https://github.com/papra-hq/papra/commit/60059c895c4860cbfda69d3c989ad00542def65b) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added pending invitation management page
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#309](https://github.com/papra-hq/papra/pull/309) [`d4f72e8`](https://github.com/papra-hq/papra/commit/d4f72e889a4d39214de998942bc0eb88cd5cee3d) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Disable "Manage subscription" from organization setting by default
|
||||
|
||||
- [#308](https://github.com/papra-hq/papra/pull/308) [`759a3ff`](https://github.com/papra-hq/papra/commit/759a3ff713db8337061418b9c9b122b957479343) Thanks [@CorentinTh](https://github.com/CorentinTh)! - I18n: full support for French language
|
||||
|
||||
- [#312](https://github.com/papra-hq/papra/pull/312) [`e5ef40f`](https://github.com/papra-hq/papra/commit/e5ef40f36c27ea25dc8a79ef2805d673761eec2a) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fixed an issue with the reset-password page navigation guard that prevented reset
|
||||
|
||||
## 0.5.1
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#295](https://github.com/papra-hq/papra/pull/295) [`438a311`](https://github.com/papra-hq/papra/commit/438a31171c606138c4b7fa299fdd58dcbeaaf298) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added support for custom oauth2 providers
|
||||
|
||||
- [#291](https://github.com/papra-hq/papra/pull/291) [`0627ec2`](https://github.com/papra-hq/papra/commit/0627ec25a422b7b820b08740cfc2905f9c55c00e) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added invitation system to add users to an organization
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#296](https://github.com/papra-hq/papra/pull/296) [`0ddc234`](https://github.com/papra-hq/papra/commit/0ddc2340f092cf6fe5bf2175b55fb46db7681c36) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix register page description
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@papra/app-client",
|
||||
"type": "module",
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.3",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.9.0",
|
||||
"description": "Papra frontend client",
|
||||
@@ -26,51 +26,52 @@
|
||||
"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": "tsx src/scripts/generate-i18n-types.script.ts",
|
||||
"script:sync-i18n-key-order": "tsx src/scripts/sync-i18n-key-order.script.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@corentinth/chisels": "^1.0.2",
|
||||
"@kobalte/core": "^0.13.7",
|
||||
"@corentinth/chisels": "^1.3.1",
|
||||
"@kobalte/core": "^0.13.9",
|
||||
"@kobalte/utils": "^0.9.1",
|
||||
"@modular-forms/solid": "^0.25.0",
|
||||
"@pdfslick/solid": "^2.0.0",
|
||||
"@solid-primitives/storage": "^4.2.1",
|
||||
"@solidjs/router": "^0.14.3",
|
||||
"@tanstack/solid-query": "^5.61.5",
|
||||
"@tanstack/solid-table": "^8.20.5",
|
||||
"@unocss/reset": "^0.64.0",
|
||||
"@modular-forms/solid": "^0.25.1",
|
||||
"@pdfslick/solid": "^2.3.0",
|
||||
"@solid-primitives/storage": "^4.3.2",
|
||||
"@solidjs/router": "^0.14.10",
|
||||
"@tanstack/solid-query": "^5.77.2",
|
||||
"@tanstack/solid-table": "^8.21.3",
|
||||
"@unocss/reset": "^0.64.1",
|
||||
"better-auth": "catalog:",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk-solid": "^1.1.0",
|
||||
"cmdk-solid": "^1.1.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"ofetch": "^1.4.1",
|
||||
"posthog-js": "^1.231.0",
|
||||
"posthog-js": "^1.246.0",
|
||||
"radix3": "^1.1.2",
|
||||
"solid-js": "^1.8.11",
|
||||
"solid-js": "^1.9.7",
|
||||
"solid-sonner": "^0.2.8",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"ts-pattern": "^5.5.0",
|
||||
"unocss-preset-animations": "^1.1.0",
|
||||
"unstorage": "^1.14.4",
|
||||
"ts-pattern": "^5.7.1",
|
||||
"unocss-preset-animations": "^1.2.1",
|
||||
"unstorage": "^1.16.0",
|
||||
"valibot": "1.0.0-beta.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "catalog:",
|
||||
"@iconify-json/tabler": "^1.1.120",
|
||||
"@playwright/test": "^1.46.1",
|
||||
"@iconify-json/tabler": "^1.2.18",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"jsdom": "^25.0.0",
|
||||
"tinyglobby": "^0.2.13",
|
||||
"tsx": "^4.19.1",
|
||||
"jsdom": "^25.0.1",
|
||||
"tinyglobby": "^0.2.14",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "catalog:",
|
||||
"unocss": "0.65.0-beta.2",
|
||||
"vite": "^5.0.11",
|
||||
"vite-plugin-solid": "^2.8.2",
|
||||
"vite": "^5.4.19",
|
||||
"vite-plugin-solid": "^2.11.6",
|
||||
"vitest": "catalog:",
|
||||
"yaml": "^2.7.0"
|
||||
"yaml": "^2.8.0"
|
||||
}
|
||||
}
|
||||
|
||||
3
apps/papra-client/public/_headers
Normal file
3
apps/papra-client/public/_headers
Normal file
@@ -0,0 +1,3 @@
|
||||
/*
|
||||
X-Frame-Options: DENY
|
||||
X-Content-Type-Options: nosniff
|
||||
@@ -9,6 +9,7 @@ import { render, Suspense } from 'solid-js/web';
|
||||
import { CommandPaletteProvider } from './modules/command-palette/command-palette.provider';
|
||||
import { ConfigProvider } from './modules/config/config.provider';
|
||||
import { DemoIndicator } from './modules/demo/demo.provider';
|
||||
import { RenameDocumentDialogProvider } from './modules/documents/components/rename-document-button.component';
|
||||
import { I18nProvider } from './modules/i18n/i18n.provider';
|
||||
import { ConfirmModalProvider } from './modules/shared/confirm';
|
||||
import { queryClient } from './modules/shared/query/query-client';
|
||||
@@ -44,9 +45,11 @@ render(
|
||||
>
|
||||
<CommandPaletteProvider>
|
||||
<ConfigProvider>
|
||||
<div class="min-h-screen font-sans text-sm font-400">
|
||||
{props.children}
|
||||
</div>
|
||||
<RenameDocumentDialogProvider>
|
||||
<div class="min-h-screen font-sans text-sm font-400">
|
||||
{props.children}
|
||||
</div>
|
||||
</RenameDocumentDialogProvider>
|
||||
<DemoIndicator />
|
||||
</ConfigProvider>
|
||||
|
||||
|
||||
552
apps/papra-client/src/locales/de.yml
Normal file
552
apps/papra-client/src/locales/de.yml
Normal file
@@ -0,0 +1,552 @@
|
||||
# Authentication
|
||||
|
||||
auth.request-password-reset.title: Passwort zurücksetzen
|
||||
auth.request-password-reset.description: Geben Sie Ihre E-Mail-Adresse ein, um Ihr Passwort zurückzusetzen.
|
||||
auth.request-password-reset.requested: Wenn ein Konto mit dieser E-Mail-Adresse existiert, haben wir Ihnen eine E-Mail zum Zurücksetzen Ihres Passworts gesendet.
|
||||
auth.request-password-reset.back-to-login: Zurück zum Login
|
||||
auth.request-password-reset.form.email.label: E-Mail
|
||||
auth.request-password-reset.form.email.placeholder: 'Beispiel: ada@papra.app'
|
||||
auth.request-password-reset.form.email.required: Bitte geben Sie Ihre E-Mail-Adresse ein
|
||||
auth.request-password-reset.form.email.invalid: Diese E-Mail-Adresse ist ungültig
|
||||
auth.request-password-reset.form.submit: Passwort zurücksetzen anfordern
|
||||
|
||||
auth.reset-password.title: Passwort zurücksetzen
|
||||
auth.reset-password.description: Geben Sie Ihr neues Passwort ein, um Ihr Passwort zurückzusetzen.
|
||||
auth.reset-password.reset: Ihr Passwort wurde zurückgesetzt.
|
||||
auth.reset-password.back-to-login: Zurück zum Login
|
||||
auth.reset-password.form.new-password.label: Neues Passwort
|
||||
auth.reset-password.form.new-password.placeholder: 'Beispiel: **********'
|
||||
auth.reset-password.form.new-password.required: Bitte geben Sie Ihr neues Passwort ein
|
||||
auth.reset-password.form.new-password.min-length: Das Passwort muss mindestens {{ minLength }} Zeichen lang sein
|
||||
auth.reset-password.form.new-password.max-length: Das Passwort muss weniger als {{ maxLength }} Zeichen lang sein
|
||||
auth.reset-password.form.submit: Passwort zurücksetzen
|
||||
|
||||
auth.email-provider.open: '{{ provider }} öffnen'
|
||||
|
||||
auth.login.title: Bei Papra anmelden
|
||||
auth.login.description: Geben Sie Ihre E-Mail-Adresse ein oder verwenden Sie die soziale Anmeldung, um auf Ihr Papra-Konto zuzugreifen.
|
||||
auth.login.login-with-provider: Mit {{ provider }} anmelden
|
||||
auth.login.no-account: Sie haben noch kein Konto?
|
||||
auth.login.register: Registrieren
|
||||
auth.login.form.email.label: E-Mail
|
||||
auth.login.form.email.placeholder: 'Beispiel: ada@papra.app'
|
||||
auth.login.form.email.required: Bitte geben Sie Ihre E-Mail-Adresse ein
|
||||
auth.login.form.email.invalid: Diese E-Mail-Adresse ist ungültig
|
||||
auth.login.form.password.label: Passwort
|
||||
auth.login.form.password.placeholder: Passwort festlegen
|
||||
auth.login.form.password.required: Bitte geben Sie Ihr Passwort ein
|
||||
auth.login.form.remember-me.label: Angemeldet bleiben
|
||||
auth.login.form.forgot-password.label: Passwort vergessen?
|
||||
auth.login.form.submit: Anmelden
|
||||
|
||||
auth.register.title: Bei Papra registrieren
|
||||
auth.register.description: Erstellen Sie ein Konto, um Papra zu nutzen.
|
||||
auth.register.register-with-email: Mit E-Mail registrieren
|
||||
auth.register.register-with-provider: Mit {{ provider }} registrieren
|
||||
auth.register.providers.google: Google
|
||||
auth.register.providers.github: GitHub
|
||||
auth.register.have-account: Sie haben bereits ein Konto?
|
||||
auth.register.login: Anmelden
|
||||
auth.register.registration-disabled.title: Registrierung ist deaktiviert
|
||||
auth.register.registration-disabled.description: Die Erstellung neuer Konten ist auf dieser Papra-Instanz derzeit deaktiviert. Nur Benutzer mit bestehenden Konten können sich anmelden. Wenn Sie dies für einen Fehler halten, wenden Sie sich bitte an den Administrator dieser Instanz.
|
||||
auth.register.form.email.label: E-Mail
|
||||
auth.register.form.email.placeholder: 'Beispiel: ada@papra.app'
|
||||
auth.register.form.email.required: Bitte geben Sie Ihre E-Mail-Adresse ein
|
||||
auth.register.form.email.invalid: Diese E-Mail-Adresse ist ungültig
|
||||
auth.register.form.password.label: Passwort
|
||||
auth.register.form.password.placeholder: Passwort festlegen
|
||||
auth.register.form.password.required: Bitte geben Sie Ihr Passwort ein
|
||||
auth.register.form.password.min-length: Das Passwort muss mindestens {{ minLength }} Zeichen lang sein
|
||||
auth.register.form.password.max-length: Das Passwort muss weniger als {{ maxLength }} Zeichen lang sein
|
||||
auth.register.form.name.label: Name
|
||||
auth.register.form.name.placeholder: 'Beispiel: Ada Lovelace'
|
||||
auth.register.form.name.required: Bitte geben Sie Ihren Namen ein
|
||||
auth.register.form.name.max-length: Der Name muss weniger als {{ maxLength }} Zeichen lang sein
|
||||
auth.register.form.submit: Registrieren
|
||||
|
||||
auth.email-validation-required.title: E-Mail verifizieren
|
||||
auth.email-validation-required.description: Eine Verifizierungs-E-Mail wurde an Ihre E-Mail-Adresse gesendet. Bitte verifizieren Sie Ihre E-Mail-Adresse, indem Sie auf den Link in der E-Mail klicken.
|
||||
|
||||
auth.legal-links.description: Indem Sie fortfahren, bestätigen Sie, dass Sie die {{ terms }} und die {{ privacy }} verstanden haben und ihnen zustimmen.
|
||||
auth.legal-links.terms: Nutzungsbedingungen
|
||||
auth.legal-links.privacy: Datenschutzrichtlinie
|
||||
|
||||
# User settings
|
||||
|
||||
user.settings.title: Benutzereinstellungen
|
||||
user.settings.description: Verwalten Sie hier Ihre Kontoeinstellungen.
|
||||
|
||||
user.settings.email.title: E-Mail-Adresse
|
||||
user.settings.email.description: Ihre E-Mail-Adresse kann nicht geändert werden.
|
||||
user.settings.email.label: E-Mail-Adresse
|
||||
|
||||
user.settings.name.title: Vollständiger Name
|
||||
user.settings.name.description: Ihr vollständiger Name wird anderen Organisationsmitgliedern angezeigt.
|
||||
user.settings.name.label: Vollständiger Name
|
||||
user.settings.name.placeholder: Z.B. Max Mustermann
|
||||
user.settings.name.update: Namen aktualisieren
|
||||
user.settings.name.updated: Ihr vollständiger Name wurde aktualisiert
|
||||
|
||||
user.settings.logout.title: Abmelden
|
||||
user.settings.logout.description: Melden Sie sich von Ihrem Konto ab. Sie können sich später wieder anmelden.
|
||||
user.settings.logout.button: Abmelden
|
||||
|
||||
# Organizations
|
||||
|
||||
organizations.list.title: Ihre Organisationen
|
||||
organizations.list.description: Organisationen sind eine Möglichkeit, Ihre Dokumente zu gruppieren und den Zugriff darauf zu verwalten. Sie können mehrere Organisationen erstellen und Ihre Teammitglieder zur Zusammenarbeit einladen.
|
||||
organizations.list.create-new: Neue Organisation erstellen
|
||||
|
||||
organizations.details.no-documents.title: Keine Dokumente
|
||||
organizations.details.no-documents.description: Es sind noch keine Dokumente in dieser Organisation vorhanden. Beginnen Sie mit dem Hochladen von Dokumenten.
|
||||
organizations.details.upload-documents: Dokumente hochladen
|
||||
organizations.details.documents-count: Dokumente insgesamt
|
||||
organizations.details.total-size: Gesamtgröße
|
||||
organizations.details.latest-documents: Neueste importierte Dokumente
|
||||
|
||||
organizations.create.title: Eine neue Organisation erstellen
|
||||
organizations.create.description: Ihre Dokumente werden nach Organisation gruppiert. Sie können mehrere Organisationen erstellen, um Ihre Dokumente zu trennen, z.B. für persönliche und geschäftliche Dokumente.
|
||||
organizations.create.back: Zurück
|
||||
organizations.create.error.max-count-reached: Sie haben die maximale Anzahl an Organisationen erreicht, die Sie erstellen können. Wenn Sie weitere erstellen möchten, kontaktieren Sie bitte den Support.
|
||||
organizations.create.form.name.label: Name der Organisation
|
||||
organizations.create.form.name.placeholder: Z.B. Acme Inc.
|
||||
organizations.create.form.name.required: Bitte geben Sie einen Organisationsnamen ein
|
||||
organizations.create.form.submit: Organisation erstellen
|
||||
organizations.create.success: Organisation erfolgreich erstellt
|
||||
|
||||
organizations.create-first.title: Erstellen Sie Ihre Organisation
|
||||
organizations.create-first.description: Ihre Dokumente werden nach Organisation gruppiert. Sie können mehrere Organisationen erstellen, um Ihre Dokumente zu trennen, z.B. für persönliche und geschäftliche Dokumente.
|
||||
organizations.create-first.default-name: Meine Organisation
|
||||
organizations.create-first.user-name: Organisation von "{{ name }}"
|
||||
|
||||
organization.settings.title: Organisationseinstellungen
|
||||
organization.settings.page.title: Organisationseinstellungen
|
||||
organization.settings.page.description: Verwalten Sie hier Ihre Organisationseinstellungen.
|
||||
organization.settings.name.title: Name der Organisation
|
||||
organization.settings.name.update: Namen aktualisieren
|
||||
organization.settings.name.placeholder: Z.B. Acme Inc.
|
||||
organization.settings.name.updated: Organisationsname aktualisiert
|
||||
organization.settings.subscription.title: Abonnement
|
||||
organization.settings.subscription.description: Verwalten Sie Ihre Abrechnung, Rechnungen und Zahlungsmethoden.
|
||||
organization.settings.subscription.manage: Abonnement verwalten
|
||||
organization.settings.subscription.error: Kundenportal-URL konnte nicht abgerufen werden
|
||||
organization.settings.delete.title: Organisation löschen
|
||||
organization.settings.delete.description: Das Löschen dieser Organisation entfernt dauerhaft alle damit verbundenen Daten.
|
||||
organization.settings.delete.confirm.title: Organisation löschen
|
||||
organization.settings.delete.confirm.message: Sind Sie sicher, dass Sie diese Organisation löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden und alle mit dieser Organisation verbundenen Daten werden dauerhaft entfernt.
|
||||
organization.settings.delete.confirm.confirm-button: Organisation löschen
|
||||
organization.settings.delete.confirm.cancel-button: Abbrechen
|
||||
organization.settings.delete.success: Organisation gelöscht
|
||||
|
||||
organizations.members.title: Mitglieder
|
||||
organizations.members.description: Verwalten Sie Ihre Organisationsmitglieder
|
||||
organizations.members.invite-member: Mitglied einladen
|
||||
organizations.members.invite-member-disabled-tooltip: Nur Administratoren oder Eigentümer können Mitglieder in die Organisation einladen
|
||||
organizations.members.remove-from-organization: Aus Organisation entfernen
|
||||
organizations.members.role: Rolle
|
||||
organizations.members.roles.owner: Eigentümer
|
||||
organizations.members.roles.admin: Administrator
|
||||
organizations.members.roles.member: Mitglied
|
||||
organizations.members.delete.confirm.title: Mitglied entfernen
|
||||
organizations.members.delete.confirm.message: Sind Sie sicher, dass Sie dieses Mitglied aus der Organisation entfernen möchten?
|
||||
organizations.members.delete.confirm.confirm-button: Entfernen
|
||||
organizations.members.delete.confirm.cancel-button: Abbrechen
|
||||
organizations.members.delete.success: Mitglied aus Organisation entfernt
|
||||
organizations.members.update-role.success: Mitgliederrolle aktualisiert
|
||||
organizations.members.table.headers.name: Name
|
||||
organizations.members.table.headers.email: E-Mail
|
||||
organizations.members.table.headers.role: Rolle
|
||||
organizations.members.table.headers.created: Erstellt
|
||||
organizations.members.table.headers.actions: Aktionen
|
||||
|
||||
organizations.invite-member.title: Mitglied einladen
|
||||
organizations.invite-member.description: Laden Sie ein Mitglied in Ihre Organisation ein
|
||||
organizations.invite-member.form.email.label: E-Mail
|
||||
organizations.invite-member.form.email.placeholder: 'Beispiel: ada@papra.app'
|
||||
organizations.invite-member.form.email.required: Bitte geben Sie eine gültige E-Mail-Adresse ein
|
||||
organizations.invite-member.form.role.label: Rolle
|
||||
organizations.invite-member.form.submit: In Organisation einladen
|
||||
organizations.invite-member.success.message: Mitglied eingeladen
|
||||
organizations.invite-member.success.description: Die E-Mail wurde in die Organisation eingeladen.
|
||||
organizations.invite-member.error.message: Mitglied konnte nicht eingeladen werden
|
||||
|
||||
organizations.invitations.title: Einladungen
|
||||
organizations.invitations.description: Verwalten Sie Ihre Organisationseinladungen
|
||||
organizations.invitations.list.cta: Mitglied einladen
|
||||
organizations.invitations.list.empty.title: Keine ausstehenden Einladungen
|
||||
organizations.invitations.list.empty.description: Sie wurden noch nicht zu Organisationen eingeladen.
|
||||
organizations.invitations.status.pending: Ausstehend
|
||||
organizations.invitations.status.accepted: Angenommen
|
||||
organizations.invitations.status.rejected: Abgelehnt
|
||||
organizations.invitations.status.expired: Abgelaufen
|
||||
organizations.invitations.status.cancelled: Abgebrochen
|
||||
organizations.invitations.resend: Einladung erneut senden
|
||||
organizations.invitations.cancel.title: Einladung abbrechen
|
||||
organizations.invitations.cancel.description: Sind Sie sicher, dass Sie diese Einladung abbrechen möchten?
|
||||
organizations.invitations.cancel.confirm: Einladung abbrechen
|
||||
organizations.invitations.cancel.cancel: Abbrechen
|
||||
organizations.invitations.resend.title: Einladung erneut senden
|
||||
organizations.invitations.resend.description: Sind Sie sicher, dass Sie diese Einladung erneut senden möchten? Dadurch wird eine neue E-Mail an den Empfänger gesendet.
|
||||
organizations.invitations.resend.confirm: Einladung erneut senden
|
||||
organizations.invitations.resend.cancel: Abbrechen
|
||||
|
||||
invitations.list.title: Einladungen
|
||||
invitations.list.description: Verwalten Sie Ihre Organisationseinladungen
|
||||
invitations.list.empty.title: Keine ausstehenden Einladungen
|
||||
invitations.list.empty.description: Sie wurden noch nicht zu Organisationen eingeladen.
|
||||
invitations.list.headers.organization: Organisation
|
||||
invitations.list.headers.status: Status
|
||||
invitations.list.headers.created: Erstellt
|
||||
invitations.list.headers.actions: Aktionen
|
||||
invitations.list.actions.accept: Annehmen
|
||||
invitations.list.actions.reject: Ablehnen
|
||||
invitations.list.actions.accept.success.message: Einladung angenommen
|
||||
invitations.list.actions.accept.success.description: Die Einladung wurde angenommen.
|
||||
invitations.list.actions.reject.success.message: Einladung abgelehnt
|
||||
invitations.list.actions.reject.success.description: Die Einladung wurde abgelehnt.
|
||||
|
||||
# Documents
|
||||
|
||||
documents.list.title: Dokumente
|
||||
documents.list.no-documents.title: Keine Dokumente
|
||||
documents.list.no-documents.description: Es sind noch keine Dokumente in dieser Organisation vorhanden. Beginnen Sie mit dem Hochladen von Dokumenten.
|
||||
documents.list.no-results: Keine Dokumente gefunden
|
||||
|
||||
documents.tabs.info: Info
|
||||
documents.tabs.content: Inhalt
|
||||
documents.tabs.activity: Aktivität
|
||||
documents.deleted.message: Dieses Dokument wurde gelöscht und wird in {{ days }} Tagen dauerhaft entfernt.
|
||||
documents.actions.download: Herunterladen
|
||||
documents.actions.open-in-new-tab: In neuem Tab öffnen
|
||||
documents.actions.restore: Wiederherstellen
|
||||
documents.actions.delete: Löschen
|
||||
documents.actions.edit: Bearbeiten
|
||||
documents.actions.cancel: Abbrechen
|
||||
documents.actions.save: Speichern
|
||||
documents.actions.saving: Speichern...
|
||||
documents.content.alert: Der Inhalt des Dokuments wird beim Hochladen automatisch aus dem Dokument extrahiert. Er wird nur für Such- und Indexierungszwecke verwendet.
|
||||
documents.info.id: ID
|
||||
documents.info.name: Name
|
||||
documents.info.type: Typ
|
||||
documents.info.size: Größe
|
||||
documents.info.created-at: Erstellt am
|
||||
documents.info.updated-at: Aktualisiert am
|
||||
documents.info.never: Nie
|
||||
|
||||
documents.rename.title: Dokument umbenennen
|
||||
documents.rename.form.name.label: Name
|
||||
documents.rename.form.name.placeholder: 'Beispiel: Rechnung 2024'
|
||||
documents.rename.form.name.required: Bitte geben Sie einen Namen für das Dokument ein
|
||||
documents.rename.form.name.max-length: Der Name muss weniger als 255 Zeichen lang sein
|
||||
documents.rename.form.submit: Dokument umbenennen
|
||||
documents.rename.success: Dokument erfolgreich umbenannt
|
||||
documents.rename.cancel: Abbrechen
|
||||
|
||||
import-documents.title.error: '{{ count }} Dokumente fehlgeschlagen'
|
||||
import-documents.title.success: '{{ count }} Dokumente importiert'
|
||||
import-documents.title.pending: '{{ count }} / {{ total }} Dokumente importiert'
|
||||
import-documents.title.none: Dokumente importieren
|
||||
import-documents.no-import-in-progress: Kein Dokumentimport im Gange
|
||||
|
||||
documents.deleted.title: Gelöschte Dokumente
|
||||
documents.deleted.empty.title: Keine gelöschten Dokumente
|
||||
documents.deleted.empty.description: Sie haben keine gelöschten Dokumente. Gelöschte Dokumente werden für {{ days }} Tage in den Papierkorb verschoben.
|
||||
documents.deleted.retention-notice: Alle gelöschten Dokumente werden für {{ days }} Tage im Papierkorb gespeichert. Nach Ablauf dieser Frist werden die Dokumente dauerhaft gelöscht und Sie können sie nicht wiederherstellen.
|
||||
documents.deleted.deleted-at: Gelöscht
|
||||
documents.deleted.restoring: Wiederherstellen...
|
||||
documents.deleted.deleting: Löschen...
|
||||
|
||||
trash.delete-all.button: Alles löschen
|
||||
trash.delete-all.confirm.title: Alle Dokumente dauerhaft löschen?
|
||||
trash.delete-all.confirm.description: Sind Sie sicher, dass Sie alle Dokumente aus dem Papierkorb dauerhaft löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.
|
||||
trash.delete-all.confirm.label: Löschen
|
||||
trash.delete-all.confirm.cancel: Abbrechen
|
||||
trash.delete.button: Löschen
|
||||
trash.delete.confirm.title: Dokument dauerhaft löschen?
|
||||
trash.delete.confirm.description: Sind Sie sicher, dass Sie dieses Dokument dauerhaft aus dem Papierkorb löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.
|
||||
trash.delete.confirm.label: Löschen
|
||||
trash.delete.confirm.cancel: Abbrechen
|
||||
trash.deleted.success.title: Dokument gelöscht
|
||||
trash.deleted.success.description: Das Dokument wurde dauerhaft gelöscht.
|
||||
|
||||
activity.document.created: Das Dokument wurde erstellt
|
||||
activity.document.updated.single: Das Feld {{ field }} wurde aktualisiert
|
||||
activity.document.updated.multiple: Die Felder {{ fields }} wurden aktualisiert
|
||||
activity.document.updated: Das Dokument wurde aktualisiert
|
||||
activity.document.deleted: Das Dokument wurde gelöscht
|
||||
activity.document.restored: Das Dokument wurde wiederhergestellt
|
||||
activity.document.tagged: Tag {{ tag }} wurde hinzugefügt
|
||||
activity.document.untagged: Tag {{ tag }} wurde entfernt
|
||||
|
||||
activity.document.user.name: von {{ name }}
|
||||
|
||||
activity.load-more: Mehr laden
|
||||
activity.no-more-activities: Keine weiteren Aktivitäten für dieses Dokument
|
||||
|
||||
# Tags
|
||||
|
||||
tags.no-tags.title: Noch keine Tags
|
||||
tags.no-tags.description: Diese Organisation hat noch keine Tags. Tags werden zur Kategorisierung von Dokumenten verwendet. Sie können Ihren Dokumenten Tags hinzufügen, um sie leichter zu finden und zu organisieren.
|
||||
tags.no-tags.create-tag: Tag erstellen
|
||||
|
||||
tags.title: Dokumenten-Tags
|
||||
tags.description: Tags werden zur Kategorisierung von Dokumenten verwendet. Sie können Ihren Dokumenten Tags hinzufügen, um sie leichter zu finden und zu organisieren.
|
||||
tags.create: Tag erstellen
|
||||
tags.update: Tag aktualisieren
|
||||
tags.delete: Tag löschen
|
||||
tags.delete.confirm.title: Tag löschen
|
||||
tags.delete.confirm.message: Sind Sie sicher, dass Sie diesen Tag löschen möchten? Das Löschen eines Tags entfernt ihn von allen Dokumenten.
|
||||
tags.delete.confirm.confirm-button: Löschen
|
||||
tags.delete.confirm.cancel-button: Abbrechen
|
||||
tags.delete.success: Tag erfolgreich gelöscht
|
||||
tags.create.success: Tag "{{ name }}" erfolgreich erstellt.
|
||||
tags.update.success: Tag "{{ name }}" erfolgreich aktualisiert.
|
||||
tags.form.name.label: Name
|
||||
tags.form.name.placeholder: Z.B. Verträge
|
||||
tags.form.name.required: Bitte geben Sie einen Tag-Namen ein
|
||||
tags.form.name.max-length: Tag-Name muss weniger als 64 Zeichen lang sein
|
||||
tags.form.color.label: Farbe
|
||||
tags.form.color.placeholder: 'Z.B. #FF0000'
|
||||
tags.form.color.required: Bitte geben Sie eine Farbe ein
|
||||
tags.form.color.invalid: Die Hex-Farbe ist falsch formatiert.
|
||||
tags.form.description.label: Beschreibung
|
||||
tags.form.description.optional: (optional)
|
||||
tags.form.description.placeholder: Z.B. Alle von der Firma unterzeichneten Verträge
|
||||
tags.form.description.max-length: Beschreibung muss weniger als 256 Zeichen lang sein
|
||||
tags.form.no-description: Keine Beschreibung
|
||||
tags.table.headers.tag: Tag
|
||||
tags.table.headers.description: Beschreibung
|
||||
tags.table.headers.documents: Dokumente
|
||||
tags.table.headers.created: Erstellt
|
||||
tags.table.headers.actions: Aktionen
|
||||
|
||||
# Tagging rules
|
||||
|
||||
tagging-rules.field.name: Dokumentenname
|
||||
tagging-rules.field.content: Dokumenteninhalt
|
||||
tagging-rules.operator.equals: ist gleich
|
||||
tagging-rules.operator.not-equals: ist nicht gleich
|
||||
tagging-rules.operator.contains: enthält
|
||||
tagging-rules.operator.not-contains: enthält nicht
|
||||
tagging-rules.operator.starts-with: beginnt mit
|
||||
tagging-rules.operator.ends-with: endet mit
|
||||
tagging-rules.list.title: Tagging-Regeln
|
||||
tagging-rules.list.description: Verwalten Sie die Tagging-Regeln Ihrer Organisation, um Dokumente automatisch basierend auf von Ihnen definierten Bedingungen zu taggen.
|
||||
tagging-rules.list.demo-warning: 'Hinweis: Da dies eine Demo-Umgebung (ohne Server) ist, werden Tagging-Regeln nicht auf neu hinzugefügte Dokumente angewendet.'
|
||||
tagging-rules.list.no-tagging-rules.title: Keine Tagging-Regeln
|
||||
tagging-rules.list.no-tagging-rules.description: Erstellen Sie eine Tagging-Regel, um Ihre hinzugefügten Dokumente automatisch basierend auf von Ihnen definierten Bedingungen zu taggen.
|
||||
tagging-rules.list.no-tagging-rules.create-tagging-rule: Tagging-Regel erstellen
|
||||
tagging-rules.list.card.no-conditions: Keine Bedingungen
|
||||
tagging-rules.list.card.one-condition: 1 Bedingung
|
||||
tagging-rules.list.card.conditions: '{{ count }} Bedingungen'
|
||||
tagging-rules.list.card.delete: Regel löschen
|
||||
tagging-rules.list.card.edit: Regel bearbeiten
|
||||
tagging-rules.create.title: Tagging-Regel erstellen
|
||||
tagging-rules.create.success: Tagging-Regel erfolgreich erstellt
|
||||
tagging-rules.create.error: Tagging-Regel konnte nicht erstellt werden
|
||||
tagging-rules.create.submit: Regel erstellen
|
||||
tagging-rules.form.name.label: Name
|
||||
tagging-rules.form.name.placeholder: 'Beispiel: Rechnungen taggen'
|
||||
tagging-rules.form.name.min-length: Bitte geben Sie einen Namen für die Regel ein
|
||||
tagging-rules.form.name.max-length: Der Name muss weniger als 64 Zeichen lang sein
|
||||
tagging-rules.form.description.label: Beschreibung
|
||||
tagging-rules.form.description.placeholder: "Beispiel: Dokumente mit 'Rechnung' im Namen taggen"
|
||||
tagging-rules.form.description.max-length: Die Beschreibung muss weniger als 256 Zeichen lang sein
|
||||
tagging-rules.form.conditions.label: Bedingungen
|
||||
tagging-rules.form.conditions.description: Definieren Sie die Bedingungen, die erfüllt sein müssen, damit die Regel angewendet wird. Alle Bedingungen müssen erfüllt sein, damit die Regel angewendet wird.
|
||||
tagging-rules.form.conditions.add-condition: Bedingung hinzufügen
|
||||
tagging-rules.form.conditions.no-conditions.title: Keine Bedingungen
|
||||
tagging-rules.form.conditions.no-conditions.description: Sie haben dieser Regel keine Bedingungen hinzugefügt. Diese Regel wendet ihre Tags auf alle Dokumente an.
|
||||
tagging-rules.form.conditions.no-conditions.confirm: Regel ohne Bedingungen anwenden
|
||||
tagging-rules.form.conditions.no-conditions.cancel: Abbrechen
|
||||
tagging-rules.form.conditions.value.placeholder: 'Beispiel: Rechnung'
|
||||
tagging-rules.form.conditions.value.min-length: Bitte geben Sie einen Wert für die Bedingung ein
|
||||
tagging-rules.form.tags.label: Tags
|
||||
tagging-rules.form.tags.description: Wählen Sie die Tags aus, die auf die hinzugefügten Dokumente angewendet werden sollen, die den Bedingungen entsprechen
|
||||
tagging-rules.form.tags.min-length: Es ist mindestens ein anzuwendender Tag erforderlich
|
||||
tagging-rules.form.tags.add-tag: Tag erstellen
|
||||
tagging-rules.form.submit: Regel erstellen
|
||||
tagging-rules.update.title: Tagging-Regel aktualisieren
|
||||
tagging-rules.update.error: Tagging-Regel konnte nicht aktualisiert werden
|
||||
tagging-rules.update.submit: Regel aktualisieren
|
||||
tagging-rules.update.cancel: Abbrechen
|
||||
|
||||
# Intake emails
|
||||
|
||||
intake-emails.title: E-Mail-Eingang
|
||||
intake-emails.description: E-Mail-Eingangsadressen werden verwendet, um E-Mails automatisch in Papra aufzunehmen. Leiten Sie einfach E-Mails an die Eingangsadresse weiter und deren Anhänge werden zu den Dokumenten Ihrer Organisation hinzugefügt.
|
||||
intake-emails.disabled.title: E-Mail-Eingang ist deaktiviert
|
||||
intake-emails.disabled.description: E-Mail-Eingang ist auf dieser Instanz deaktiviert. Bitte kontaktieren Sie Ihren Administrator, um ihn zu aktivieren. Weitere Informationen finden Sie in der {{ documentation }}.
|
||||
intake-emails.disabled.documentation: Dokumentation
|
||||
intake-emails.info: Es werden nur aktivierte E-Mails aus zulässigen Ursprüngen verarbeitet. Sie können eine E-Mail-Eingangsadresse jederzeit aktivieren oder deaktivieren.
|
||||
intake-emails.empty.title: Keine E-Mail-Eingänge
|
||||
intake-emails.empty.description: Generieren Sie eine Eingangsadresse, um E-Mail-Anhänge einfach aufzunehmen.
|
||||
intake-emails.empty.generate: E-Mail-Eingang generieren
|
||||
intake-emails.count: '{{ count }} Eingangse-Mail{{ plural }} für diese Organisation'
|
||||
intake-emails.new: Neue Eingangse-Mail
|
||||
intake-emails.disabled-label: (Deaktiviert)
|
||||
intake-emails.no-origins: Keine zulässigen E-Mail-Ursprünge
|
||||
intake-emails.allowed-origins: Zulässig von {{ count }} Adresse{{ plural }}
|
||||
intake-emails.actions.enable: Aktivieren
|
||||
intake-emails.actions.disable: Deaktivieren
|
||||
intake-emails.actions.manage-origins: Ursprungsadressen verwalten
|
||||
intake-emails.actions.delete: Löschen
|
||||
intake-emails.delete.confirm.title: Eingangse-Mail löschen?
|
||||
intake-emails.delete.confirm.message: Sind Sie sicher, dass Sie diese Eingangse-Mail löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.
|
||||
intake-emails.delete.confirm.confirm-button: Eingangse-Mail löschen
|
||||
intake-emails.delete.confirm.cancel-button: Abbrechen
|
||||
intake-emails.delete.success: Eingangse-Mail gelöscht
|
||||
intake-emails.create.success: Eingangse-Mail erstellt
|
||||
intake-emails.update.success.enabled: Eingangse-Mail aktiviert
|
||||
intake-emails.update.success.disabled: Eingangse-Mail deaktiviert
|
||||
intake-emails.allowed-origins.title: Zulässige Ursprünge
|
||||
intake-emails.allowed-origins.description: Es werden nur E-Mails, die an {{ email }} von diesen Ursprüngen gesendet werden, verarbeitet. Wenn keine Ursprünge angegeben sind, werden alle E-Mails verworfen.
|
||||
intake-emails.allowed-origins.add.label: Zulässige Ursprungs-E-Mail hinzufügen
|
||||
intake-emails.allowed-origins.add.placeholder: Z.B. ada@papra.app
|
||||
intake-emails.allowed-origins.add.button: Hinzufügen
|
||||
intake-emails.allowed-origins.add.error.exists: Diese E-Mail ist bereits in den zulässigen Ursprüngen für diese Eingangse-Mail vorhanden
|
||||
|
||||
# API keys
|
||||
|
||||
api-keys.permissions.documents.title: Dokumente
|
||||
api-keys.permissions.documents.documents:create: Dokumente erstellen
|
||||
api-keys.permissions.documents.documents:read: Dokumente lesen
|
||||
api-keys.permissions.documents.documents:update: Dokumente aktualisieren
|
||||
api-keys.permissions.documents.documents:delete: Dokumente löschen
|
||||
api-keys.permissions.tags.title: Tags
|
||||
api-keys.permissions.tags.tags:create: Tags erstellen
|
||||
api-keys.permissions.tags.tags:read: Tags lesen
|
||||
api-keys.permissions.tags.tags:update: Tags aktualisieren
|
||||
api-keys.permissions.tags.tags:delete: Tags löschen
|
||||
api-keys.create.title: API-Schlüssel erstellen
|
||||
api-keys.create.description: Erstellen Sie einen neuen API-Schlüssel, um auf die Papra API zuzugreifen.
|
||||
api-keys.create.success: Der API-Schlüssel wurde erfolgreich erstellt.
|
||||
api-keys.create.back: Zurück zu den API-Schlüsseln
|
||||
api-keys.create.form.name.label: Name
|
||||
api-keys.create.form.name.placeholder: 'Beispiel: Mein API-Schlüssel'
|
||||
api-keys.create.form.name.required: Bitte geben Sie einen Namen für den API-Schlüssel ein
|
||||
api-keys.create.form.permissions.label: Berechtigungen
|
||||
api-keys.create.form.permissions.required: Bitte wählen Sie mindestens eine Berechtigung aus
|
||||
api-keys.create.form.submit: API-Schlüssel erstellen
|
||||
api-keys.create.created.title: API-Schlüssel erstellt
|
||||
api-keys.create.created.description: Der API-Schlüssel wurde erfolgreich erstellt. Speichern Sie ihn an einem sicheren Ort, da er nicht erneut angezeigt wird.
|
||||
api-keys.list.title: API-Schlüssel
|
||||
api-keys.list.description: Verwalten Sie hier Ihre API-Schlüssel.
|
||||
api-keys.list.create: API-Schlüssel erstellen
|
||||
api-keys.list.empty.title: Keine API-Schlüssel
|
||||
api-keys.list.empty.description: Erstellen Sie einen API-Schlüssel, um auf die Papra API zuzugreifen.
|
||||
api-keys.list.card.last-used: Zuletzt verwendet
|
||||
api-keys.list.card.never: Nie
|
||||
api-keys.list.card.created: Erstellt
|
||||
api-keys.delete.success: Der API-Schlüssel wurde erfolgreich gelöscht
|
||||
api-keys.delete.confirm.title: API-Schlüssel löschen
|
||||
api-keys.delete.confirm.message: Sind Sie sicher, dass Sie diesen API-Schlüssel löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.
|
||||
api-keys.delete.confirm.confirm-button: Löschen
|
||||
api-keys.delete.confirm.cancel-button: Abbrechen
|
||||
|
||||
# Webhooks
|
||||
|
||||
webhooks.list.title: Webhooks
|
||||
webhooks.list.description: Verwalten Sie Ihre Organisations-Webhooks
|
||||
webhooks.list.empty.title: Keine Webhooks
|
||||
webhooks.list.empty.description: Erstellen Sie Ihren ersten Webhook, um Ereignisse zu empfangen
|
||||
webhooks.list.create: Webhook erstellen
|
||||
webhooks.list.card.last-triggered: Zuletzt ausgelöst
|
||||
webhooks.list.card.never: Nie
|
||||
webhooks.list.card.created: Erstellt
|
||||
webhooks.create.title: Webhook erstellen
|
||||
webhooks.create.description: Erstellen Sie einen neuen Webhook, um Ereignisse zu empfangen
|
||||
webhooks.create.success: Webhook erfolgreich erstellt
|
||||
webhooks.create.back: Zurück
|
||||
webhooks.create.form.submit: Webhook erstellen
|
||||
webhooks.create.form.name.label: Webhook-Name
|
||||
webhooks.create.form.name.placeholder: Webhook-Namen eingeben
|
||||
webhooks.create.form.name.required: Name ist erforderlich
|
||||
webhooks.create.form.url.label: Webhook-URL
|
||||
webhooks.create.form.url.placeholder: Webhook-URL eingeben
|
||||
webhooks.create.form.url.required: URL ist erforderlich
|
||||
webhooks.create.form.url.invalid: URL ist ungültig
|
||||
webhooks.create.form.secret.label: Geheimnis
|
||||
webhooks.create.form.secret.placeholder: Webhook-Geheimnis eingeben
|
||||
webhooks.create.form.events.label: Ereignisse
|
||||
webhooks.create.form.events.required: Mindestens ein Ereignis ist erforderlich
|
||||
webhooks.update.title: Webhook bearbeiten
|
||||
webhooks.update.description: Aktualisieren Sie Ihre Webhook-Details
|
||||
webhooks.update.success: Webhook erfolgreich aktualisiert
|
||||
webhooks.update.submit: Webhook aktualisieren
|
||||
webhooks.update.cancel: Abbrechen
|
||||
webhooks.update.form.secret.placeholder: Neues Geheimnis eingeben
|
||||
webhooks.update.form.secret.placeholder-redacted: '[Geheimnis geschwärzt]'
|
||||
webhooks.update.form.rotate-secret.button: Geheimnis rotieren
|
||||
webhooks.delete.success: Webhook erfolgreich gelöscht
|
||||
webhooks.delete.confirm.title: Webhook löschen
|
||||
webhooks.delete.confirm.message: Sind Sie sicher, dass Sie diesen Webhook löschen möchten?
|
||||
webhooks.delete.confirm.confirm-button: Löschen
|
||||
webhooks.delete.confirm.cancel-button: Abbrechen
|
||||
|
||||
webhooks.events.documents.document:created.description: Dokument erstellt
|
||||
webhooks.events.documents.document:deleted.description: Dokument gelöscht
|
||||
|
||||
# Navigation
|
||||
|
||||
layout.menu.home: Startseite
|
||||
layout.menu.documents: Dokumente
|
||||
layout.menu.tags: Tags
|
||||
layout.menu.tagging-rules: Tagging-Regeln
|
||||
layout.menu.deleted-documents: Gelöschte Dokumente
|
||||
layout.menu.organization-settings: Einstellungen
|
||||
layout.menu.api-keys: API-Schlüssel
|
||||
layout.menu.settings: Einstellungen
|
||||
layout.menu.account: Konto
|
||||
layout.menu.general-settings: Allgemeine Einstellungen
|
||||
layout.menu.intake-emails: E-Mail-Eingang
|
||||
layout.menu.webhooks: Webhooks
|
||||
layout.menu.members: Mitglieder
|
||||
layout.menu.invitations: Einladungen
|
||||
|
||||
layout.theme.light: Heller Modus
|
||||
layout.theme.dark: Dunkler Modus
|
||||
layout.theme.system: Systemmodus
|
||||
|
||||
layout.search.placeholder: Suchen...
|
||||
layout.menu.import-document: Dokument importieren
|
||||
|
||||
user-menu.account-settings: Kontoeinstellungen
|
||||
user-menu.api-keys: API-Schlüssel
|
||||
user-menu.invitations: Einladungen
|
||||
user-menu.language: Sprache
|
||||
user-menu.logout: Abmelden
|
||||
|
||||
# Command palette
|
||||
|
||||
command-palette.search.placeholder: Befehle oder Dokumente suchen
|
||||
command-palette.no-results: Keine Ergebnisse gefunden
|
||||
command-palette.sections.documents: Dokumente
|
||||
command-palette.sections.theme: Thema
|
||||
|
||||
# API errors
|
||||
|
||||
api-errors.document.already_exists: Das Dokument existiert bereits
|
||||
api-errors.document.file_too_big: Die Dokumentdatei ist zu groß
|
||||
api-errors.intake_email.limit_reached: Die maximale Anzahl an Eingangse-Mails für diese Organisation wurde erreicht. Bitte aktualisieren Sie Ihren Plan, um weitere Eingangse-Mails zu erstellen.
|
||||
api-errors.user.max_organization_count_reached: Sie haben die maximale Anzahl an Organisationen erreicht, die Sie erstellen können. Wenn Sie weitere erstellen möchten, kontaktieren Sie bitte den Support.
|
||||
api-errors.default: Beim Verarbeiten Ihrer Anfrage ist ein Fehler aufgetreten.
|
||||
api-errors.organization.invitation_already_exists: Eine Einladung für diese E-Mail existiert bereits in dieser Organisation.
|
||||
api-errors.user.already_in_organization: Dieser Benutzer ist bereits in dieser Organisation.
|
||||
api-errors.user.organization_invitation_limit_reached: Die maximale Anzahl an Einladungen für heute wurde erreicht. Bitte versuchen Sie es morgen erneut.
|
||||
api-errors.demo.not_available: Diese Funktion ist in der Demo nicht verfügbar
|
||||
api-errors.tags.already_exists: Ein Tag mit diesem Namen existiert bereits für diese Organisation
|
||||
|
||||
# Not found
|
||||
|
||||
not-found.title: 404 - Seite nicht gefunden
|
||||
not-found.description: Entschuldigung, die gesuchte Seite scheint nicht zu existieren. Bitte überprüfen Sie die URL und versuchen Sie es erneut.
|
||||
not-found.back-to-home: Zurück zur Startseite
|
||||
|
||||
# Demo
|
||||
|
||||
demo.popup.description: Dies ist eine Demo-Umgebung, alle Daten werden im lokalen Speicher Ihres Browsers gespeichert.
|
||||
demo.popup.discord: Treten Sie dem {{ discordLink }} bei, um Support zu erhalten, Funktionen vorzuschlagen oder einfach nur zu chatten.
|
||||
demo.popup.discord-link-label: Discord-Server
|
||||
demo.popup.reset: Demo-Daten zurücksetzen
|
||||
demo.popup.hide: Ausblenden
|
||||
@@ -1,3 +1,5 @@
|
||||
# Authentication
|
||||
|
||||
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.
|
||||
@@ -38,7 +40,7 @@ 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.description: Create an account to start using Papra.
|
||||
auth.register.register-with-email: Register with email
|
||||
auth.register.register-with-provider: Register with {{ provider }}
|
||||
auth.register.providers.google: Google
|
||||
@@ -69,22 +71,256 @@ auth.legal-links.description: By continuing, you acknowledge that you understand
|
||||
auth.legal-links.terms: Terms of Service
|
||||
auth.legal-links.privacy: Privacy Policy
|
||||
|
||||
# User settings
|
||||
|
||||
user.settings.title: User settings
|
||||
user.settings.description: Manage your account settings here.
|
||||
|
||||
user.settings.email.title: Email address
|
||||
user.settings.email.description: Your email address cannot be changed.
|
||||
user.settings.email.label: Email address
|
||||
|
||||
user.settings.name.title: Full name
|
||||
user.settings.name.description: Your full name is displayed to other organization members.
|
||||
user.settings.name.label: Full name
|
||||
user.settings.name.placeholder: Eg. John Doe
|
||||
user.settings.name.update: Update name
|
||||
user.settings.name.updated: Your full name has been updated
|
||||
|
||||
user.settings.logout.title: Logout
|
||||
user.settings.logout.description: Logout from your account. You can login again later.
|
||||
user.settings.logout.button: Logout
|
||||
|
||||
# Organizations
|
||||
|
||||
organizations.list.title: Your organizations
|
||||
organizations.list.description: Organizations are a way to group your documents and manage access to them. You can create multiple organizations and invite your team members to collaborate.
|
||||
organizations.list.create-new: Create new organization
|
||||
|
||||
organizations.details.no-documents.title: No documents
|
||||
organizations.details.no-documents.description: There are no documents in this organization yet. Start by uploading some documents.
|
||||
organizations.details.upload-documents: Upload documents
|
||||
organizations.details.documents-count: documents in total
|
||||
organizations.details.total-size: total size
|
||||
organizations.details.latest-documents: Latest imported documents
|
||||
|
||||
organizations.create.title: Create a new organization
|
||||
organizations.create.description: Your documents will be grouped by organization. You can create multiple organizations to separate your documents, for example, for personal and work documents.
|
||||
organizations.create.back: Back
|
||||
organizations.create.error.max-count-reached: You have reached the maximum number of organizations you can create, if you need to create more, please contact support.
|
||||
organizations.create.form.name.label: Organization name
|
||||
organizations.create.form.name.placeholder: Eg. Acme Inc.
|
||||
organizations.create.form.name.required: Please enter an organization name
|
||||
organizations.create.form.submit: Create organization
|
||||
organizations.create.success: Organization created successfully
|
||||
|
||||
organizations.create-first.title: Create your organization
|
||||
organizations.create-first.description: Your documents will be grouped by organization. You can create multiple organizations to separate your documents, for example, for personal and work documents.
|
||||
organizations.create-first.default-name: My organization
|
||||
organizations.create-first.user-name: "{{ name }}'s organization"
|
||||
|
||||
organization.settings.title: Organization Settings
|
||||
organization.settings.page.title: Organization settings
|
||||
organization.settings.page.description: Manage your organization settings here.
|
||||
organization.settings.name.title: Organization name
|
||||
organization.settings.name.update: Update name
|
||||
organization.settings.name.placeholder: Eg. Acme Inc.
|
||||
organization.settings.name.updated: Organization name updated
|
||||
organization.settings.subscription.title: Subscription
|
||||
organization.settings.subscription.description: Manage your billing, invoices and payment methods.
|
||||
organization.settings.subscription.manage: Manage subscription
|
||||
organization.settings.subscription.error: Failed to get customer portal URL
|
||||
organization.settings.delete.title: Delete organization
|
||||
organization.settings.delete.description: Deleting this organization will permanently remove all data associated with it.
|
||||
organization.settings.delete.confirm.title: Delete organization
|
||||
organization.settings.delete.confirm.message: Are you sure you want to delete this organization? This action cannot be undone, and all data associated with this organization will be permanently removed.
|
||||
organization.settings.delete.confirm.confirm-button: Delete organization
|
||||
organization.settings.delete.confirm.cancel-button: Cancel
|
||||
organization.settings.delete.success: Organization deleted
|
||||
|
||||
organizations.members.title: Members
|
||||
organizations.members.description: Manage your organization members
|
||||
organizations.members.invite-member: Invite member
|
||||
organizations.members.invite-member-disabled-tooltip: Only admins or owners can invite members to the organization
|
||||
organizations.members.remove-from-organization: Remove from organization
|
||||
organizations.members.role: Role
|
||||
organizations.members.roles.owner: Owner
|
||||
organizations.members.roles.admin: Admin
|
||||
organizations.members.roles.member: Member
|
||||
organizations.members.delete.confirm.title: Remove member
|
||||
organizations.members.delete.confirm.message: Are you sure you want to remove this member from the organization?
|
||||
organizations.members.delete.confirm.confirm-button: Remove
|
||||
organizations.members.delete.confirm.cancel-button: Cancel
|
||||
organizations.members.delete.success: Member removed from organization
|
||||
organizations.members.update-role.success: Member role updated
|
||||
organizations.members.table.headers.name: Name
|
||||
organizations.members.table.headers.email: Email
|
||||
organizations.members.table.headers.role: Role
|
||||
organizations.members.table.headers.created: Created
|
||||
organizations.members.table.headers.actions: Actions
|
||||
|
||||
organizations.invite-member.title: Invite member
|
||||
organizations.invite-member.description: Invite a member to your organization
|
||||
organizations.invite-member.form.email.label: Email
|
||||
organizations.invite-member.form.email.placeholder: 'Example: ada@papra.app'
|
||||
organizations.invite-member.form.email.required: Please enter a valid email address
|
||||
organizations.invite-member.form.role.label: Role
|
||||
organizations.invite-member.form.submit: Invite to organization
|
||||
organizations.invite-member.success.message: Member invited
|
||||
organizations.invite-member.success.description: The email has been invited to the organization.
|
||||
organizations.invite-member.error.message: Failed to invite member
|
||||
|
||||
organizations.invitations.title: Invitations
|
||||
organizations.invitations.description: Manage your organization invitations
|
||||
organizations.invitations.list.cta: Invite member
|
||||
organizations.invitations.list.empty.title: No pending invitations
|
||||
organizations.invitations.list.empty.description: You haven't been invited to any organizations yet.
|
||||
organizations.invitations.status.pending: Pending
|
||||
organizations.invitations.status.accepted: Accepted
|
||||
organizations.invitations.status.rejected: Rejected
|
||||
organizations.invitations.status.expired: Expired
|
||||
organizations.invitations.status.cancelled: Cancelled
|
||||
organizations.invitations.resend: Resend invitation
|
||||
organizations.invitations.cancel.title: Cancel invitation
|
||||
organizations.invitations.cancel.description: Are you sure you want to cancel this invitation?
|
||||
organizations.invitations.cancel.confirm: Cancel invitation
|
||||
organizations.invitations.cancel.cancel: Cancel
|
||||
organizations.invitations.resend.title: Resend invitation
|
||||
organizations.invitations.resend.description: Are you sure you want to resend this invitation? This will send a new email to the recipient.
|
||||
organizations.invitations.resend.confirm: Resend invitation
|
||||
organizations.invitations.resend.cancel: Cancel
|
||||
|
||||
invitations.list.title: Invitations
|
||||
invitations.list.description: Manage your organization invitations
|
||||
invitations.list.empty.title: No pending invitations
|
||||
invitations.list.empty.description: You haven't been invited to any organizations yet.
|
||||
invitations.list.headers.organization: Organization
|
||||
invitations.list.headers.status: Status
|
||||
invitations.list.headers.created: Created
|
||||
invitations.list.headers.actions: Actions
|
||||
invitations.list.actions.accept: Accept
|
||||
invitations.list.actions.reject: Reject
|
||||
invitations.list.actions.accept.success.message: Invitation accepted
|
||||
invitations.list.actions.accept.success.description: The invitation has been accepted.
|
||||
invitations.list.actions.reject.success.message: Invitation rejected
|
||||
invitations.list.actions.reject.success.description: The invitation has been rejected.
|
||||
|
||||
# Documents
|
||||
|
||||
documents.list.title: Documents
|
||||
documents.list.no-documents.title: No documents
|
||||
documents.list.no-documents.description: There are no documents in this organization yet. Start by uploading some documents.
|
||||
documents.list.no-results: No documents found
|
||||
|
||||
documents.tabs.info: Info
|
||||
documents.tabs.content: Content
|
||||
documents.tabs.activity: Activity
|
||||
documents.deleted.message: This document has been deleted and will be permanently removed in {{ days }} days.
|
||||
documents.actions.download: Download
|
||||
documents.actions.open-in-new-tab: Open in new tab
|
||||
documents.actions.restore: Restore
|
||||
documents.actions.delete: Delete
|
||||
documents.actions.edit: Edit
|
||||
documents.actions.cancel: Cancel
|
||||
documents.actions.save: Save
|
||||
documents.actions.saving: Saving...
|
||||
documents.content.alert: The content of the document is automatically extracted from the document on upload. It is only used for search and indexing purposes.
|
||||
documents.info.id: ID
|
||||
documents.info.name: Name
|
||||
documents.info.type: Type
|
||||
documents.info.size: Size
|
||||
documents.info.created-at: Created At
|
||||
documents.info.updated-at: Updated At
|
||||
documents.info.never: Never
|
||||
|
||||
documents.rename.title: Rename document
|
||||
documents.rename.form.name.label: Name
|
||||
documents.rename.form.name.placeholder: 'Example: Invoice 2024'
|
||||
documents.rename.form.name.required: Please enter a name for the document
|
||||
documents.rename.form.name.max-length: The name must be less than 255 characters
|
||||
documents.rename.form.submit: Rename document
|
||||
documents.rename.success: Document renamed successfully
|
||||
documents.rename.cancel: Cancel
|
||||
|
||||
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
|
||||
|
||||
documents.deleted.title: Deleted documents
|
||||
documents.deleted.empty.title: No deleted documents
|
||||
documents.deleted.empty.description: You have no deleted documents. Documents that are deleted will be moved to the trash bin for {{ days }} days.
|
||||
documents.deleted.retention-notice: All deleted documents are stored in the trash bin for {{ days }} days. Passing this delay, the documents will be permanently deleted, and you will not be able to restore them.
|
||||
documents.deleted.deleted-at: Deleted
|
||||
documents.deleted.restoring: Restoring...
|
||||
documents.deleted.deleting: Deleting...
|
||||
|
||||
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.
|
||||
|
||||
activity.document.created: The document has been created
|
||||
activity.document.updated.single: The {{ field }} has been updated
|
||||
activity.document.updated.multiple: The {{ fields }} have been updated
|
||||
activity.document.updated: The document has been updated
|
||||
activity.document.deleted: The document has been deleted
|
||||
activity.document.restored: The document has been restored
|
||||
activity.document.tagged: Tag {{ tag }} has been added
|
||||
activity.document.untagged: Tag {{ tag }} has been removed
|
||||
|
||||
activity.document.user.name: by {{ name }}
|
||||
|
||||
activity.load-more: Load more
|
||||
activity.no-more-activities: No more activities for this document
|
||||
|
||||
# Tags
|
||||
|
||||
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
|
||||
tags.title: Documents Tags
|
||||
tags.description: Tags are used to categorize documents. You can add tags to your documents to make them easier to find and organize.
|
||||
tags.create: Create tag
|
||||
tags.update: Update tag
|
||||
tags.delete: Delete tag
|
||||
tags.delete.confirm.title: Delete tag
|
||||
tags.delete.confirm.message: Are you sure you want to delete this tag? Deleting a tag will remove it from all documents.
|
||||
tags.delete.confirm.confirm-button: Delete
|
||||
tags.delete.confirm.cancel-button: Cancel
|
||||
tags.delete.success: Tag deleted successfully
|
||||
tags.create.success: Tag "{{ name }}" created successfully.
|
||||
tags.update.success: Tag "{{ name }}" updated successfully.
|
||||
tags.form.name.label: Name
|
||||
tags.form.name.placeholder: Eg. Contracts
|
||||
tags.form.name.required: Please enter a tag name
|
||||
tags.form.name.max-length: Tag name must be less than 64 characters
|
||||
tags.form.color.label: Color
|
||||
tags.form.color.placeholder: 'Eg. #FF0000'
|
||||
tags.form.color.required: Please enter a color
|
||||
tags.form.color.invalid: The hex color is badly formatted.
|
||||
tags.form.description.label: Description
|
||||
tags.form.description.optional: (optional)
|
||||
tags.form.description.placeholder: Eg. All the contracts signed by the company
|
||||
tags.form.description.max-length: Description must be less than 256 characters
|
||||
tags.form.no-description: No description
|
||||
tags.table.headers.tag: Tag
|
||||
tags.table.headers.description: Description
|
||||
tags.table.headers.documents: Documents
|
||||
tags.table.headers.created: Created
|
||||
tags.table.headers.actions: Actions
|
||||
|
||||
# Tagging rules
|
||||
|
||||
tagging-rules.field.name: document name
|
||||
tagging-rules.field.content: document content
|
||||
@@ -135,36 +371,42 @@ 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
|
||||
# Intake emails
|
||||
|
||||
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.
|
||||
intake-emails.title: Intake Emails
|
||||
intake-emails.description: 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.
|
||||
intake-emails.disabled.title: Intake Emails are disabled
|
||||
intake-emails.disabled.description: Intake emails are disabled on this instance. Please contact your administrator to enable them. See the {{ documentation }} for more information.
|
||||
intake-emails.disabled.documentation: documentation
|
||||
intake-emails.info: Only enabled intake emails from allowed origins will be processed. You can enable or disable an intake email at any time.
|
||||
intake-emails.empty.title: No intake emails
|
||||
intake-emails.empty.description: Generate an intake address to easily ingest emails attachments.
|
||||
intake-emails.empty.generate: Generate intake email
|
||||
intake-emails.count: '{{ count }} intake email{{ plural }} for this organization'
|
||||
intake-emails.new: New intake email
|
||||
intake-emails.disabled-label: (Disabled)
|
||||
intake-emails.no-origins: No allowed email origins
|
||||
intake-emails.allowed-origins: Allowed from {{ count }} address{{ plural }}
|
||||
intake-emails.actions.enable: Enable
|
||||
intake-emails.actions.disable: Disable
|
||||
intake-emails.actions.manage-origins: Manage origins addresses
|
||||
intake-emails.actions.delete: Delete
|
||||
intake-emails.delete.confirm.title: Delete intake email?
|
||||
intake-emails.delete.confirm.message: Are you sure you want to delete this intake email? This action cannot be undone.
|
||||
intake-emails.delete.confirm.confirm-button: Delete intake email
|
||||
intake-emails.delete.confirm.cancel-button: Cancel
|
||||
intake-emails.delete.success: Intake email deleted
|
||||
intake-emails.create.success: Intake email created
|
||||
intake-emails.update.success.enabled: Intake email enabled
|
||||
intake-emails.update.success.disabled: Intake email disabled
|
||||
intake-emails.allowed-origins.title: Allowed origins
|
||||
intake-emails.allowed-origins.description: Only emails sent to {{ email }} from these origins will be processed. If no origins are specified, all emails will be discarded.
|
||||
intake-emails.allowed-origins.add.label: Add allowed origin email
|
||||
intake-emails.allowed-origins.add.placeholder: Eg. ada@papra.app
|
||||
intake-emails.allowed-origins.add.button: Add
|
||||
intake-emails.allowed-origins.add.error.exists: This email is already in the allowed origins for this intake email
|
||||
|
||||
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
|
||||
|
||||
api-keys.permissions.documents.title: Documents
|
||||
api-keys.permissions.documents.documents:create: Create documents
|
||||
@@ -202,6 +444,8 @@ api-keys.delete.confirm.message: Are you sure you want to delete this API key? T
|
||||
api-keys.delete.confirm.confirm-button: Delete
|
||||
api-keys.delete.confirm.cancel-button: Cancel
|
||||
|
||||
# Webhooks
|
||||
|
||||
webhooks.list.title: Webhooks
|
||||
webhooks.list.description: Manage your organization webhooks
|
||||
webhooks.list.empty.title: No webhooks
|
||||
@@ -210,7 +454,6 @@ 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
|
||||
@@ -243,3 +486,67 @@ webhooks.delete.confirm.cancel-button: Cancel
|
||||
|
||||
webhooks.events.documents.document:created.description: Document created
|
||||
webhooks.events.documents.document:deleted.description: Document deleted
|
||||
|
||||
# Navigation
|
||||
|
||||
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
|
||||
layout.menu.members: Members
|
||||
layout.menu.invitations: Invitations
|
||||
|
||||
layout.theme.light: Light mode
|
||||
layout.theme.dark: Dark mode
|
||||
layout.theme.system: System mode
|
||||
|
||||
layout.search.placeholder: Search...
|
||||
layout.menu.import-document: Import a document
|
||||
|
||||
user-menu.account-settings: Account settings
|
||||
user-menu.api-keys: API keys
|
||||
user-menu.invitations: Invitations
|
||||
user-menu.language: Language
|
||||
user-menu.logout: Logout
|
||||
|
||||
# Command palette
|
||||
|
||||
command-palette.search.placeholder: Search commands or documents
|
||||
command-palette.no-results: No results found
|
||||
command-palette.sections.documents: Documents
|
||||
command-palette.sections.theme: Theme
|
||||
|
||||
# API errors
|
||||
|
||||
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-errors.organization.invitation_already_exists: An invitation for this email already exists in this organization.
|
||||
api-errors.user.already_in_organization: This user is already in this organization.
|
||||
api-errors.user.organization_invitation_limit_reached: The maximum number of invitations has been reached for today. Please try again tomorrow.
|
||||
api-errors.demo.not_available: This feature is not available in demo
|
||||
api-errors.tags.already_exists: A tag with this name already exists for this organization
|
||||
|
||||
# Not found
|
||||
|
||||
not-found.title: 404 - Not Found
|
||||
not-found.description: Sorry, the page you are looking for does not seem to exist. Please check the URL and try again.
|
||||
not-found.back-to-home: Go back to home
|
||||
|
||||
# Demo
|
||||
|
||||
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
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# Authentication
|
||||
|
||||
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.
|
||||
@@ -38,7 +40,7 @@ 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.description: Créez un compte pour commencer à utiliser Papra.
|
||||
auth.register.register-with-email: S'inscrire avec email
|
||||
auth.register.register-with-provider: S'inscrire avec {{ provider }}
|
||||
auth.register.providers.google: Google
|
||||
@@ -69,30 +71,265 @@ auth.legal-links.description: En continuant, vous reconnaissez que vous comprene
|
||||
auth.legal-links.terms: Conditions d'utilisation
|
||||
auth.legal-links.privacy: Politique de confidentialité
|
||||
|
||||
# User settings
|
||||
|
||||
user.settings.title: Paramètres de l'utilisateur
|
||||
user.settings.description: Gérez vos paramètres de compte ici.
|
||||
|
||||
user.settings.email.title: Adresse email
|
||||
user.settings.email.description: Votre adresse email ne peut pas être modifiée.
|
||||
user.settings.email.label: Adresse email
|
||||
|
||||
user.settings.name.title: Nom complet
|
||||
user.settings.name.description: Votre nom complet est affiché aux autres membres de l'organisation.
|
||||
user.settings.name.label: Nom complet
|
||||
user.settings.name.placeholder: 'Exemple: John Doe'
|
||||
user.settings.name.update: Mettre à jour le nom
|
||||
user.settings.name.updated: Votre nom complet a été mis à jour
|
||||
|
||||
user.settings.logout.title: Déconnexion
|
||||
user.settings.logout.description: Déconnectez-vous de votre compte. Vous pouvez vous reconnecter plus tard.
|
||||
user.settings.logout.button: Déconnexion
|
||||
|
||||
# Organizations
|
||||
|
||||
organizations.list.title: Vos organisations
|
||||
organizations.list.description: Les organisations sont un moyen de grouper vos documents et de gérer l'accès à eux. Vous pouvez créer plusieurs organisations et inviter vos membres de l'équipe à collaborer.
|
||||
organizations.list.create-new: Créer une nouvelle organisation
|
||||
|
||||
organizations.details.no-documents.title: Aucun document
|
||||
organizations.details.no-documents.description: Il n'y a pas de documents dans cette organisation. Commencez par télécharger des documents.
|
||||
organizations.details.upload-documents: Télécharger des documents
|
||||
organizations.details.documents-count: documents en total
|
||||
organizations.details.total-size: taille totale
|
||||
organizations.details.latest-documents: Derniers documents importés
|
||||
|
||||
organizations.create.title: Créer une nouvelle organisation
|
||||
organizations.create.description: Vos documents seront regroupés par organisation. Vous pouvez créer plusieurs organisations pour séparer vos documents, par exemple, pour les documents personnels et professionnels.
|
||||
organizations.create.back: Retour
|
||||
organizations.create.error.max-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.
|
||||
organizations.create.form.name.label: Nom de l'organisation
|
||||
organizations.create.form.name.placeholder: 'Exemple: Acme Inc.'
|
||||
organizations.create.form.name.required: Veuillez entrer un nom pour l'organisation
|
||||
organizations.create.form.submit: Créer l'organisation
|
||||
organizations.create.success: Organisation créée avec succès
|
||||
|
||||
organizations.create-first.title: Créer votre organisation
|
||||
organizations.create-first.description: Vos documents seront regroupés par organisation. Vous pouvez créer plusieurs organisations pour séparer vos documents, par exemple, pour les documents personnels et professionnels.
|
||||
organizations.create-first.default-name: Mon organisation
|
||||
organizations.create-first.user-name: "{{ name }}'s organisation"
|
||||
|
||||
organization.settings.title: Paramètres de l'organisation
|
||||
organization.settings.page.title: Paramètres de l'organisation
|
||||
organization.settings.page.description: Gérez les paramètres de votre organisation ici.
|
||||
organization.settings.name.title: Nom de l'organisation
|
||||
organization.settings.name.update: Modifier le nom
|
||||
organization.settings.name.placeholder: 'Exemple: Acme Inc.'
|
||||
organization.settings.name.updated: Nom de l'organisation mis à jour
|
||||
organization.settings.subscription.title: Subscription
|
||||
organization.settings.subscription.description: Gérez votre facturation, vos factures et vos méthodes de paiement.
|
||||
organization.settings.subscription.manage: Gérer la souscription
|
||||
organization.settings.subscription.error: Échec de la récupération de l'URL du portail client
|
||||
organization.settings.delete.title: Supprimer l'organisation
|
||||
organization.settings.delete.description: Supprimer cette organisation supprimera définitivement toutes les données associées à elle.
|
||||
organization.settings.delete.confirm.title: Supprimer l'organisation
|
||||
organization.settings.delete.confirm.message: Êtes-vous sûr de vouloir supprimer cette organisation ? Cette action est irréversible, et toutes les données associées à cette organisation seront supprimées définitivement.
|
||||
organization.settings.delete.confirm.confirm-button: Supprimer l'organisation
|
||||
organization.settings.delete.confirm.cancel-button: Annuler
|
||||
organization.settings.delete.success: Organisation supprimée
|
||||
|
||||
organizations.members.title: Membres
|
||||
organizations.members.description: Gérez les membres de votre organisation.
|
||||
organizations.members.invite-member: Inviter un membre
|
||||
organizations.members.invite-member-disabled-tooltip: Seuls les administrateurs ou les propriétaires peuvent inviter des membres à l'organisation
|
||||
organizations.members.remove-from-organization: Retirer de l'organisation
|
||||
organizations.members.role: Rôle
|
||||
organizations.members.roles.owner: Propriétaire
|
||||
organizations.members.roles.admin: Admin
|
||||
organizations.members.roles.member: Membre
|
||||
organizations.members.delete.confirm.title: Retirer un membre
|
||||
organizations.members.delete.confirm.message: Êtes-vous sûr de vouloir retirer ce membre de l'organisation ?
|
||||
organizations.members.delete.confirm.confirm-button: Retirer
|
||||
organizations.members.delete.confirm.cancel-button: Annuler
|
||||
organizations.members.delete.success: Membre retiré de l'organisation
|
||||
organizations.members.update-role.success: Rôle du membre mis à jour
|
||||
organizations.members.table.headers.name: Nom
|
||||
organizations.members.table.headers.email: Email
|
||||
organizations.members.table.headers.role: Rôle
|
||||
# organizations.members.table.headers.created: Created
|
||||
organizations.members.table.headers.actions: Actions
|
||||
|
||||
organizations.invite-member.title: Inviter un membre
|
||||
organizations.invite-member.description: Invite un membre à votre organisation
|
||||
organizations.invite-member.form.email.label: Email
|
||||
organizations.invite-member.form.email.placeholder: 'Exemple: ada@papra.app'
|
||||
organizations.invite-member.form.email.required: Veuillez entrer une adresse email valide
|
||||
organizations.invite-member.form.role.label: Rôle
|
||||
organizations.invite-member.form.submit: Inviter à l'organisation
|
||||
organizations.invite-member.success.message: Membre invité
|
||||
organizations.invite-member.success.description: L'email a été invité à l'organisation.
|
||||
organizations.invite-member.error.message: Échec de l'invitation du membre
|
||||
|
||||
organizations.invitations.title: Invitations
|
||||
organizations.invitations.description: Gérez les invitations de votre organisation.
|
||||
organizations.invitations.list.cta: Inviter un membre
|
||||
organizations.invitations.list.empty.title: Aucune invitation en attente
|
||||
organizations.invitations.list.empty.description: Vous n'avez pas été invité à aucune organisation.
|
||||
organizations.invitations.status.pending: En attente
|
||||
organizations.invitations.status.accepted: Accepté
|
||||
organizations.invitations.status.rejected: Refusé
|
||||
organizations.invitations.status.expired: Expiré
|
||||
organizations.invitations.status.cancelled: Annulé
|
||||
organizations.invitations.resend: Renvoyer l'invitation
|
||||
organizations.invitations.cancel.title: Annuler l'invitation
|
||||
organizations.invitations.cancel.description: Êtes-vous sûr de vouloir annuler cette invitation ?
|
||||
organizations.invitations.cancel.confirm: Annuler l'invitation
|
||||
organizations.invitations.cancel.cancel: Annuler
|
||||
organizations.invitations.resend.title: Renvoyer l'invitation
|
||||
organizations.invitations.resend.description: Êtes-vous sûr de vouloir renvoyer cette invitation ? Cela enverra un nouvel email à l'invité.
|
||||
organizations.invitations.resend.confirm: Renvoyer l'invitation
|
||||
organizations.invitations.resend.cancel: Annuler
|
||||
|
||||
invitations.list.title: Invitations
|
||||
invitations.list.description: Gérez les invitations de votre organisation.
|
||||
invitations.list.empty.title: Aucune invitation en attente
|
||||
invitations.list.empty.description: Vous n'avez pas été invité à aucune organisation.
|
||||
invitations.list.headers.organization: Organisation
|
||||
# invitations.list.headers.status: Status
|
||||
invitations.list.headers.created: Créé
|
||||
invitations.list.headers.actions: Actions
|
||||
invitations.list.actions.accept: Accepter
|
||||
invitations.list.actions.reject: Refuser
|
||||
invitations.list.actions.accept.success.message: Invitation acceptée
|
||||
invitations.list.actions.accept.success.description: L'invitation a été acceptée.
|
||||
invitations.list.actions.reject.success.message: Invitation refusée
|
||||
invitations.list.actions.reject.success.description: L'invitation a été refusée.
|
||||
|
||||
# Documents
|
||||
|
||||
documents.list.title: Documents
|
||||
documents.list.no-documents.title: Aucun document
|
||||
documents.list.no-documents.description: Il n'y a pas de documents dans cette organisation. Commencez par télécharger des documents.
|
||||
documents.list.no-results: Aucun document trouvé
|
||||
|
||||
documents.tabs.info: Info
|
||||
documents.tabs.content: Contenu
|
||||
documents.tabs.activity: Activité
|
||||
documents.deleted.message: Ce document a été supprimé et sera supprimé définitivement dans {{ days }} jours.
|
||||
documents.actions.download: Télécharger
|
||||
documents.actions.open-in-new-tab: Ouvrir dans un nouvel onglet
|
||||
documents.actions.restore: Restaurer
|
||||
documents.actions.delete: Supprimer
|
||||
documents.actions.edit: Modifier
|
||||
documents.actions.cancel: Annuler
|
||||
documents.actions.save: Enregistrer
|
||||
documents.actions.saving: Enregistrement...
|
||||
documents.content.alert: Le contenu du document est automatiquement extrait du document lors de l'import. Il est uniquement utilisé pour la recherche et l'indexation.
|
||||
documents.info.id: ID
|
||||
documents.info.name: Nom
|
||||
documents.info.type: Type
|
||||
documents.info.size: Taille
|
||||
documents.info.created-at: Créé le
|
||||
documents.info.updated-at: Mis à jour le
|
||||
documents.info.never: Jamais
|
||||
|
||||
documents.rename.title: Renommer le document
|
||||
documents.rename.form.name.label: Nom
|
||||
documents.rename.form.name.placeholder: 'Exemple: Facture 2024'
|
||||
documents.rename.form.name.required: Veuillez entrer un nom pour le document
|
||||
documents.rename.form.name.max-length: Le nom doit contenir moins de 255 caractères
|
||||
documents.rename.form.submit: Renommer
|
||||
documents.rename.success: Document renommé avec succès
|
||||
documents.rename.cancel: Annuler
|
||||
|
||||
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
|
||||
|
||||
documents.deleted.title: Documents supprimés
|
||||
documents.deleted.empty.title: Aucun document supprimé
|
||||
documents.deleted.empty.description: Vous n'avez pas de documents supprimés. Les documents supprimés seront déplacés dans la corbeille pour {{ days }} jours.
|
||||
documents.deleted.retention-notice: Tous les documents supprimés sont stockés dans la corbeille pour {{ days }} jours. Passé ce délai, les documents seront supprimés définitivement, et vous ne pourrez plus les restaurer.
|
||||
documents.deleted.deleted-at: Supprimé
|
||||
documents.deleted.restoring: Restauration...
|
||||
documents.deleted.deleting: Suppression...
|
||||
|
||||
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.
|
||||
|
||||
activity.document.created: Le document a été créé
|
||||
activity.document.updated.single: Le {{ field }} a été mis à jour
|
||||
activity.document.updated.multiple: Les {{ fields }} ont été mis à jour
|
||||
activity.document.updated: Le document a été mis à jour
|
||||
activity.document.deleted: Le document a été supprimé
|
||||
activity.document.restored: Le document a été restauré
|
||||
activity.document.tagged: Le tag {{ tag }} a été ajouté
|
||||
activity.document.untagged: Le tag {{ tag }} a été supprimé
|
||||
|
||||
activity.document.user.name: par {{ name }}
|
||||
|
||||
activity.load-more: Charger plus
|
||||
activity.no-more-activities: Aucune activité pour ce document
|
||||
|
||||
# Tags
|
||||
|
||||
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
|
||||
tags.title: Tags de documents
|
||||
tags.description: 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.create: Créer un tag
|
||||
tags.update: Mettre à jour un tag
|
||||
tags.delete: Supprimer un tag
|
||||
tags.delete.confirm.title: Supprimer un tag
|
||||
tags.delete.confirm.message: Êtes-vous sûr de vouloir supprimer ce tag ? Supprimer un tag supprimera toutes les règles de catégorisation qui l'utilisent.
|
||||
tags.delete.confirm.confirm-button: Supprimer
|
||||
tags.delete.confirm.cancel-button: Annuler
|
||||
tags.delete.success: Tag supprimé avec succès
|
||||
tags.create.success: Tag "{{ name }}" créé avec succès.
|
||||
tags.update.success: Tag "{{ name }}" mis à jour avec succès.
|
||||
tags.form.name.label: Nom
|
||||
tags.form.name.placeholder: 'Exemple: Contrats'
|
||||
tags.form.name.required: Veuillez entrer un nom pour le tag
|
||||
tags.form.name.max-length: Le nom du tag doit contenir moins de 64 caractères
|
||||
tags.form.color.label: Couleur
|
||||
tags.form.color.placeholder: 'Exemple: #FF0000'
|
||||
tags.form.color.required: Veuillez entrer une couleur
|
||||
tags.form.color.invalid: La couleur hexadécimale est mal formatée.
|
||||
tags.form.description.label: Description
|
||||
tags.form.description.optional: (optionnel)
|
||||
tags.form.description.placeholder: "Exemple: Tous les contrats signés par l'entreprise"
|
||||
tags.form.description.max-length: La description doit contenir moins de 256 caractères
|
||||
tags.form.no-description: Aucune description
|
||||
tags.table.headers.tag: Tag
|
||||
tags.table.headers.description: Description
|
||||
tags.table.headers.documents: Documents
|
||||
tags.table.headers.created: Date de création
|
||||
tags.table.headers.actions: Actions
|
||||
|
||||
# Tagging rules
|
||||
|
||||
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.'
|
||||
@@ -134,36 +371,42 @@ tagging-rules.update.error: Échec de la mise à jour de la règle de catégoris
|
||||
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
|
||||
# Intake emails
|
||||
|
||||
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.
|
||||
intake-emails.title: Adresses de réception
|
||||
intake-emails.description: Les adresses de réception sont utilisées pour ingérer automatiquement les emails dans Papra. Il suffit de les envoyer à l'adresse de réception et leurs pièces jointes seront ajoutées à vos documents.
|
||||
intake-emails.disabled.title: Les adresses de réception sont désactivées
|
||||
intake-emails.disabled.description: Les adresses de réception sont désactivées sur cette instance. Veuillez contacter votre administrateur pour les activer. Voir la {{ documentation }} pour plus d'informations.
|
||||
intake-emails.disabled.documentation: documentation
|
||||
intake-emails.info: Seules les adresses de réception activées depuis les origines autorisées seront traitées. Vous pouvez activer ou désactiver une adresse de réception à tout moment.
|
||||
intake-emails.empty.title: Aucune adresse de réception
|
||||
intake-emails.empty.description: Générez une adresse de réception pour ingérer facilement les pièces jointes des emails.
|
||||
intake-emails.empty.generate: Générer une adresse de réception
|
||||
intake-emails.count: '{{ count }} intake email{{ plural }} for this organization'
|
||||
intake-emails.new: Nouvelle adresse de réception
|
||||
intake-emails.disabled-label: (Désactivé)
|
||||
intake-emails.no-origins: Aucune adresse de réception autorisée
|
||||
intake-emails.allowed-origins: Autorisées depuis {{ count }} adresse{{ plural }}
|
||||
intake-emails.actions.enable: Activer
|
||||
intake-emails.actions.disable: Désactiver
|
||||
intake-emails.actions.manage-origins: Gérer les adresses d'origine
|
||||
intake-emails.actions.delete: Supprimer
|
||||
intake-emails.delete.confirm.title: Supprimer l'adresse de réception ?
|
||||
intake-emails.delete.confirm.message: Êtes-vous sûr de vouloir supprimer cette adresse de réception ? Cette action est irréversible.
|
||||
intake-emails.delete.confirm.confirm-button: Supprimer l'adresse de réception
|
||||
intake-emails.delete.confirm.cancel-button: Annuler
|
||||
intake-emails.delete.success: Adresse de réception supprimée
|
||||
intake-emails.create.success: Adresse de réception créée
|
||||
intake-emails.update.success.enabled: Adresse de réception activée
|
||||
intake-emails.update.success.disabled: Adresse de réception désactivée
|
||||
intake-emails.allowed-origins.title: Adresses d'origine autorisées
|
||||
intake-emails.allowed-origins.description: Seuls les emails envoyés à {{ email }} depuis ces adresses d'origine seront traités. Si aucune adresse d'origine n'est spécifiée, tous les emails seront rejetés.
|
||||
intake-emails.allowed-origins.add.label: Ajouter une adresse d'origine autorisée
|
||||
intake-emails.allowed-origins.add.placeholder: 'Exemple: ada@papra.app'
|
||||
intake-emails.allowed-origins.add.button: Ajouter
|
||||
intake-emails.allowed-origins.add.error.exists: Cette adresse email est déjà dans les adresses d'origine autorisées pour cette adresse de réception
|
||||
|
||||
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
|
||||
|
||||
api-keys.permissions.documents.title: Documents
|
||||
api-keys.permissions.documents.documents:create: Créer des documents
|
||||
@@ -200,3 +443,110 @@ 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
|
||||
|
||||
# Webhooks
|
||||
|
||||
webhooks.list.title: Webhooks
|
||||
webhooks.list.description: Gérez vos webhooks ici.
|
||||
webhooks.list.empty.title: Aucun webhook
|
||||
webhooks.list.empty.description: Créez votre premier webhook pour commencer à recevoir des événements.
|
||||
webhooks.list.create: Créer un webhook
|
||||
webhooks.list.card.last-triggered: Dernière invocation
|
||||
webhooks.list.card.never: Jamais
|
||||
webhooks.list.card.created: Créée
|
||||
webhooks.create.title: Créer un webhook
|
||||
webhooks.create.description: Créez un webhook pour recevoir des événements lorsque des documents sont ajoutés à votre organisation.
|
||||
webhooks.create.success: Le webhook a été créé avec succès.
|
||||
webhooks.create.back: Retour aux webhooks
|
||||
webhooks.create.form.submit: Créer le webhook
|
||||
webhooks.create.form.name.label: Nom du webhook
|
||||
webhooks.create.form.name.placeholder: Entrez le nom du webhook
|
||||
webhooks.create.form.name.required: Le nom est requis
|
||||
webhooks.create.form.url.label: URL du webhook
|
||||
webhooks.create.form.url.placeholder: Entrez l'URL du webhook
|
||||
webhooks.create.form.url.required: L'URL est requise
|
||||
webhooks.create.form.url.invalid: L'URL est invalide
|
||||
webhooks.create.form.secret.label: Secret
|
||||
webhooks.create.form.secret.placeholder: Entrez le secret du webhook
|
||||
webhooks.create.form.events.label: Événements
|
||||
webhooks.create.form.events.required: Au moins un événement est requis
|
||||
webhooks.update.title: Modifier le webhook
|
||||
webhooks.update.description: Mettez à jour les détails de votre webhook
|
||||
webhooks.update.success: Le webhook a été mis à jour avec succès
|
||||
webhooks.update.submit: Mettre à jour le webhook
|
||||
webhooks.update.cancel: Annuler
|
||||
webhooks.update.form.secret.placeholder: Entrez un nouveau secret
|
||||
webhooks.update.form.secret.placeholder-redacted: '[Secret masqué]'
|
||||
webhooks.update.form.rotate-secret.button: Rotation du secret
|
||||
webhooks.delete.success: Le webhook a été supprimé avec succès
|
||||
webhooks.delete.confirm.title: Supprimer le webhook
|
||||
webhooks.delete.confirm.message: Êtes-vous sûr de vouloir supprimer ce webhook ? Cette action est irréversible.
|
||||
webhooks.delete.confirm.confirm-button: Supprimer
|
||||
webhooks.delete.confirm.cancel-button: Annuler
|
||||
|
||||
webhooks.events.documents.document:created.description: Document créé
|
||||
webhooks.events.documents.document:deleted.description: Document supprimé
|
||||
|
||||
# Navigation
|
||||
|
||||
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
|
||||
layout.menu.general-settings: Paramètres généraux
|
||||
layout.menu.intake-emails: Adresses de réception
|
||||
layout.menu.webhooks: Webhooks
|
||||
layout.menu.members: Membres
|
||||
layout.menu.invitations: Invitations
|
||||
|
||||
layout.theme.light: Mode clair
|
||||
layout.theme.dark: Mode sombre
|
||||
layout.theme.system: Mode système
|
||||
|
||||
layout.search.placeholder: Rechercher...
|
||||
layout.menu.import-document: Importer un document
|
||||
|
||||
user-menu.account-settings: Paramètres du compte
|
||||
user-menu.api-keys: Clés d'API
|
||||
user-menu.invitations: Invitations
|
||||
user-menu.language: Langue
|
||||
user-menu.logout: Déconnexion
|
||||
|
||||
# Command palette
|
||||
|
||||
command-palette.search.placeholder: Rechercher des commandes ou des documents
|
||||
command-palette.no-results: Aucun résultat trouvé
|
||||
command-palette.sections.documents: Documents
|
||||
command-palette.sections.theme: Thème
|
||||
|
||||
# API errors
|
||||
|
||||
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-errors.organization.invitation_already_exists: Une invitation pour cet email existe déjà dans cette organisation.
|
||||
api-errors.user.already_in_organization: Cet utilisateur est déjà dans cette organisation.
|
||||
api-errors.user.organization_invitation_limit_reached: Le nombre maximum d'invitations a été atteint pour aujourd'hui. Veuillez réessayer demain.
|
||||
api-errors.demo.not_available: Cette fonctionnalité n'est pas disponible dans la démo
|
||||
api-errors.tags.already_exists: Un tag avec ce nom existe déjà pour cette organisation
|
||||
|
||||
# Not found
|
||||
|
||||
not-found.title: 404 - Not Found
|
||||
not-found.description: Désolé, la page que vous cherchez n'existe pas. Veuillez vérifier l'URL et réessayer.
|
||||
not-found.back-to-home: Retour à l'accueil
|
||||
|
||||
# Demo
|
||||
|
||||
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 la démo
|
||||
demo.popup.hide: Masquer
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { LocaleKeys } from '@/modules/i18n/locales.types';
|
||||
import type { Component } from 'solid-js';
|
||||
import type { LocaleKeys } from '@/modules/i18n/locales.types';
|
||||
import { createSignal, For } 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) => {
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import type { ApiKey } from '../api-keys.types';
|
||||
import { A } from '@solidjs/router';
|
||||
import { useMutation, useQuery } from '@tanstack/solid-query';
|
||||
import { format } from 'date-fns';
|
||||
import { For, Match, Show, Suspense, Switch } from 'solid-js';
|
||||
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(() => ({
|
||||
const deleteApiKeyMutation = useMutation(() => ({
|
||||
mutationFn: deleteApiKey,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['api-keys'] });
|
||||
@@ -85,7 +85,7 @@ export const ApiKeyCard: Component<{ apiKey: ApiKey }> = ({ apiKey }) => {
|
||||
|
||||
export const ApiKeysPage: Component = () => {
|
||||
const { t } = useI18n();
|
||||
const query = createQuery(() => ({
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['api-keys'],
|
||||
queryFn: () => fetchApiKeys(),
|
||||
}));
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { setValue } from '@modular-forms/solid';
|
||||
import { A } from '@solidjs/router';
|
||||
import { createSignal, Show } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { createForm } from '@/modules/shared/form/form';
|
||||
import { queryClient } from '@/modules/shared/query/query-client';
|
||||
@@ -6,10 +10,6 @@ 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';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Config } from '../config/config';
|
||||
import type { SsoProviderConfig } from './auth.types';
|
||||
import { get } from 'lodash-es';
|
||||
import { ssoProviders } from './auth.constants';
|
||||
|
||||
@@ -8,8 +9,15 @@ export function isAuthErrorWithCode({ error, code }: { error: unknown; code: str
|
||||
|
||||
export const isEmailVerificationRequiredError = ({ error }: { error: unknown }) => isAuthErrorWithCode({ error, code: 'EMAIL_NOT_VERIFIED' });
|
||||
|
||||
export function getEnabledSsoProviderConfigs({ config }: { config: Config }) {
|
||||
const enabledSsoProviders = ssoProviders.filter(({ key }) => get(config, `auth.providers.${key}.isEnabled`));
|
||||
export function getEnabledSsoProviderConfigs({ config }: { config: Config }): SsoProviderConfig[] {
|
||||
const enabledSsoProviders: SsoProviderConfig[] = [
|
||||
...ssoProviders.filter(({ key }) => get(config, `auth.providers.${key}.isEnabled`)),
|
||||
...config.auth.providers.customs.map(({ providerId, providerName, providerIconUrl }) => ({
|
||||
key: providerId,
|
||||
name: providerName,
|
||||
icon: providerIconUrl ?? 'i-tabler-login-2',
|
||||
})),
|
||||
];
|
||||
|
||||
return enabledSsoProviders;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { createAuthClient as createBetterAuthClient } from 'better-auth/solid';
|
||||
import type { Config } from '../config/config';
|
||||
|
||||
import type { SsoProviderConfig } from './auth.types';
|
||||
import { genericOAuthClient } from 'better-auth/client/plugins';
|
||||
import { createAuthClient as createBetterAuthClient } from 'better-auth/solid';
|
||||
import { buildTimeConfig } from '../config/config';
|
||||
import { trackingServices } from '../tracking/tracking.services';
|
||||
import { createDemoAuthClient } from './auth.demo.services';
|
||||
@@ -7,6 +10,9 @@ import { createDemoAuthClient } from './auth.demo.services';
|
||||
export function createAuthClient() {
|
||||
const client = createBetterAuthClient({
|
||||
baseURL: buildTimeConfig.baseApiUrl,
|
||||
plugins: [
|
||||
genericOAuthClient(),
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -38,3 +44,17 @@ export const {
|
||||
} = buildTimeConfig.isDemoMode
|
||||
? createDemoAuthClient()
|
||||
: createAuthClient();
|
||||
|
||||
export async function authWithProvider({ provider, config }: { provider: SsoProviderConfig; config: Config }) {
|
||||
const isCustomProvider = config.auth.providers.customs.some(({ providerId }) => providerId === provider.key);
|
||||
|
||||
if (isCustomProvider) {
|
||||
signIn.oauth2({
|
||||
providerId: provider.key,
|
||||
callbackURL: config.baseUrl,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await signIn.social({ provider: provider.key as 'github' | 'google', callbackURL: config.baseUrl });
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ssoProviders } from './auth.constants';
|
||||
|
||||
export type SsoProviderKey = (typeof ssoProviders)[number]['key'];
|
||||
export type SsoProviderKey = (typeof ssoProviders)[number]['key'] | string & {};
|
||||
export type SsoProviderConfig = { key: SsoProviderKey; name: string; icon: string };
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { A } from '@solidjs/router';
|
||||
import { useConfig } from '@/modules/config/config.provider';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { createVitrineUrl } from '@/modules/shared/utils/urls';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { A } from '@solidjs/router';
|
||||
|
||||
export const AuthLegalLinks: Component = () => {
|
||||
const { config } = useConfig();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Component, ComponentProps } from 'solid-js';
|
||||
import { splitProps } 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 { splitProps } from 'solid-js';
|
||||
|
||||
const providers = [
|
||||
{
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { createSignal, Match, Switch } from 'solid-js';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { createSignal } from 'solid-js';
|
||||
|
||||
export const SsoProviderButton: Component<{ name: string; icon: string; onClick: () => Promise<void>; label: string }> = (props) => {
|
||||
export const SsoProviderButton: Component<{ name: string; icon?: string; onClick: () => Promise<void>; label: string }> = (props) => {
|
||||
const [getIsLoading, setIsLoading] = createSignal(false);
|
||||
|
||||
const navigateToProvider = async () => {
|
||||
const onClick = async () => {
|
||||
setIsLoading(true);
|
||||
await props.onClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant="secondary" class="block w-full flex items-center justify-center" onClick={navigateToProvider} disabled={getIsLoading()}>
|
||||
<span class={cn(`mr-2 size-4.5 inline-block`, getIsLoading() ? 'i-tabler-loader-2 animate-spin' : props.icon)} />
|
||||
<Button variant="secondary" class="block w-full flex items-center justify-center gap-2" onClick={onClick} disabled={getIsLoading()}>
|
||||
|
||||
<Switch>
|
||||
<Match when={getIsLoading()}>
|
||||
<span class="i-tabler-loader-2 animate-spin" />
|
||||
</Match>
|
||||
|
||||
<Match when={props.icon?.startsWith('i-')}>
|
||||
<span class={cn(`size-4.5`, props.icon)} />
|
||||
</Match>
|
||||
|
||||
<Match when={props.icon}>
|
||||
<img src={props.icon} alt={props.name} class="size-4.5" />
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
{props.label}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import type { SsoProviderKey } from '../auth.types';
|
||||
import type { SsoProviderConfig } from '../auth.types';
|
||||
import { A, useNavigate } from '@solidjs/router';
|
||||
import { createSignal, For, Show } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { useConfig } from '@/modules/config/config.provider';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { createForm } from '@/modules/shared/form/form';
|
||||
@@ -7,12 +10,9 @@ import { Button } from '@/modules/ui/components/button';
|
||||
import { Checkbox, CheckboxControl, CheckboxLabel } from '@/modules/ui/components/checkbox';
|
||||
import { Separator } from '@/modules/ui/components/separator';
|
||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { A, useNavigate } from '@solidjs/router';
|
||||
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';
|
||||
import { signIn } from '../auth.services';
|
||||
import { authWithProvider, signIn } from '../auth.services';
|
||||
import { AuthLegalLinks } from '../components/legal-links.component';
|
||||
import { SsoProviderButton } from '../components/sso-provider-button.component';
|
||||
|
||||
@@ -86,9 +86,11 @@ export const EmailLoginForm: Component = () => {
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Button variant="link" as={A} class="inline p-0! h-auto" href="/request-password-reset">
|
||||
{t('auth.login.form.forgot-password.label')}
|
||||
</Button>
|
||||
<Show when={config.auth.isPasswordResetEnabled}>
|
||||
<Button variant="link" as={A} class="inline p-0! h-auto" href="/request-password-reset">
|
||||
{t('auth.login.form.forgot-password.label')}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Button type="submit" class="w-full">{t('auth.login.form.submit')}</Button>
|
||||
@@ -105,8 +107,8 @@ export const LoginPage: Component = () => {
|
||||
|
||||
const [getShowEmailLogin, setShowEmailLogin] = createSignal(false);
|
||||
|
||||
const loginWithProvider = async (provider: { key: SsoProviderKey }) => {
|
||||
await signIn.social({ provider: provider.key, callbackURL: config.baseUrl });
|
||||
const loginWithProvider = async (provider: SsoProviderConfig) => {
|
||||
await authWithProvider({ provider, config });
|
||||
};
|
||||
|
||||
const getHasSsoProviders = () => getEnabledSsoProviderConfigs({ config }).length > 0;
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import type { ssoProviders } from '../auth.constants';
|
||||
import type { SsoProviderConfig } from '../auth.types';
|
||||
import { A, useNavigate } from '@solidjs/router';
|
||||
import { createSignal, For, Show } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
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 { createSignal, For, Show } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
|
||||
import { getEnabledSsoProviderConfigs } from '../auth.models';
|
||||
import { signIn, signUp } from '../auth.services';
|
||||
import { authWithProvider, signUp } from '../auth.services';
|
||||
import { AuthLegalLinks } from '../components/legal-links.component';
|
||||
import { SsoProviderButton } from '../components/sso-provider-button.component';
|
||||
|
||||
@@ -133,8 +133,8 @@ export const RegisterPage: Component = () => {
|
||||
|
||||
const [getShowEmailRegister, setShowEmailRegister] = createSignal(false);
|
||||
|
||||
const registerWithProvider = async (provider: typeof ssoProviders[number]) => {
|
||||
await signIn.social({ provider: provider.key });
|
||||
const registerWithProvider = async (provider: SsoProviderConfig) => {
|
||||
await authWithProvider({ provider, config });
|
||||
};
|
||||
|
||||
const getHasSsoProviders = () => getEnabledSsoProviderConfigs({ config }).length > 0;
|
||||
@@ -169,7 +169,7 @@ export const RegisterPage: Component = () => {
|
||||
name={provider.name}
|
||||
icon={provider.icon}
|
||||
onClick={() => registerWithProvider(provider)}
|
||||
label={t('auth.register.register-with-provider', { provider: t(`auth.register.providers.${provider.key}`) })}
|
||||
label={t('auth.register.register-with-provider', { provider: provider.name })}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { buildUrl } from '@corentinth/chisels';
|
||||
import { A, useNavigate } from '@solidjs/router';
|
||||
import { createSignal, onMount } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
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 { 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';
|
||||
@@ -58,7 +58,7 @@ export const RequestPasswordResetPage: Component = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
onMount(() => {
|
||||
if (config.auth.isPasswordResetEnabled) {
|
||||
if (!config.auth.isPasswordResetEnabled) {
|
||||
navigate('/login');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { A, Navigate, useNavigate, useSearchParams } from '@solidjs/router';
|
||||
import { createSignal, onMount } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
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 { createSignal, onMount } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
|
||||
import { resetPassword } from '../auth.services';
|
||||
|
||||
@@ -62,7 +62,7 @@ export const ResetPasswordPage: Component = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
onMount(() => {
|
||||
if (config.auth.isPasswordResetEnabled) {
|
||||
if (!config.auth.isPasswordResetEnabled) {
|
||||
navigate('/login');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { debounce } from 'lodash-es';
|
||||
import { createContext, createEffect, createSignal, For, on, onCleanup, onMount, Show, useContext } from 'solid-js';
|
||||
import { getDocumentIcon } from '../documents/document.models';
|
||||
import { searchDocuments } from '../documents/documents.services';
|
||||
import { useI18n } from '../i18n/i18n.provider';
|
||||
import { cn } from '../shared/style/cn';
|
||||
import { useThemeStore } from '../theme/theme.store';
|
||||
import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandLoading } from '../ui/components/command';
|
||||
@@ -29,9 +30,11 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
|
||||
const [getIsCommandPaletteOpen, setIsCommandPaletteOpen] = createSignal(false);
|
||||
const [getMatchingDocuments, setMatchingDocuments] = createSignal<Document[]>([]);
|
||||
const [getSearchQuery, setSearchQuery] = createSignal('');
|
||||
const params = useParams();
|
||||
const [getIsLoading, setIsLoading] = createSignal(false);
|
||||
|
||||
const params = useParams();
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
@@ -82,7 +85,7 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
|
||||
options: { label: string; icon: string; action: () => void; forceMatch?: boolean }[];
|
||||
}[] => [
|
||||
{
|
||||
label: 'Documents',
|
||||
label: t('command-palette.sections.documents'),
|
||||
forceMatch: true,
|
||||
options: getMatchingDocuments().map(document => ({
|
||||
label: document.name,
|
||||
@@ -92,20 +95,20 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
|
||||
})),
|
||||
},
|
||||
{
|
||||
label: `Theme`,
|
||||
label: t('command-palette.sections.theme'),
|
||||
options: [
|
||||
{
|
||||
label: 'Switch to light mode',
|
||||
label: t('layout.theme.light'),
|
||||
icon: 'i-tabler-sun',
|
||||
action: () => setColorMode({ mode: 'light' }),
|
||||
},
|
||||
{
|
||||
label: 'Switch to dark mode',
|
||||
label: t('layout.theme.dark'),
|
||||
icon: 'i-tabler-moon',
|
||||
action: () => setColorMode({ mode: 'dark' }),
|
||||
},
|
||||
{
|
||||
label: 'Switch to system',
|
||||
label: t('layout.theme.system'),
|
||||
icon: 'i-tabler-device-laptop',
|
||||
action: () => setColorMode({ mode: 'system' }),
|
||||
},
|
||||
@@ -132,7 +135,7 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
|
||||
onOpenChange={setIsCommandPaletteOpen}
|
||||
>
|
||||
|
||||
<CommandInput onValueChange={setSearchQuery} placeholder="Search commands or documents" />
|
||||
<CommandInput onValueChange={setSearchQuery} placeholder={t('command-palette.search.placeholder')} />
|
||||
<CommandList>
|
||||
<Show when={getIsLoading()}>
|
||||
<CommandLoading>
|
||||
@@ -142,7 +145,7 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
|
||||
<Show when={!getIsLoading()}>
|
||||
<Show when={getMatchingDocuments().length === 0}>
|
||||
<CommandEmpty>
|
||||
No results found.
|
||||
{t('command-palette.no-results')}
|
||||
</CommandEmpty>
|
||||
</Show>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ParentComponent } from 'solid-js';
|
||||
import type { Config, RuntimePublicConfig } from './config';
|
||||
import { createQuery } from '@tanstack/solid-query';
|
||||
import { useQuery } from '@tanstack/solid-query';
|
||||
import { merge } from 'lodash-es';
|
||||
import { createContext, Match, Switch, useContext } from 'solid-js';
|
||||
import { Button } from '../ui/components/button';
|
||||
@@ -24,7 +24,7 @@ export function useConfig() {
|
||||
}
|
||||
|
||||
export const ConfigProvider: ParentComponent = (props) => {
|
||||
const query = createQuery(() => ({
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['config'],
|
||||
queryFn: fetchPublicConfig,
|
||||
}));
|
||||
|
||||
@@ -5,7 +5,7 @@ const asString = <T extends string | undefined>(value: string | undefined, defau
|
||||
const asNumber = <T extends number | undefined>(value: string | undefined, defaultValue?: T): T extends undefined ? number | undefined : number => (value === undefined ? defaultValue : Number(value)) as T extends undefined ? number | undefined : number;
|
||||
|
||||
export const buildTimeConfig = {
|
||||
papraVersion: asString(import.meta.env.VITE_PAPRA_VERSION),
|
||||
papraVersion: asString(import.meta.env.VITE_PAPRA_VERSION, '0.0.0'),
|
||||
baseUrl: asString(import.meta.env.VITE_BASE_URL, window.location.origin),
|
||||
baseApiUrl: asString(import.meta.env.VITE_BASE_API_URL, window.location.origin),
|
||||
vitrineBaseUrl: asString(import.meta.env.VITE_VITRINE_BASE_URL, 'http://localhost:3000/'),
|
||||
@@ -18,6 +18,11 @@ export const buildTimeConfig = {
|
||||
providers: {
|
||||
github: { isEnabled: asBoolean(import.meta.env.VITE_AUTH_PROVIDERS_GITHUB_IS_ENABLED, false) },
|
||||
google: { isEnabled: asBoolean(import.meta.env.VITE_AUTH_PROVIDERS_GOOGLE_IS_ENABLED, false) },
|
||||
customs: [] as {
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
providerIconUrl: string;
|
||||
}[],
|
||||
},
|
||||
},
|
||||
documents: {
|
||||
@@ -32,6 +37,7 @@ export const buildTimeConfig = {
|
||||
isEnabled: asBoolean(import.meta.env.VITE_INTAKE_EMAILS_IS_ENABLED, false),
|
||||
emailGenerationDomain: asString(import.meta.env.VITE_INTAKE_EMAILS_EMAIL_GENERATION_DOMAIN),
|
||||
},
|
||||
isSubscriptionsEnabled: asBoolean(import.meta.env.VITE_IS_SUBSCRIPTIONS_ENABLED, false),
|
||||
} as const;
|
||||
|
||||
export type Config = typeof buildTimeConfig;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ApiKey } from '../api-keys/api-keys.types';
|
||||
import type { Webhook } from '../webhooks/webhooks.types';
|
||||
import { get } from 'lodash-es';
|
||||
import { FetchError } from 'ofetch';
|
||||
import { createRouter } from 'radix3';
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
tagDocumentStorage,
|
||||
taggingRuleStorage,
|
||||
tagStorage,
|
||||
webhooksStorage,
|
||||
} from './demo.storage';
|
||||
import { findMany, getValues } from './demo.storage.models';
|
||||
|
||||
@@ -191,7 +193,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
const {
|
||||
pageIndex = 0,
|
||||
pageSize = 5,
|
||||
searchQuery = '',
|
||||
searchQuery: rawSearchQuery = '',
|
||||
} = query ?? {};
|
||||
|
||||
const organization = organizationStorage.getItem(organizationId);
|
||||
@@ -199,7 +201,9 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
|
||||
const documents = await findMany(documentStorage, document => document?.organizationId === organizationId);
|
||||
|
||||
const filteredDocuments = documents.filter(document => document?.name.includes(searchQuery) && !document?.deletedAt);
|
||||
const searchQuery = rawSearchQuery.trim().toLowerCase();
|
||||
|
||||
const filteredDocuments = documents.filter(document => document?.name.toLowerCase().includes(searchQuery) && !document?.deletedAt);
|
||||
|
||||
return {
|
||||
documents: filteredDocuments.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize),
|
||||
@@ -565,6 +569,55 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/members',
|
||||
method: 'GET',
|
||||
handler: async ({ params: { organizationId } }) => {
|
||||
return {
|
||||
members: [{
|
||||
id: 'mem_1',
|
||||
user: {
|
||||
id: 'usr_1',
|
||||
email: 'jane.doe@papra.app',
|
||||
name: 'Jane Doe',
|
||||
},
|
||||
role: 'owner',
|
||||
organizationId,
|
||||
}],
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/members/invitations',
|
||||
method: 'POST',
|
||||
handler: async () => {
|
||||
throw Object.assign(new FetchError('Not available in demo'), {
|
||||
status: 501,
|
||||
data: {
|
||||
error: {
|
||||
message: 'This feature is not available in demo',
|
||||
code: 'demo.not_available',
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/members/me',
|
||||
method: 'GET',
|
||||
handler: async ({ params: { organizationId } }) => {
|
||||
return {
|
||||
member: {
|
||||
id: 'mem_1',
|
||||
role: 'owner',
|
||||
organizationId,
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/api-keys',
|
||||
method: 'GET',
|
||||
@@ -606,6 +659,80 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
await apiKeyStorage.removeItem(apiKeyId);
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/invitations/count',
|
||||
method: 'GET',
|
||||
handler: async () => ({ pendingInvitationsCount: 0 }),
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/invitations',
|
||||
method: 'GET',
|
||||
handler: async () => ({ invitations: [] }),
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/webhooks',
|
||||
method: 'GET',
|
||||
handler: async ({ params: { organizationId } }) => {
|
||||
const webhooks = await findMany(webhooksStorage, webhook => webhook.organizationId === organizationId);
|
||||
|
||||
return { webhooks };
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/webhooks',
|
||||
method: 'POST',
|
||||
handler: async ({ params: { organizationId }, body }) => {
|
||||
const webhook: Webhook = {
|
||||
id: createId({ prefix: 'webhook' }),
|
||||
organizationId,
|
||||
name: get(body, 'name'),
|
||||
url: get(body, 'url'),
|
||||
enabled: true,
|
||||
events: get(body, 'events'),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await webhooksStorage.setItem(webhook.id, webhook);
|
||||
|
||||
return { webhook };
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/webhooks/:webhookId',
|
||||
method: 'GET',
|
||||
handler: async ({ params: { webhookId } }) => {
|
||||
const webhook = await webhooksStorage.getItem(webhookId);
|
||||
return { webhook };
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/webhooks/:webhookId',
|
||||
method: 'DELETE',
|
||||
handler: async ({ params: { webhookId } }) => {
|
||||
await webhooksStorage.removeItem(webhookId);
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/webhooks/:webhookId',
|
||||
method: 'PUT',
|
||||
handler: async ({ params: { webhookId }, body }) => {
|
||||
const webhook = await webhooksStorage.getItem(webhookId);
|
||||
|
||||
assert(webhook, { status: 404 });
|
||||
|
||||
await webhooksStorage.setItem(webhookId, Object.assign(webhook, body, { updatedAt: new Date() }));
|
||||
|
||||
return { webhook };
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
export const router = createRouter({ routes: inMemoryApiMock, strictTrailingSlash: false });
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 type { Webhook } from '../webhooks/webhooks.types';
|
||||
import { createStorage, prefixStorage } from 'unstorage';
|
||||
import localStorageDriver from 'unstorage/drivers/localstorage';
|
||||
import { trackingServices } from '../tracking/tracking.services';
|
||||
@@ -18,6 +19,7 @@ export const tagStorage = prefixStorage<Omit<Tag, 'documentsCount'>>(storage, 't
|
||||
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 const webhooksStorage = prefixStorage<Webhook>(storage, 'webhooks');
|
||||
|
||||
export async function clearDemoStorage() {
|
||||
await storage.clear();
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
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 { 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 { invalidateOrganizationDocumentsQuery } from '../documents.composables';
|
||||
import { uploadDocument } from '../documents.services';
|
||||
|
||||
@@ -17,7 +17,7 @@ const DocumentUploadContext = createContext<{
|
||||
uploadDocuments: (args: { files: File[]; organizationId: string }) => Promise<void>;
|
||||
}>();
|
||||
|
||||
export function useDocumentUpload({ organizationId }: { organizationId: string }) {
|
||||
export function useDocumentUpload({ getOrganizationId }: { getOrganizationId: () => string }) {
|
||||
const context = useContext(DocumentUploadContext);
|
||||
|
||||
if (!context) {
|
||||
@@ -27,11 +27,11 @@ export function useDocumentUpload({ organizationId }: { organizationId: string }
|
||||
const { uploadDocuments } = context;
|
||||
|
||||
return {
|
||||
uploadDocuments: async ({ files }: { files: File[] }) => uploadDocuments({ files, organizationId }),
|
||||
uploadDocuments: async ({ files }: { files: File[] }) => uploadDocuments({ files, organizationId: getOrganizationId() }),
|
||||
promptImport: async () => {
|
||||
const { files } = await promptUploadFiles();
|
||||
|
||||
await uploadDocuments({ files, organizationId });
|
||||
await uploadDocuments({ files, organizationId: getOrganizationId() });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import type { DropdownMenuSubTriggerProps } from '@kobalte/core/dropdown-menu';
|
||||
import type { Component } from 'solid-js';
|
||||
import type { Document } from '../documents.types';
|
||||
import { A } from '@solidjs/router';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/modules/ui/components/dropdown-menu';
|
||||
import { A } from '@solidjs/router';
|
||||
import { useDeleteDocument } from '../documents.composables';
|
||||
import { useRenameDocumentDialog } from './rename-document-button.component';
|
||||
|
||||
export const DocumentManagementDropdown: Component<{ document: Document }> = (props) => {
|
||||
const { deleteDocument } = useDeleteDocument();
|
||||
const { openRenameDialog } = useRenameDocumentDialog();
|
||||
|
||||
const deleteDoc = () => deleteDocument({
|
||||
documentId: props.document.id,
|
||||
@@ -16,6 +18,7 @@ export const DocumentManagementDropdown: Component<{ document: Document }> = (pr
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
as={(props: DropdownMenuSubTriggerProps) => (
|
||||
@@ -34,6 +37,18 @@ export const DocumentManagementDropdown: Component<{ document: Document }> = (pr
|
||||
<span>Document details</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
class="cursor-pointer"
|
||||
onClick={() => openRenameDialog({
|
||||
documentId: props.document.id,
|
||||
organizationId: props.document.organizationId,
|
||||
documentName: props.document.name,
|
||||
})}
|
||||
>
|
||||
<div class="i-tabler-pencil size-4 mr-2"></div>
|
||||
<span>Rename document</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
class="cursor-pointer text-red"
|
||||
onClick={() => deleteDoc()}
|
||||
@@ -43,5 +58,6 @@ export const DocumentManagementDropdown: Component<{ document: Document }> = (pr
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +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 { useQuery } from '@tanstack/solid-query';
|
||||
import { createResource, Match, Suspense, Switch } from 'solid-js';
|
||||
import { Card } from '@/modules/ui/components/card';
|
||||
import { fetchDocumentFile } from '../documents.services';
|
||||
import { PdfViewer } from './pdf-viewer.component';
|
||||
|
||||
@@ -35,7 +35,7 @@ export const DocumentPreview: Component<{ document: Document }> = (props) => {
|
||||
const getIsImage = () => imageMimeType.includes(props.document.mimeType);
|
||||
const getIsPdf = () => pdfMimeType.includes(props.document.mimeType);
|
||||
|
||||
const query = createQuery(() => ({
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['organizations', props.document.organizationId, 'documents', props.document.id, 'file'],
|
||||
queryFn: () => fetchDocumentFile({ documentId: props.document.id, organizationId: props.document.organizationId }),
|
||||
}));
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { promptUploadFiles } from '@/modules/shared/files/upload';
|
||||
import { queryClient } from '@/modules/shared/query/query-client';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { uploadDocument } from '../documents.services';
|
||||
|
||||
export const DocumentUploadArea: Component<{ organizationId?: string }> = (props) => {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
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 type { Tag } from '@/modules/tags/tags.types';
|
||||
import { formatBytes } from '@corentinth/chisels';
|
||||
import { A } from '@solidjs/router';
|
||||
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
|
||||
import { For, Match, Show, Switch } from 'solid-js';
|
||||
import { timeAgo } from '@/modules/shared/date/time-ago';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { TagLink } from '@/modules/tags/components/tag.component';
|
||||
@@ -10,10 +14,6 @@ import { Button } from '@/modules/ui/components/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/modules/ui/components/select';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/modules/ui/components/table';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/modules/ui/components/tooltip';
|
||||
import { formatBytes } from '@corentinth/chisels';
|
||||
import { A } from '@solidjs/router';
|
||||
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
|
||||
import { For, Match, Show, Switch } from 'solid-js';
|
||||
import { getDocumentIcon, getDocumentNameExtension, getDocumentNameWithoutExtension } from '../document.models';
|
||||
import { DocumentManagementDropdown } from './document-management-dropdown.component';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { createSignal, onCleanup } from 'solid-js';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
|
||||
export const GlobalDropArea: Component<{ onFilesDrop?: (args: { files: File[] }) => void }> = (props) => {
|
||||
const [isDragging, setIsDragging] = createSignal(false);
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { Component, ParentComponent } from 'solid-js';
|
||||
import { setValue } from '@modular-forms/solid';
|
||||
import { useMutation } from '@tanstack/solid-query';
|
||||
import { createContext, createEffect, createSignal, useContext } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { createForm } from '@/modules/shared/form/form';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/modules/ui/components/dialog';
|
||||
import { createToast } from '@/modules/ui/components/sonner';
|
||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { getDocumentNameExtension, getDocumentNameWithoutExtension } from '../document.models';
|
||||
import { invalidateOrganizationDocumentsQuery } from '../documents.composables';
|
||||
import { updateDocument } from '../documents.services';
|
||||
|
||||
export const RenameDocumentDialog: Component<{
|
||||
documentId: string;
|
||||
organizationId: string;
|
||||
documentName: string;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}> = (props) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const renameDocumentMutation = useMutation(() => ({
|
||||
mutationFn: ({ name }: { name: string }) => updateDocument({ documentId: props.documentId, organizationId: props.organizationId, name }),
|
||||
onSuccess: async () => {
|
||||
createToast({
|
||||
message: t('documents.rename.success'),
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
props.setIsOpen(false);
|
||||
|
||||
await invalidateOrganizationDocumentsQuery({ organizationId: props.organizationId });
|
||||
},
|
||||
|
||||
}));
|
||||
|
||||
const { Form, Field, form } = createForm({
|
||||
schema: v.object({
|
||||
name: v.pipe(
|
||||
v.string(),
|
||||
v.trim(),
|
||||
v.maxLength(255, t('documents.rename.form.name.max-length')),
|
||||
v.minLength(1, t('documents.rename.form.name.required')),
|
||||
),
|
||||
}),
|
||||
initialValues: {
|
||||
name: getDocumentNameWithoutExtension({ name: props.documentName }),
|
||||
},
|
||||
onSubmit: async ({ name }) => {
|
||||
const extension = getDocumentNameExtension({ name: props.documentName });
|
||||
const newName = extension ? `${name}.${extension}` : name;
|
||||
|
||||
await renameDocumentMutation.mutateAsync({ name: newName });
|
||||
},
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
setValue(form, 'name', getDocumentNameWithoutExtension({ name: props.documentName }));
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={props.setIsOpen} open={props.isOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('documents.rename.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Form>
|
||||
<Field name="name">
|
||||
{(field, inputProps) => (
|
||||
<TextFieldRoot>
|
||||
<TextFieldLabel class="sr-only" for="name">{t('documents.rename.form.name.label')}</TextFieldLabel>
|
||||
<TextField {...inputProps} value={field.value} id="name" placeholder={t('documents.rename.form.name.placeholder')} />
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<div class="flex justify-end mt-4 gap-2">
|
||||
<Button type="button" variant="secondary" onClick={() => props.setIsOpen(false)}>
|
||||
{t('documents.rename.cancel')}
|
||||
</Button>
|
||||
<Button type="submit">{t('documents.rename.form.submit')}</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const context = createContext<{
|
||||
openRenameDialog: (args: { documentId: string; organizationId: string; documentName: string }) => void;
|
||||
}>();
|
||||
|
||||
export function useRenameDocumentDialog() {
|
||||
const renameDialogContext = useContext(context);
|
||||
|
||||
if (!renameDialogContext) {
|
||||
throw new Error('useRenameDocumentDialog must be used within a RenameDocumentDialogProvider');
|
||||
}
|
||||
|
||||
return renameDialogContext;
|
||||
}
|
||||
|
||||
export const RenameDocumentDialogProvider: ParentComponent = (props) => {
|
||||
const [getIsRenameDialogOpen, setIsRenameDialogOpen] = createSignal(false);
|
||||
const [getDocumentId, setDocumentId] = createSignal<string | undefined>(undefined);
|
||||
const [getOrganizationId, setOrganizationId] = createSignal<string | undefined>(undefined);
|
||||
const [getDocumentName, setDocumentName] = createSignal<string | undefined>(undefined);
|
||||
|
||||
return (
|
||||
<context.Provider
|
||||
value={{
|
||||
openRenameDialog: ({ documentId, organizationId, documentName }) => {
|
||||
setIsRenameDialogOpen(true);
|
||||
setDocumentId(documentId);
|
||||
setOrganizationId(organizationId);
|
||||
setDocumentName(documentName);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<RenameDocumentDialog
|
||||
documentId={getDocumentId() ?? ''}
|
||||
organizationId={getOrganizationId() ?? ''}
|
||||
documentName={getDocumentName() ?? ''}
|
||||
isOpen={getIsRenameDialogOpen()}
|
||||
setIsOpen={setIsRenameDialogOpen}
|
||||
/>
|
||||
|
||||
{props.children}
|
||||
</context.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DocumentActivityEvent } from './documents.types';
|
||||
import { addDays, differenceInDays } from 'date-fns';
|
||||
|
||||
export const iconByFileType = {
|
||||
@@ -79,3 +80,16 @@ export function getDocumentNameExtension({ name }: { name: string }) {
|
||||
|
||||
return dotSplittedName[dotCount];
|
||||
}
|
||||
|
||||
export const documentActivityIcon: Record<DocumentActivityEvent, string> = {
|
||||
created: 'i-tabler-file-plus',
|
||||
updated: 'i-tabler-file-diff',
|
||||
deleted: 'i-tabler-file-x',
|
||||
restored: 'i-tabler-file-check',
|
||||
tagged: 'i-tabler-tag',
|
||||
untagged: 'i-tabler-tag-off',
|
||||
} as const;
|
||||
|
||||
export function getDocumentActivityIcon({ event }: { event: DocumentActivityEvent }) {
|
||||
return documentActivityIcon[event] ?? 'i-tabler-file';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export const DOCUMENT_ACTIVITY_EVENTS = {
|
||||
CREATED: 'created',
|
||||
UPDATED: 'updated',
|
||||
DELETED: 'deleted',
|
||||
RESTORED: 'restored',
|
||||
TAGGED: 'tagged',
|
||||
UNTAGGED: 'untagged',
|
||||
} as const;
|
||||
|
||||
export const DOCUMENT_ACTIVITY_EVENT_LIST = Object.values(DOCUMENT_ACTIVITY_EVENTS);
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AsDto } from '../shared/http/http-client.types';
|
||||
import type { Document } from './documents.types';
|
||||
import type { Document, DocumentActivity } from './documents.types';
|
||||
import { apiClient } from '../shared/http/api-client';
|
||||
import { coerceDates, getFormData } from '../shared/http/http-client.models';
|
||||
|
||||
@@ -194,18 +194,45 @@ export async function updateDocument({
|
||||
documentId,
|
||||
organizationId,
|
||||
content,
|
||||
name,
|
||||
}: {
|
||||
documentId: string;
|
||||
organizationId: string;
|
||||
content: string;
|
||||
content?: string;
|
||||
name?: string;
|
||||
}) {
|
||||
const { document } = await apiClient<{ document: AsDto<Document> }>({
|
||||
method: 'PATCH',
|
||||
path: `/api/organizations/${organizationId}/documents/${documentId}`,
|
||||
body: { content },
|
||||
body: { content, name },
|
||||
});
|
||||
|
||||
return {
|
||||
document: coerceDates(document),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchDocumentActivities({
|
||||
documentId,
|
||||
organizationId,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
}: {
|
||||
documentId: string;
|
||||
organizationId: string;
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
}) {
|
||||
const { activities } = await apiClient<{ activities: AsDto<DocumentActivity>[] }>({
|
||||
method: 'GET',
|
||||
path: `/api/organizations/${organizationId}/documents/${documentId}/activity`,
|
||||
query: {
|
||||
pageIndex,
|
||||
pageSize,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
activities: activities.map(coerceDates),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Tag } from '../tags/tags.types';
|
||||
import type { User } from '../users/users.types';
|
||||
import type { DOCUMENT_ACTIVITY_EVENTS } from './documents.constants';
|
||||
|
||||
export type Document = {
|
||||
id: string;
|
||||
@@ -14,3 +16,17 @@ export type Document = {
|
||||
content: string;
|
||||
tags: Tag[];
|
||||
};
|
||||
|
||||
export type DocumentActivityEvent = (typeof DOCUMENT_ACTIVITY_EVENTS)[keyof typeof DOCUMENT_ACTIVITY_EVENTS];
|
||||
|
||||
export type DocumentActivity = {
|
||||
id: string;
|
||||
documentId: string;
|
||||
event: DocumentActivityEvent;
|
||||
eventData: Record<string, unknown>;
|
||||
userId?: string;
|
||||
createdAt: Date;
|
||||
updatedAt?: Date;
|
||||
tag?: Pick<Tag, 'id' | 'name' | 'color' | 'description'>;
|
||||
user?: Pick<User, 'id' | 'name'>;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import type { Document } from '../documents.types';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { keepPreviousData, useMutation, useQuery } from '@tanstack/solid-query';
|
||||
import { createSignal, Show, Suspense } from 'solid-js';
|
||||
import { useConfig } from '@/modules/config/config.provider';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { useConfirmModal } from '@/modules/shared/confirm';
|
||||
@@ -8,15 +11,13 @@ 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 { 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 { deleteAllTrashDocuments, deleteTrashDocument, fetchOrganizationDeletedDocuments } from '../documents.services';
|
||||
|
||||
const RestoreDocumentButton: Component<{ document: Document }> = (props) => {
|
||||
const { getIsRestoring, restore } = useRestoreDocument();
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -26,11 +27,11 @@ const RestoreDocumentButton: Component<{ document: Document }> = (props) => {
|
||||
isLoading={getIsRestoring()}
|
||||
>
|
||||
{ getIsRestoring()
|
||||
? (<>Restoring...</>)
|
||||
? (<>{t('documents.deleted.restoring')}</>)
|
||||
: (
|
||||
<>
|
||||
<div class="i-tabler-refresh size-4 mr-2" />
|
||||
Restore
|
||||
{t('documents.actions.restore')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -41,7 +42,7 @@ const PermanentlyDeleteTrashDocumentButton: Component<{ document: Document; orga
|
||||
const { confirm } = useConfirmModal();
|
||||
const { t } = useI18n();
|
||||
|
||||
const deleteMutation = createMutation(() => ({
|
||||
const deleteMutation = useMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
await deleteTrashDocument({ documentId: props.document.id, organizationId: props.organizationId });
|
||||
},
|
||||
@@ -82,7 +83,7 @@ const PermanentlyDeleteTrashDocumentButton: Component<{ document: Document; orga
|
||||
class="text-red-500 hover:text-red-600"
|
||||
>
|
||||
{deleteMutation.isPending
|
||||
? (<>Deleting...</>)
|
||||
? (<>{t('documents.deleted.deleting')}</>)
|
||||
: (
|
||||
<>
|
||||
<div class="i-tabler-trash size-4 mr-2" />
|
||||
@@ -97,7 +98,7 @@ const DeleteAllTrashDocumentsButton: Component<{ organizationId: string }> = (pr
|
||||
const { confirm } = useConfirmModal();
|
||||
const { t } = useI18n();
|
||||
|
||||
const deleteAllMutation = createMutation(() => ({
|
||||
const deleteAllMutation = useMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
await deleteAllTrashDocuments({ organizationId: props.organizationId });
|
||||
},
|
||||
@@ -133,7 +134,7 @@ const DeleteAllTrashDocumentsButton: Component<{ organizationId: string }> = (pr
|
||||
class="text-red-500 hover:text-red-600"
|
||||
>
|
||||
{deleteAllMutation.isPending
|
||||
? (<>Deleting...</>)
|
||||
? (<>{t('documents.deleted.deleting')}</>)
|
||||
: (
|
||||
<>
|
||||
<div class="i-tabler-trash size-4 mr-2" />
|
||||
@@ -148,8 +149,9 @@ export const DeletedDocumentsPage: Component = () => {
|
||||
const [getPagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 100 });
|
||||
const params = useParams();
|
||||
const { config } = useConfig();
|
||||
const { t } = useI18n();
|
||||
|
||||
const query = createQuery(() => ({
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'documents', 'deleted', getPagination()],
|
||||
queryFn: () => fetchOrganizationDeletedDocuments({
|
||||
organizationId: params.organizationId,
|
||||
@@ -160,16 +162,12 @@ export const DeletedDocumentsPage: Component = () => {
|
||||
|
||||
return (
|
||||
<div class="p-6 mt-4 pb-32">
|
||||
<h1 class="text-2xl font-bold">Deleted documents</h1>
|
||||
<h1 class="text-2xl font-bold">{t('documents.deleted.title')}</h1>
|
||||
|
||||
<Alert variant="muted" class="my-4 flex items-center gap-6 xl:gap-4">
|
||||
<div class="i-tabler-info-circle size-10 xl:size-8 text-primary flex-shrink-0 hidden sm:block" />
|
||||
<AlertDescription>
|
||||
All deleted documents are stored in the trash bin for
|
||||
{' '}
|
||||
{config.documents.deletedDocumentsRetentionDays}
|
||||
{' '}
|
||||
days. Passing this delay, the documents will be permanently deleted, and you will not be able to restore them.
|
||||
{t('documents.deleted.retention-notice', { days: config.documents.deletedDocumentsRetentionDays })}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -177,13 +175,9 @@ export const DeletedDocumentsPage: Component = () => {
|
||||
<Show when={query.data?.documents.length === 0}>
|
||||
<div class="flex flex-col items-center justify-center gap-2 pt-24 mx-auto max-w-md text-center">
|
||||
<div class="i-tabler-trash text-primary size-12" aria-hidden="true" />
|
||||
<div class="text-xl font-medium">No deleted documents</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
You have no deleted documents. Documents that are deleted will be moved to the trash bin for
|
||||
{' '}
|
||||
{config.documents.deletedDocumentsRetentionDays}
|
||||
{' '}
|
||||
days.
|
||||
<div class="text-xl font-medium">{t('documents.deleted.empty.title')}</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{t('documents.deleted.empty.description', { days: config.documents.deletedDocumentsRetentionDays })}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -203,7 +197,7 @@ export const DeletedDocumentsPage: Component = () => {
|
||||
id: 'deletion',
|
||||
cell: data => (
|
||||
<div class="text-muted-foreground hidden sm:block">
|
||||
Deleted
|
||||
{t('documents.deleted.deleted-at')}
|
||||
{' '}
|
||||
<span class="text-foreground font-bold" title={data.row.original.deletedAt?.toLocaleString()}>{timeAgo({ date: data.row.original.deletedAt! })}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import type { Component, JSX } from 'solid-js';
|
||||
import type { DocumentActivity } from '../documents.types';
|
||||
import { formatBytes, safely } from '@corentinth/chisels';
|
||||
import { A, useNavigate, useParams, useSearchParams } from '@solidjs/router';
|
||||
import { createQueries, useInfiniteQuery } from '@tanstack/solid-query';
|
||||
import { createEffect, createSignal, For, Match, Show, Suspense, Switch } 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 { cn } from '@/modules/shared/style/cn';
|
||||
import { DocumentTagPicker } from '@/modules/tags/components/tag-picker.component';
|
||||
import { TagLink } from '@/modules/tags/components/tag.component';
|
||||
import { CreateTagModal } from '@/modules/tags/pages/tags.page';
|
||||
import { addTagToDocument, removeTagFromDocument } from '@/modules/tags/tags.services';
|
||||
import { Alert, AlertDescription } from '@/modules/ui/components/alert';
|
||||
@@ -14,14 +21,11 @@ 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 { createSignal, For, Show, Suspense } from 'solid-js';
|
||||
import { DocumentPreview } from '../components/document-preview.component';
|
||||
import { getDaysBeforePermanentDeletion } from '../document.models';
|
||||
import { useRenameDocumentDialog } from '../components/rename-document-button.component';
|
||||
import { getDaysBeforePermanentDeletion, getDocumentActivityIcon } from '../document.models';
|
||||
import { useDeleteDocument, useRestoreDocument } from '../documents.composables';
|
||||
import { fetchDocument, fetchDocumentFile, updateDocument } from '../documents.services';
|
||||
import { fetchDocument, fetchDocumentActivities, fetchDocumentFile, updateDocument } from '../documents.services';
|
||||
import '@pdfslick/solid/dist/pdf_viewer.css';
|
||||
|
||||
type KeyValueItem = {
|
||||
@@ -50,13 +54,78 @@ const KeyValues: Component<{ data?: KeyValueItem[] }> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const ActivityItem: Component<{ activity: DocumentActivity }> = (props) => {
|
||||
const { t, te } = useI18n();
|
||||
const params = useParams();
|
||||
|
||||
return (
|
||||
<div class="border-b py-3 flex items-center gap-2">
|
||||
<div>
|
||||
<div class={cn(getDocumentActivityIcon({ event: props.activity.event }), 'size-6 text-muted-foreground')} />
|
||||
</div>
|
||||
<div>
|
||||
<Switch fallback={<span class="text-sm">{t(`activity.document.${props.activity.event}`)}</span>}>
|
||||
<Match when={['tagged', 'untagged'].includes(props.activity.event)}>
|
||||
<span class="text-sm flex items-baseline gap-1">
|
||||
{te(`activity.document.${props.activity.event}`, { tag: props.activity.tag ? <TagLink {...props.activity.tag} organizationId={params.organizationId} class="text-xs" /> : undefined })}
|
||||
</span>
|
||||
</Match>
|
||||
|
||||
<Match when={props.activity.event === 'updated' && (props.activity.eventData.updatedFields as string[]).length === 1}>
|
||||
<span class="text-sm flex items-baseline gap-1">
|
||||
{te(`activity.document.updated.single`, {
|
||||
field: <span class="font-bold">{(props.activity.eventData.updatedFields as string[])[0]}</span>,
|
||||
})}
|
||||
</span>
|
||||
</Match>
|
||||
|
||||
<Match when={props.activity.event === 'updated' && (props.activity.eventData.updatedFields as string[]).length > 1}>
|
||||
<span class="text-sm flex items-baseline gap-1">
|
||||
{te(`activity.document.updated.multiple`, { fields: (props.activity.eventData.updatedFields as string[]).join(', ') })}
|
||||
</span>
|
||||
</Match>
|
||||
|
||||
</Switch>
|
||||
|
||||
<div class="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<span title={props.activity.createdAt.toLocaleString()}>{timeAgo({ date: props.activity.createdAt })}</span>
|
||||
<Show when={props.activity.user}>
|
||||
{getUser => (
|
||||
<span>{te('activity.document.user.name', { name: <A href={`/organizations/${params.organizationId}/members`} class="underline hover:text-primary transition">{getUser().name}</A> })}</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const tabs = ['info', 'content', 'activity'] as const;
|
||||
type Tab = typeof tabs[number];
|
||||
|
||||
export const DocumentPage: Component = () => {
|
||||
const { t } = useI18n();
|
||||
const params = useParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { deleteDocument } = useDeleteDocument();
|
||||
const { restore, getIsRestoring } = useRestoreDocument();
|
||||
const navigate = useNavigate();
|
||||
const { config } = useConfig();
|
||||
const { openRenameDialog } = useRenameDocumentDialog();
|
||||
|
||||
const getInitialTab = (): Tab => {
|
||||
const tab = searchParams.tab;
|
||||
if (tab && typeof tab === 'string' && tabs.includes(tab as Tab)) {
|
||||
return tab as Tab;
|
||||
}
|
||||
return 'info';
|
||||
};
|
||||
|
||||
const [getTab, setTab] = createSignal<Tab>(getInitialTab());
|
||||
|
||||
createEffect(() => {
|
||||
setSearchParams({ tab: getTab() }, { replace: true });
|
||||
});
|
||||
|
||||
const queries = createQueries(() => ({
|
||||
queries: [
|
||||
@@ -71,6 +140,30 @@ export const DocumentPage: Component = () => {
|
||||
],
|
||||
}));
|
||||
|
||||
const activityPageSize = 20;
|
||||
const activityQuery = useInfiniteQuery(() => ({
|
||||
enabled: getTab() === 'activity',
|
||||
queryKey: ['organizations', params.organizationId, 'documents', params.documentId, 'activity'],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const { activities } = await fetchDocumentActivities({
|
||||
documentId: params.documentId,
|
||||
organizationId: params.organizationId,
|
||||
pageIndex: pageParam,
|
||||
pageSize: activityPageSize,
|
||||
});
|
||||
|
||||
return activities;
|
||||
},
|
||||
getNextPageParam: (lastPage, _pages, lastPageParam) => {
|
||||
if (lastPage.length < activityPageSize) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return lastPageParam + 1;
|
||||
},
|
||||
initialPageParam: 0,
|
||||
}));
|
||||
|
||||
const deleteDoc = async () => {
|
||||
if (!queries[0].data) {
|
||||
return;
|
||||
@@ -137,7 +230,21 @@ export const DocumentPage: Component = () => {
|
||||
{getDocument => (
|
||||
<div class="flex gap-4 md:pr-6">
|
||||
<div class="flex-1">
|
||||
<h1 class="text-xl font-semibold">{getDocument().name}</h1>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="flex items-center gap-2 group bg-transparent! px-0"
|
||||
onClick={() => openRenameDialog({
|
||||
documentId: getDocument().id,
|
||||
organizationId: params.organizationId,
|
||||
documentName: getDocument().name,
|
||||
})}
|
||||
>
|
||||
<h1 class="text-xl font-semibold">
|
||||
{getDocument().name}
|
||||
</h1>
|
||||
|
||||
<div class="i-tabler-pencil size-4 text-muted-foreground group-hover:text-foreground transition-colors"></div>
|
||||
</Button>
|
||||
<p class="text-sm text-muted-foreground mb-6">{getDocument().id}</p>
|
||||
|
||||
<div class="flex gap-2 mb-2">
|
||||
@@ -147,7 +254,7 @@ export const DocumentPage: Component = () => {
|
||||
size="sm"
|
||||
>
|
||||
<div class="i-tabler-download size-4 mr-2"></div>
|
||||
Download
|
||||
{t('documents.actions.download')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -156,7 +263,7 @@ export const DocumentPage: Component = () => {
|
||||
size="sm"
|
||||
>
|
||||
<div class="i-tabler-eye size-4 mr-2"></div>
|
||||
Open in new tab
|
||||
{t('documents.actions.open-in-new-tab')}
|
||||
</Button>
|
||||
|
||||
{getDocument().isDeleted
|
||||
@@ -168,7 +275,7 @@ export const DocumentPage: Component = () => {
|
||||
isLoading={getIsRestoring()}
|
||||
>
|
||||
<div class="i-tabler-refresh size-4 mr-2"></div>
|
||||
Restore
|
||||
{t('documents.actions.restore')}
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
@@ -178,7 +285,7 @@ export const DocumentPage: Component = () => {
|
||||
onClick={deleteDoc}
|
||||
>
|
||||
<div class="i-tabler-trash size-4 mr-2"></div>
|
||||
Delete
|
||||
{t('documents.actions.delete')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -218,56 +325,67 @@ export const DocumentPage: Component = () => {
|
||||
|
||||
{getDocument().isDeleted && (
|
||||
<Alert variant="destructive" class="mt-6">
|
||||
This document has been deleted and will be permanently removed in
|
||||
{' '}
|
||||
{getDaysBeforePermanentDeletion({
|
||||
{t('documents.deleted.message', { days: getDaysBeforePermanentDeletion({
|
||||
document: getDocument(),
|
||||
deletedDocumentsRetentionDays: config.documents.deletedDocumentsRetentionDays,
|
||||
})}
|
||||
{' '}
|
||||
days.
|
||||
}) ?? 0 })}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Separator class="my-3" />
|
||||
|
||||
<Tabs defaultValue="info" class="w-full">
|
||||
<Tabs value={getTab()} onChange={setTab} class="w-full">
|
||||
<TabsList class="w-full h-8">
|
||||
<TabsTrigger value="info">Info</TabsTrigger>
|
||||
<TabsTrigger value="content">Content</TabsTrigger>
|
||||
<TabsTrigger value="info">{t('documents.tabs.info')}</TabsTrigger>
|
||||
<TabsTrigger value="content">{t('documents.tabs.content')}</TabsTrigger>
|
||||
<TabsTrigger value="activity">{t('documents.tabs.activity')}</TabsTrigger>
|
||||
<TabsIndicator />
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="info">
|
||||
<KeyValues data={[
|
||||
{
|
||||
label: 'ID',
|
||||
label: t('documents.info.id'),
|
||||
value: getDocument().id,
|
||||
icon: 'i-tabler-id',
|
||||
},
|
||||
{
|
||||
label: 'Name',
|
||||
value: getDocument().name,
|
||||
label: t('documents.info.name'),
|
||||
value: (
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="flex items-center gap-2 group bg-transparent! p-0 h-auto"
|
||||
onClick={() => openRenameDialog({
|
||||
documentId: getDocument().id,
|
||||
organizationId: params.organizationId,
|
||||
documentName: getDocument().name,
|
||||
})}
|
||||
>
|
||||
{getDocument().name}
|
||||
|
||||
<div class="i-tabler-pencil size-4 text-muted-foreground group-hover:text-foreground transition-colors"></div>
|
||||
</Button>
|
||||
),
|
||||
icon: 'i-tabler-file-text',
|
||||
},
|
||||
{
|
||||
label: 'Type',
|
||||
label: t('documents.info.type'),
|
||||
value: getDocument().mimeType,
|
||||
icon: 'i-tabler-file-unknown',
|
||||
},
|
||||
{
|
||||
label: 'Size',
|
||||
label: t('documents.info.size'),
|
||||
value: formatBytes({ bytes: getDocument().originalSize, base: 1000 }),
|
||||
icon: 'i-tabler-weight',
|
||||
},
|
||||
{
|
||||
label: 'Created At',
|
||||
label: t('documents.info.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>,
|
||||
label: t('documents.info.updated-at'),
|
||||
value: getDocument().updatedAt ? timeAgo({ date: getDocument().updatedAt! }) : <span class="text-muted-foreground">{t('documents.info.never')}</span>,
|
||||
icon: 'i-tabler-calendar',
|
||||
},
|
||||
]}
|
||||
@@ -284,14 +402,14 @@ export const DocumentPage: Component = () => {
|
||||
<div class="flex justify-end">
|
||||
<Button variant="outline" onClick={handleEdit}>
|
||||
<div class="i-tabler-edit size-4 mr-2" />
|
||||
Edit
|
||||
{t('documents.actions.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.
|
||||
{t('documents.content.alert')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
@@ -307,15 +425,49 @@ export const DocumentPage: Component = () => {
|
||||
</TextFieldRoot>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={handleCancel} disabled={isSaving()}>
|
||||
Cancel
|
||||
{t('documents.actions.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving()}>
|
||||
{isSaving() ? 'Saving...' : 'Save'}
|
||||
{isSaving() ? t('documents.actions.saving') : t('documents.actions.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</TabsContent>
|
||||
<TabsContent value="activity">
|
||||
<Show when={activityQuery.data?.pages}>
|
||||
{getActivitiesPages => (
|
||||
<div class="flex flex-col">
|
||||
<For each={getActivitiesPages() ?? []}>
|
||||
{activities => (
|
||||
<For each={activities}>
|
||||
{activity => (
|
||||
<ActivityItem activity={activity} />
|
||||
)}
|
||||
</For>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show
|
||||
when={activityQuery.hasNextPage}
|
||||
fallback={(
|
||||
<div class="text-sm text-muted-foreground text-center py-4">
|
||||
{t('activity.no-more-activities')}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => activityQuery.fetchNextPage()}
|
||||
isLoading={activityQuery.isFetchingNextPage}
|
||||
>
|
||||
{t('activity.load-more')}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
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 { createSignal, For, Show, Suspense } from 'solid-js';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { fetchOrganization } from '@/modules/organizations/organizations.services';
|
||||
import { Tag } from '@/modules/tags/components/tag.component';
|
||||
import { fetchTags } from '@/modules/tags/tags.services';
|
||||
import { DocumentUploadArea } from '../components/document-upload-area.component';
|
||||
import { createdAtColumn, DocumentsPaginatedList, standardActionsColumn, tagsColumn } from '../components/documents-list.component';
|
||||
import { fetchOrganizationDocuments } from '../documents.services';
|
||||
|
||||
export const DocumentsPage: Component = () => {
|
||||
const params = useParams();
|
||||
const { t } = useI18n();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [getPagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 100 });
|
||||
|
||||
@@ -51,11 +53,11 @@ export const DocumentsPage: Component = () => {
|
||||
? (
|
||||
<>
|
||||
<h2 class="text-xl font-bold ">
|
||||
No documents
|
||||
{t('documents.list.no-documents.title')}
|
||||
</h2>
|
||||
|
||||
<p class="text-muted-foreground mt-1 mb-6">
|
||||
There are no documents in this organization yet. Start by uploading some documents.
|
||||
{t('documents.list.no-documents.description')}
|
||||
</p>
|
||||
|
||||
<DocumentUploadArea />
|
||||
@@ -65,7 +67,7 @@ export const DocumentsPage: Component = () => {
|
||||
: (
|
||||
<>
|
||||
<h2 class="text-lg font-semibold mb-4">
|
||||
Documents
|
||||
{t('documents.list.title')}
|
||||
</h2>
|
||||
<Show when={hasFilters()}>
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
@@ -83,7 +85,7 @@ export const DocumentsPage: Component = () => {
|
||||
|
||||
<Show when={hasFilters() && query[0].data?.documentsCount === 0}>
|
||||
<p class="text-muted-foreground mt-1 mb-6">
|
||||
No documents found
|
||||
{t('documents.list.no-results')}
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const locales = [
|
||||
{ key: 'en', name: 'English' },
|
||||
{ key: 'fr', name: 'Français' },
|
||||
{ key: 'de', name: 'Deutsch' },
|
||||
] as const;
|
||||
|
||||
@@ -40,6 +40,9 @@ describe('locales', () => {
|
||||
/^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
|
||||
/^organizations\.members\.roles\.[a-z0-9]+$/, // organizations.members.roles.admin
|
||||
/^activity\.document\.[a-z0-9:]+$/, // activity.document.created
|
||||
/^organizations\.invitations\.status\.[a-z0-9:]+$/, // organizations.invitations.status.pending
|
||||
];
|
||||
|
||||
const keys = new Set(
|
||||
|
||||
@@ -68,21 +68,223 @@ export type LocaleKeys =
|
||||
| 'auth.legal-links.description'
|
||||
| 'auth.legal-links.terms'
|
||||
| 'auth.legal-links.privacy'
|
||||
| 'user.settings.title'
|
||||
| 'user.settings.description'
|
||||
| 'user.settings.email.title'
|
||||
| 'user.settings.email.description'
|
||||
| 'user.settings.email.label'
|
||||
| 'user.settings.name.title'
|
||||
| 'user.settings.name.description'
|
||||
| 'user.settings.name.label'
|
||||
| 'user.settings.name.placeholder'
|
||||
| 'user.settings.name.update'
|
||||
| 'user.settings.name.updated'
|
||||
| 'user.settings.logout.title'
|
||||
| 'user.settings.logout.description'
|
||||
| 'user.settings.logout.button'
|
||||
| 'organizations.list.title'
|
||||
| 'organizations.list.description'
|
||||
| 'organizations.list.create-new'
|
||||
| 'organizations.details.no-documents.title'
|
||||
| 'organizations.details.no-documents.description'
|
||||
| 'organizations.details.upload-documents'
|
||||
| 'organizations.details.documents-count'
|
||||
| 'organizations.details.total-size'
|
||||
| 'organizations.details.latest-documents'
|
||||
| 'organizations.create.title'
|
||||
| 'organizations.create.description'
|
||||
| 'organizations.create.back'
|
||||
| 'organizations.create.error.max-count-reached'
|
||||
| 'organizations.create.form.name.label'
|
||||
| 'organizations.create.form.name.placeholder'
|
||||
| 'organizations.create.form.name.required'
|
||||
| 'organizations.create.form.submit'
|
||||
| 'organizations.create.success'
|
||||
| 'organizations.create-first.title'
|
||||
| 'organizations.create-first.description'
|
||||
| 'organizations.create-first.default-name'
|
||||
| 'organizations.create-first.user-name'
|
||||
| 'organization.settings.title'
|
||||
| 'organization.settings.page.title'
|
||||
| 'organization.settings.page.description'
|
||||
| 'organization.settings.name.title'
|
||||
| 'organization.settings.name.update'
|
||||
| 'organization.settings.name.placeholder'
|
||||
| 'organization.settings.name.updated'
|
||||
| 'organization.settings.subscription.title'
|
||||
| 'organization.settings.subscription.description'
|
||||
| 'organization.settings.subscription.manage'
|
||||
| 'organization.settings.subscription.error'
|
||||
| 'organization.settings.delete.title'
|
||||
| 'organization.settings.delete.description'
|
||||
| 'organization.settings.delete.confirm.title'
|
||||
| 'organization.settings.delete.confirm.message'
|
||||
| 'organization.settings.delete.confirm.confirm-button'
|
||||
| 'organization.settings.delete.confirm.cancel-button'
|
||||
| 'organization.settings.delete.success'
|
||||
| 'organizations.members.title'
|
||||
| 'organizations.members.description'
|
||||
| 'organizations.members.invite-member'
|
||||
| 'organizations.members.invite-member-disabled-tooltip'
|
||||
| 'organizations.members.remove-from-organization'
|
||||
| 'organizations.members.role'
|
||||
| 'organizations.members.roles.owner'
|
||||
| 'organizations.members.roles.admin'
|
||||
| 'organizations.members.roles.member'
|
||||
| 'organizations.members.delete.confirm.title'
|
||||
| 'organizations.members.delete.confirm.message'
|
||||
| 'organizations.members.delete.confirm.confirm-button'
|
||||
| 'organizations.members.delete.confirm.cancel-button'
|
||||
| 'organizations.members.delete.success'
|
||||
| 'organizations.members.update-role.success'
|
||||
| 'organizations.members.table.headers.name'
|
||||
| 'organizations.members.table.headers.email'
|
||||
| 'organizations.members.table.headers.role'
|
||||
| 'organizations.members.table.headers.created'
|
||||
| 'organizations.members.table.headers.actions'
|
||||
| 'organizations.invite-member.title'
|
||||
| 'organizations.invite-member.description'
|
||||
| 'organizations.invite-member.form.email.label'
|
||||
| 'organizations.invite-member.form.email.placeholder'
|
||||
| 'organizations.invite-member.form.email.required'
|
||||
| 'organizations.invite-member.form.role.label'
|
||||
| 'organizations.invite-member.form.submit'
|
||||
| 'organizations.invite-member.success.message'
|
||||
| 'organizations.invite-member.success.description'
|
||||
| 'organizations.invite-member.error.message'
|
||||
| 'organizations.invitations.title'
|
||||
| 'organizations.invitations.description'
|
||||
| 'organizations.invitations.list.cta'
|
||||
| 'organizations.invitations.list.empty.title'
|
||||
| 'organizations.invitations.list.empty.description'
|
||||
| 'organizations.invitations.status.pending'
|
||||
| 'organizations.invitations.status.accepted'
|
||||
| 'organizations.invitations.status.rejected'
|
||||
| 'organizations.invitations.status.expired'
|
||||
| 'organizations.invitations.status.cancelled'
|
||||
| 'organizations.invitations.resend'
|
||||
| 'organizations.invitations.cancel.title'
|
||||
| 'organizations.invitations.cancel.description'
|
||||
| 'organizations.invitations.cancel.confirm'
|
||||
| 'organizations.invitations.cancel.cancel'
|
||||
| 'organizations.invitations.resend.title'
|
||||
| 'organizations.invitations.resend.description'
|
||||
| 'organizations.invitations.resend.confirm'
|
||||
| 'organizations.invitations.resend.cancel'
|
||||
| 'invitations.list.title'
|
||||
| 'invitations.list.description'
|
||||
| 'invitations.list.empty.title'
|
||||
| 'invitations.list.empty.description'
|
||||
| 'invitations.list.headers.organization'
|
||||
| 'invitations.list.headers.status'
|
||||
| 'invitations.list.headers.created'
|
||||
| 'invitations.list.headers.actions'
|
||||
| 'invitations.list.actions.accept'
|
||||
| 'invitations.list.actions.reject'
|
||||
| 'invitations.list.actions.accept.success.message'
|
||||
| 'invitations.list.actions.accept.success.description'
|
||||
| 'invitations.list.actions.reject.success.message'
|
||||
| 'invitations.list.actions.reject.success.description'
|
||||
| 'documents.list.title'
|
||||
| 'documents.list.no-documents.title'
|
||||
| 'documents.list.no-documents.description'
|
||||
| 'documents.list.no-results'
|
||||
| 'documents.tabs.info'
|
||||
| 'documents.tabs.content'
|
||||
| 'documents.tabs.activity'
|
||||
| 'documents.deleted.message'
|
||||
| 'documents.actions.download'
|
||||
| 'documents.actions.open-in-new-tab'
|
||||
| 'documents.actions.restore'
|
||||
| 'documents.actions.delete'
|
||||
| 'documents.actions.edit'
|
||||
| 'documents.actions.cancel'
|
||||
| 'documents.actions.save'
|
||||
| 'documents.actions.saving'
|
||||
| 'documents.content.alert'
|
||||
| 'documents.info.id'
|
||||
| 'documents.info.name'
|
||||
| 'documents.info.type'
|
||||
| 'documents.info.size'
|
||||
| 'documents.info.created-at'
|
||||
| 'documents.info.updated-at'
|
||||
| 'documents.info.never'
|
||||
| 'documents.rename.title'
|
||||
| 'documents.rename.form.name.label'
|
||||
| 'documents.rename.form.name.placeholder'
|
||||
| 'documents.rename.form.name.required'
|
||||
| 'documents.rename.form.name.max-length'
|
||||
| 'documents.rename.form.submit'
|
||||
| 'documents.rename.success'
|
||||
| 'documents.rename.cancel'
|
||||
| 'import-documents.title.error'
|
||||
| 'import-documents.title.success'
|
||||
| 'import-documents.title.pending'
|
||||
| 'import-documents.title.none'
|
||||
| 'import-documents.no-import-in-progress'
|
||||
| 'documents.deleted.title'
|
||||
| 'documents.deleted.empty.title'
|
||||
| 'documents.deleted.empty.description'
|
||||
| 'documents.deleted.retention-notice'
|
||||
| 'documents.deleted.deleted-at'
|
||||
| 'documents.deleted.restoring'
|
||||
| 'documents.deleted.deleting'
|
||||
| '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'
|
||||
| 'activity.document.created'
|
||||
| 'activity.document.updated.single'
|
||||
| 'activity.document.updated.multiple'
|
||||
| 'activity.document.updated'
|
||||
| 'activity.document.deleted'
|
||||
| 'activity.document.restored'
|
||||
| 'activity.document.tagged'
|
||||
| 'activity.document.untagged'
|
||||
| 'activity.document.user.name'
|
||||
| 'activity.load-more'
|
||||
| 'activity.no-more-activities'
|
||||
| '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'
|
||||
| 'tags.title'
|
||||
| 'tags.description'
|
||||
| 'tags.create'
|
||||
| 'tags.update'
|
||||
| 'tags.delete'
|
||||
| 'tags.delete.confirm.title'
|
||||
| 'tags.delete.confirm.message'
|
||||
| 'tags.delete.confirm.confirm-button'
|
||||
| 'tags.delete.confirm.cancel-button'
|
||||
| 'tags.delete.success'
|
||||
| 'tags.create.success'
|
||||
| 'tags.update.success'
|
||||
| 'tags.form.name.label'
|
||||
| 'tags.form.name.placeholder'
|
||||
| 'tags.form.name.required'
|
||||
| 'tags.form.name.max-length'
|
||||
| 'tags.form.color.label'
|
||||
| 'tags.form.color.placeholder'
|
||||
| 'tags.form.color.required'
|
||||
| 'tags.form.color.invalid'
|
||||
| 'tags.form.description.label'
|
||||
| 'tags.form.description.optional'
|
||||
| 'tags.form.description.placeholder'
|
||||
| 'tags.form.description.max-length'
|
||||
| 'tags.form.no-description'
|
||||
| 'tags.table.headers.tag'
|
||||
| 'tags.table.headers.description'
|
||||
| 'tags.table.headers.documents'
|
||||
| 'tags.table.headers.created'
|
||||
| 'tags.table.headers.actions'
|
||||
| 'tagging-rules.field.name'
|
||||
| 'tagging-rules.field.content'
|
||||
| 'tagging-rules.operator.equals'
|
||||
@@ -131,33 +333,38 @@ export type LocaleKeys =
|
||||
| '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'
|
||||
| 'intake-emails.title'
|
||||
| 'intake-emails.description'
|
||||
| 'intake-emails.disabled.title'
|
||||
| 'intake-emails.disabled.description'
|
||||
| 'intake-emails.disabled.documentation'
|
||||
| 'intake-emails.info'
|
||||
| 'intake-emails.empty.title'
|
||||
| 'intake-emails.empty.description'
|
||||
| 'intake-emails.empty.generate'
|
||||
| 'intake-emails.count'
|
||||
| 'intake-emails.new'
|
||||
| 'intake-emails.disabled-label'
|
||||
| 'intake-emails.no-origins'
|
||||
| 'intake-emails.allowed-origins'
|
||||
| 'intake-emails.actions.enable'
|
||||
| 'intake-emails.actions.disable'
|
||||
| 'intake-emails.actions.manage-origins'
|
||||
| 'intake-emails.actions.delete'
|
||||
| 'intake-emails.delete.confirm.title'
|
||||
| 'intake-emails.delete.confirm.message'
|
||||
| 'intake-emails.delete.confirm.confirm-button'
|
||||
| 'intake-emails.delete.confirm.cancel-button'
|
||||
| 'intake-emails.delete.success'
|
||||
| 'intake-emails.create.success'
|
||||
| 'intake-emails.update.success.enabled'
|
||||
| 'intake-emails.update.success.disabled'
|
||||
| 'intake-emails.allowed-origins.title'
|
||||
| 'intake-emails.allowed-origins.description'
|
||||
| 'intake-emails.allowed-origins.add.label'
|
||||
| 'intake-emails.allowed-origins.add.placeholder'
|
||||
| 'intake-emails.allowed-origins.add.button'
|
||||
| 'intake-emails.allowed-origins.add.error.exists'
|
||||
| 'api-keys.permissions.documents.title'
|
||||
| 'api-keys.permissions.documents.documents:create'
|
||||
| 'api-keys.permissions.documents.documents:read'
|
||||
@@ -231,4 +438,50 @@ export type LocaleKeys =
|
||||
| 'webhooks.delete.confirm.confirm-button'
|
||||
| 'webhooks.delete.confirm.cancel-button'
|
||||
| 'webhooks.events.documents.document:created.description'
|
||||
| 'webhooks.events.documents.document:deleted.description';
|
||||
| 'webhooks.events.documents.document:deleted.description'
|
||||
| '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'
|
||||
| 'layout.menu.members'
|
||||
| 'layout.menu.invitations'
|
||||
| 'layout.theme.light'
|
||||
| 'layout.theme.dark'
|
||||
| 'layout.theme.system'
|
||||
| 'layout.search.placeholder'
|
||||
| 'layout.menu.import-document'
|
||||
| 'user-menu.account-settings'
|
||||
| 'user-menu.api-keys'
|
||||
| 'user-menu.invitations'
|
||||
| 'user-menu.language'
|
||||
| 'user-menu.logout'
|
||||
| 'command-palette.search.placeholder'
|
||||
| 'command-palette.no-results'
|
||||
| 'command-palette.sections.documents'
|
||||
| 'command-palette.sections.theme'
|
||||
| '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-errors.organization.invitation_already_exists'
|
||||
| 'api-errors.user.already_in_organization'
|
||||
| 'api-errors.user.organization_invitation_limit_reached'
|
||||
| 'api-errors.demo.not_available'
|
||||
| 'api-errors.tags.already_exists'
|
||||
| 'not-found.title'
|
||||
| 'not-found.description'
|
||||
| 'not-found.back-to-home'
|
||||
| 'demo.popup.description'
|
||||
| 'demo.popup.discord'
|
||||
| 'demo.popup.discord-link-label'
|
||||
| 'demo.popup.reset'
|
||||
| 'demo.popup.hide';
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import type { DialogTriggerProps } from '@kobalte/core/dialog';
|
||||
import type { Component, JSX } from 'solid-js';
|
||||
import type { IntakeEmail } from '../intake-emails.types';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { useQuery } from '@tanstack/solid-query';
|
||||
import { createSignal, For, Show, Suspense } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { useConfig } from '@/modules/config/config.provider';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { useConfirmModal } from '@/modules/shared/confirm';
|
||||
import { createForm } from '@/modules/shared/form/form';
|
||||
import { isHttpErrorWithCode } from '@/modules/shared/http/http-errors';
|
||||
@@ -14,15 +20,11 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, Di
|
||||
import { EmptyState } from '@/modules/ui/components/empty';
|
||||
import { createToast } from '@/modules/ui/components/sonner';
|
||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { createQuery } from '@tanstack/solid-query';
|
||||
import { createSignal, For, Show, Suspense } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { createIntakeEmail, deleteIntakeEmail, fetchIntakeEmails, updateIntakeEmail } from '../intake-emails.services';
|
||||
|
||||
const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) => JSX.Element; intakeEmails: IntakeEmail }> = (props) => {
|
||||
const [getAllowedOrigins, setAllowedOrigins] = createSignal([...props.intakeEmails.allowedOrigins]);
|
||||
const { t } = useI18n();
|
||||
|
||||
const update = async () => {
|
||||
await updateIntakeEmail({
|
||||
@@ -47,7 +49,7 @@ const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) =>
|
||||
}),
|
||||
onSubmit: async ({ email }) => {
|
||||
if (getAllowedOrigins().includes(email)) {
|
||||
throw new Error('This email is already in the allowed origins for this intake email');
|
||||
throw new Error(t('intake-emails.allowed-origins.add.error.exists'));
|
||||
}
|
||||
|
||||
setAllowedOrigins(origins => [...origins, email]);
|
||||
@@ -67,13 +69,9 @@ const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) =>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Allowed origins</DialogTitle>
|
||||
<DialogTitle>{t('intake-emails.allowed-origins.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Only emails sent to
|
||||
{' '}
|
||||
<span class="font-medium text-primary">{props.intakeEmails.emailAddress}</span>
|
||||
{' '}
|
||||
from these origins will be processed. If no origins are specified, all emails will be discarded.
|
||||
{t('intake-emails.allowed-origins.description', { email: props.intakeEmails.emailAddress })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -81,13 +79,13 @@ const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) =>
|
||||
<Field name="email">
|
||||
{(field, inputProps) => (
|
||||
<TextFieldRoot class="flex flex-col gap-1 mb-4 mt-4">
|
||||
<TextFieldLabel for="email">Add allowed origin email</TextFieldLabel>
|
||||
<TextFieldLabel for="email">{t('intake-emails.allowed-origins.add.label')}</TextFieldLabel>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<TextField type="email" id="email" placeholder="Eg. ada@papra.app" {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
|
||||
<TextField type="email" id="email" placeholder={t('intake-emails.allowed-origins.add.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
|
||||
<Button type="submit">
|
||||
<div class="i-tabler-plus size-4 mr-2" />
|
||||
Add
|
||||
{t('intake-emails.allowed-origins.add.button')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -130,22 +128,39 @@ const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) =>
|
||||
|
||||
export const IntakeEmailsPage: Component = () => {
|
||||
const { config } = useConfig();
|
||||
const { t, te } = useI18n();
|
||||
|
||||
if (!config.intakeEmails.isEnabled) {
|
||||
return (
|
||||
<Card class="p-6">
|
||||
<h2 class="text-base font-bold">Intake Emails</h2>
|
||||
<div class="p-6 max-w-screen-md mx-auto mt-10">
|
||||
<h1 class="text-xl font-semibold">{t('intake-emails.title')}</h1>
|
||||
|
||||
<p class="text-muted-foreground mt-1">
|
||||
Intake emails are disabled on this instance. Please contact your administrator to enable them.
|
||||
{t('intake-emails.description')}
|
||||
</p>
|
||||
</Card>
|
||||
<Card class="px-6 py-4 mt-4 flex items-center gap-4">
|
||||
<div class="i-tabler-mail-off size-12 text-muted-foreground flex-shrink-0" />
|
||||
<div>
|
||||
<h2 class="text-base font-bold text-muted-foreground">{t('intake-emails.disabled.title')}</h2>
|
||||
<p class="text-muted-foreground mt-1">
|
||||
{te('intake-emails.disabled.description', {
|
||||
documentation: (
|
||||
<a href="https://docs.papra.app/guides/intake-emails-with-owlrelay/" target="_blank" class="text-primary">
|
||||
{t('intake-emails.disabled.documentation')}
|
||||
</a>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const params = useParams();
|
||||
const { confirm } = useConfirmModal();
|
||||
|
||||
const query = createQuery(() => ({
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'intake-emails'],
|
||||
queryFn: () => fetchIntakeEmails({ organizationId: params.organizationId }),
|
||||
}));
|
||||
@@ -155,7 +170,7 @@ export const IntakeEmailsPage: Component = () => {
|
||||
|
||||
if (isHttpErrorWithCode({ error, code: 'intake_email.limit_reached' })) {
|
||||
createToast({
|
||||
message: 'The maximum number of intake emails for this organization has been reached. Please upgrade your plan to create more intake emails.',
|
||||
message: t('api-errors.intake_email.limit_reached'),
|
||||
type: 'error',
|
||||
});
|
||||
|
||||
@@ -169,20 +184,20 @@ export const IntakeEmailsPage: Component = () => {
|
||||
await query.refetch();
|
||||
|
||||
createToast({
|
||||
message: 'Intake email created',
|
||||
message: t('intake-emails.create.success'),
|
||||
type: 'success',
|
||||
});
|
||||
};
|
||||
|
||||
const deleteEmail = async ({ intakeEmailId }: { intakeEmailId: string }) => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Delete intake email?',
|
||||
message: 'Are you sure you want to delete this intake email? This action cannot be undone.',
|
||||
title: t('intake-emails.delete.confirm.title'),
|
||||
message: t('intake-emails.delete.confirm.message'),
|
||||
cancelButton: {
|
||||
text: 'Cancel',
|
||||
text: t('intake-emails.delete.confirm.cancel-button'),
|
||||
},
|
||||
confirmButton: {
|
||||
text: 'Delete intake email',
|
||||
text: t('intake-emails.delete.confirm.confirm-button'),
|
||||
variant: 'destructive',
|
||||
},
|
||||
});
|
||||
@@ -195,7 +210,7 @@ export const IntakeEmailsPage: Component = () => {
|
||||
await query.refetch();
|
||||
|
||||
createToast({
|
||||
message: 'Intake email deleted',
|
||||
message: t('intake-emails.delete.success'),
|
||||
type: 'success',
|
||||
});
|
||||
};
|
||||
@@ -205,27 +220,25 @@ export const IntakeEmailsPage: Component = () => {
|
||||
await query.refetch();
|
||||
|
||||
createToast({
|
||||
message: `Intake email ${isEnabled ? 'enabled' : 'disabled'}`,
|
||||
message: isEnabled ? t('intake-emails.update.success.enabled') : t('intake-emails.update.success.disabled'),
|
||||
type: 'success',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="p-6 max-w-screen-md mx-auto mt-10">
|
||||
|
||||
<h1 class="text-xl font-semibold">Intake Emails</h1>
|
||||
<h1 class="text-xl font-semibold">{t('intake-emails.title')}</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.
|
||||
{t('intake-emails.description')}
|
||||
</p>
|
||||
|
||||
<Alert variant="default" class="mt-4 flex items-center gap-4 xl:gap-4 text-muted-foreground">
|
||||
<div class="i-tabler-info-circle size-10 xl:size-8 text-primary flex-shrink-0 " />
|
||||
|
||||
<AlertDescription>
|
||||
Only enabled intake emails from allowed origins will be processed. You can enable or disable an intake email at any time.
|
||||
{t('intake-emails.info')}
|
||||
</AlertDescription>
|
||||
|
||||
</Alert>
|
||||
|
||||
<Suspense>
|
||||
@@ -236,14 +249,14 @@ export const IntakeEmailsPage: Component = () => {
|
||||
fallback={(
|
||||
<div class="mt-4 py-8 border-2 border-dashed rounded-lg text-center">
|
||||
<EmptyState
|
||||
title="No intake emails"
|
||||
description="Generate an intake address to easily ingest emails attachments."
|
||||
title={t('intake-emails.empty.title')}
|
||||
description={t('intake-emails.empty.description')}
|
||||
class="pt-0"
|
||||
icon="i-tabler-mail"
|
||||
cta={(
|
||||
<Button variant="secondary" onClick={createEmail}>
|
||||
<div class="i-tabler-plus size-4 mr-2" />
|
||||
Generate intake email
|
||||
{t('intake-emails.empty.generate')}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
@@ -252,12 +265,15 @@ export const IntakeEmailsPage: Component = () => {
|
||||
>
|
||||
<div class="mt-4 mb-4 flex items-center justify-between">
|
||||
<div class="text-muted-foreground">
|
||||
{`${intakeEmails().length} intake email${intakeEmails().length > 1 ? 's' : ''} for this organization`}
|
||||
{t('intake-emails.count', {
|
||||
count: intakeEmails().length,
|
||||
plural: intakeEmails().length > 1 ? 's' : '',
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button onClick={createEmail}>
|
||||
<div class="i-tabler-plus size-4 mr-2" />
|
||||
New intake email
|
||||
{t('intake-emails.new')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -275,9 +291,8 @@ export const IntakeEmailsPage: Component = () => {
|
||||
{intakeEmail.emailAddress}
|
||||
|
||||
<Show when={!intakeEmail.isEnabled}>
|
||||
<span class="text-muted-foreground text-xs ml-2">(Disabled)</span>
|
||||
<span class="text-muted-foreground text-xs ml-2">{t('intake-emails.disabled-label')}</span>
|
||||
</Show>
|
||||
|
||||
</div>
|
||||
|
||||
<Show
|
||||
@@ -285,14 +300,16 @@ export const IntakeEmailsPage: Component = () => {
|
||||
fallback={(
|
||||
<div class="text-xs text-warning flex items-center gap-1.5">
|
||||
<div class="i-tabler-alert-triangle size-3.75" />
|
||||
No allowed email origins
|
||||
{t('intake-emails.no-origins')}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div class="text-xs text-muted-foreground flex items-center gap-2">
|
||||
{`Allowed from ${intakeEmail.allowedOrigins.length} address${intakeEmail.allowedOrigins.length > 1 ? 'es' : ''}`}
|
||||
{t('intake-emails.allowed-origins', {
|
||||
count: intakeEmail.allowedOrigins.length,
|
||||
plural: intakeEmail.allowedOrigins.length > 1 ? 'es' : '',
|
||||
})}
|
||||
</div>
|
||||
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
@@ -303,7 +320,7 @@ export const IntakeEmailsPage: Component = () => {
|
||||
onClick={() => updateEmail({ intakeEmailId: intakeEmail.id, isEnabled: !intakeEmail.isEnabled })}
|
||||
>
|
||||
<div class="i-tabler-power size-4 mr-2" />
|
||||
{intakeEmail.isEnabled ? 'Disable' : 'Enable'}
|
||||
{intakeEmail.isEnabled ? t('intake-emails.actions.disable') : t('intake-emails.actions.enable')}
|
||||
</Button>
|
||||
|
||||
<AllowedOriginsDialog intakeEmails={intakeEmail}>
|
||||
@@ -315,7 +332,7 @@ export const IntakeEmailsPage: Component = () => {
|
||||
class="flex items-center gap-2 leading-none"
|
||||
>
|
||||
<div class="i-tabler-edit size-4" />
|
||||
Manage origins addresses
|
||||
{t('intake-emails.actions.manage-origins')}
|
||||
</Button>
|
||||
)}
|
||||
</AllowedOriginsDialog>
|
||||
@@ -327,18 +344,14 @@ export const IntakeEmailsPage: Component = () => {
|
||||
class="text-red"
|
||||
>
|
||||
<div class="i-tabler-trash size-4 mr-2" />
|
||||
|
||||
Delete
|
||||
{t('intake-emails.actions.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
|
||||
</div>
|
||||
|
||||
</Show>
|
||||
|
||||
)}
|
||||
</Show>
|
||||
</Suspense>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useQuery } from '@tanstack/solid-query';
|
||||
import { fetchPendingInvitationsCount } from '../invitations.services';
|
||||
|
||||
export function usePendingInvitationsCount() {
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['invitations', 'count'],
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
queryFn: fetchPendingInvitationsCount,
|
||||
}));
|
||||
|
||||
return {
|
||||
...query,
|
||||
getPendingInvitationsCount: () => query.data?.pendingInvitationsCount ?? 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Organization } from '../organizations/organizations.types';
|
||||
import { apiClient } from '../shared/http/api-client';
|
||||
import { coerceDates } from '../shared/http/http-client.models';
|
||||
|
||||
export async function fetchInvitations() {
|
||||
const { invitations } = await apiClient<{ invitations: { id: string; organization: Organization }[] }>({
|
||||
path: '/api/invitations',
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
return {
|
||||
invitations: invitations.map(i => ({
|
||||
...coerceDates(i),
|
||||
organization: coerceDates(i.organization),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchPendingInvitationsCount() {
|
||||
const { pendingInvitationsCount } = await apiClient<{ pendingInvitationsCount: number }>({
|
||||
path: '/api/invitations/count',
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
return { pendingInvitationsCount };
|
||||
}
|
||||
|
||||
export async function acceptInvitation({ invitationId }: { invitationId: string }) {
|
||||
await apiClient({
|
||||
path: `/api/invitations/${invitationId}/accept`,
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export async function rejectInvitation({ invitationId }: { invitationId: string }) {
|
||||
await apiClient({
|
||||
path: `/api/invitations/${invitationId}/reject`,
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export async function resendInvitation({ invitationId }: { invitationId: string }) {
|
||||
await apiClient({
|
||||
path: `/api/invitations/${invitationId}/resend`,
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelInvitation({ invitationId }: { invitationId: string }) {
|
||||
await apiClient({
|
||||
path: `/api/invitations/${invitationId}/cancel`,
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { useMutation, useQuery } from '@tanstack/solid-query';
|
||||
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
|
||||
import { For, Show } from 'solid-js';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { timeAgo } from '@/modules/shared/date/time-ago';
|
||||
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/modules/ui/components/table';
|
||||
import { acceptInvitation, fetchInvitations, rejectInvitation } from '../invitations.services';
|
||||
|
||||
export const InvitationsPage: Component = () => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['invitations'],
|
||||
queryFn: fetchInvitations,
|
||||
}));
|
||||
|
||||
const acceptInvitationMutation = useMutation(() => ({
|
||||
mutationFn: acceptInvitation,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['invitations'] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['organizations'] });
|
||||
|
||||
createToast({
|
||||
message: t('invitations.list.actions.accept.success.message'),
|
||||
description: t('invitations.list.actions.accept.success.description'),
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const rejectInvitationMutation = useMutation(() => ({
|
||||
mutationFn: rejectInvitation,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['invitations'] });
|
||||
|
||||
createToast({
|
||||
message: t('invitations.list.actions.reject.success.message'),
|
||||
description: t('invitations.list.actions.reject.success.description'),
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const table = createSolidTable({
|
||||
get data() {
|
||||
return query.data?.invitations ?? [];
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
header: t('invitations.list.headers.organization'),
|
||||
accessorKey: 'organization.name',
|
||||
},
|
||||
{
|
||||
header: t('invitations.list.headers.created'),
|
||||
accessorKey: 'createdAt',
|
||||
cell: data => <time dateTime={data.getValue()}>{timeAgo({ date: data.getValue() })}</time>,
|
||||
},
|
||||
{
|
||||
header: () => <div class="text-right">{t('invitations.list.headers.actions')}</div>,
|
||||
id: 'actions',
|
||||
cell: data => (
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button size="sm" onClick={() => acceptInvitationMutation.mutate({ invitationId: data.row.original.id })}>
|
||||
{t('invitations.list.actions.accept')}
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => rejectInvitationMutation.mutate({ invitationId: data.row.original.id })}>
|
||||
{t('invitations.list.actions.reject')}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
});
|
||||
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-semibold mb-1">{t('invitations.list.title')}</h1>
|
||||
<p class="text-muted-foreground">{t('invitations.list.description')}</p>
|
||||
</div>
|
||||
|
||||
<Show when={query.data?.invitations.length} fallback={<EmptyState title={t('invitations.list.empty.title')} icon="i-tabler-mail" description={t('invitations.list.empty.description')} />}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<For each={table.getHeaderGroups()}>
|
||||
{headerGroup => (
|
||||
<TableRow>
|
||||
<For each={headerGroup.headers}>{header => <TableHead>{flexRender(header.column.columnDef.header, header.getContext())}</TableHead>}</For>
|
||||
</TableRow>
|
||||
)}
|
||||
</For>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<For each={table.getRowModel().rows}>
|
||||
{row => <TableRow>{row.getVisibleCells().map(cell => <TableCell>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>)}</TableRow>}
|
||||
</For>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,28 +1,33 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import * as v from 'valibot';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { createForm } from '@/modules/shared/form/form';
|
||||
import { isHttpErrorWithCode } from '@/modules/shared/http/http-errors';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import * as v from 'valibot';
|
||||
import { organizationNameSchema } from '../organizations.schemas';
|
||||
|
||||
export const CreateOrganizationForm: Component<{
|
||||
onSubmit: (args: { organizationName: string }) => Promise<void>;
|
||||
initialOrganizationName?: string;
|
||||
}> = (props) => {
|
||||
const { t } = useI18n();
|
||||
const { form, Form, Field } = createForm({
|
||||
onSubmit: async ({ organizationName }) => {
|
||||
const [, error] = await safely(props.onSubmit({ organizationName }));
|
||||
|
||||
if (isHttpErrorWithCode({ error, code: 'user.max_organization_count_reached' })) {
|
||||
throw new Error('You have reached the maximum number of organizations you can create, if you need to create more, please contact support.');
|
||||
throw new Error(t('organizations.create.error.max-count-reached'));
|
||||
}
|
||||
|
||||
throw error;
|
||||
},
|
||||
schema: v.object({
|
||||
organizationName: organizationNameSchema,
|
||||
organizationName: v.pipe(
|
||||
organizationNameSchema,
|
||||
v.nonEmpty(t('organizations.create.form.name.required')),
|
||||
),
|
||||
}),
|
||||
initialValues: {
|
||||
organizationName: props.initialOrganizationName,
|
||||
@@ -35,8 +40,8 @@ export const CreateOrganizationForm: Component<{
|
||||
<Field name="organizationName">
|
||||
{(field, inputProps) => (
|
||||
<TextFieldRoot class="flex flex-col gap-1 mb-6">
|
||||
<TextFieldLabel for="organizationName">Organization name</TextFieldLabel>
|
||||
<TextField type="text" id="organizationName" placeholder="Eg. Acme Inc." {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
|
||||
<TextFieldLabel for="organizationName">{t('organizations.create.form.name.label')}</TextFieldLabel>
|
||||
<TextField type="text" id="organizationName" placeholder={t('organizations.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>
|
||||
)}
|
||||
@@ -44,7 +49,7 @@ export const CreateOrganizationForm: Component<{
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button type="submit" isLoading={form.submitting} class="w-full">
|
||||
Create organization
|
||||
{t('organizations.create.form.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ParentComponent } from 'solid-js';
|
||||
import type { Organization } from '../organizations.types';
|
||||
import { makePersisted } from '@solid-primitives/storage';
|
||||
import { createQuery } from '@tanstack/solid-query';
|
||||
import { useQuery } from '@tanstack/solid-query';
|
||||
import { createContext, createSignal, Show, useContext } from 'solid-js';
|
||||
import { fetchOrganizations } from '../organizations.services';
|
||||
|
||||
@@ -24,7 +24,7 @@ export function useCurrentOrganization() {
|
||||
export const CurrentOrganizationProvider: ParentComponent = (props) => {
|
||||
const [getCurrentOrganizationId, setCurrentOrganizationId] = makePersisted(createSignal<string | null>(null), { name: 'papra_current_organization_id', storage: localStorage });
|
||||
|
||||
const query = createQuery(() => ({
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['organizations'],
|
||||
queryFn: fetchOrganizations,
|
||||
}));
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { useNavigate, useParams } from '@solidjs/router';
|
||||
import { useQuery } from '@tanstack/solid-query';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { queryClient } from '@/modules/shared/query/query-client';
|
||||
import { createToast } from '@/modules/ui/components/sonner';
|
||||
import { useNavigate } from '@solidjs/router';
|
||||
import { createOrganization, deleteOrganization, updateOrganization } from './organizations.services';
|
||||
import { ORGANIZATION_ROLES } from './organizations.constants';
|
||||
import { createOrganization, deleteOrganization, getMembership, updateOrganization } from './organizations.services';
|
||||
|
||||
export function useCreateOrganization() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
|
||||
return {
|
||||
createOrganization: async ({ organizationName }: { organizationName: string }) => {
|
||||
const { organization } = await createOrganization({ name: organizationName });
|
||||
|
||||
createToast({ type: 'success', message: 'Organization created' });
|
||||
createToast({ type: 'success', message: t('organizations.create.success') });
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['organizations'],
|
||||
@@ -50,3 +54,29 @@ export function useDeleteOrganization() {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useCurrentUserRole({ organizationId }: { organizationId?: string } = {}) {
|
||||
const params = useParams();
|
||||
|
||||
const getOrganizationId = () => organizationId ?? params.organizationId;
|
||||
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['organizations', getOrganizationId(), 'members', 'me'],
|
||||
queryFn: () => getMembership({ organizationId: getOrganizationId() }),
|
||||
}));
|
||||
|
||||
const getRole = () => query.data?.member.role;
|
||||
const getIsMember = () => getRole() === ORGANIZATION_ROLES.MEMBER;
|
||||
const getIsAdmin = () => getRole() === ORGANIZATION_ROLES.ADMIN;
|
||||
const getIsOwner = () => getRole() === ORGANIZATION_ROLES.OWNER;
|
||||
const getIsAtLeastAdmin = () => getIsAdmin() || getIsOwner();
|
||||
|
||||
return {
|
||||
query,
|
||||
getRole,
|
||||
getIsMember,
|
||||
getIsAdmin,
|
||||
getIsOwner,
|
||||
getIsAtLeastAdmin,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export const ORGANIZATION_ROLES = {
|
||||
OWNER: 'owner',
|
||||
ADMIN: 'admin',
|
||||
MEMBER: 'member',
|
||||
} as const;
|
||||
|
||||
export const ORGANIZATION_ROLES_LIST = Object.values(ORGANIZATION_ROLES);
|
||||
|
||||
export const ORGANIZATION_INVITATION_STATUS = {
|
||||
PENDING: 'pending',
|
||||
ACCEPTED: 'accepted',
|
||||
REJECTED: 'rejected',
|
||||
EXPIRED: 'expired',
|
||||
CANCELLED: 'cancelled',
|
||||
} as const;
|
||||
|
||||
export const ORGANIZATION_INVITATION_STATUS_LIST = Object.values(ORGANIZATION_INVITATION_STATUS);
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { OrganizationMemberRole } from './organizations.types';
|
||||
import { ORGANIZATION_ROLES } from './organizations.constants';
|
||||
|
||||
export function getIsMemberRoleDisabled({
|
||||
currentUserRole,
|
||||
memberRole,
|
||||
targetRole,
|
||||
}: {
|
||||
currentUserRole?: OrganizationMemberRole;
|
||||
memberRole: OrganizationMemberRole;
|
||||
targetRole: OrganizationMemberRole;
|
||||
}) {
|
||||
if (currentUserRole === ORGANIZATION_ROLES.MEMBER) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (memberRole === ORGANIZATION_ROLES.OWNER) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (targetRole === ORGANIZATION_ROLES.OWNER) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,8 +1,16 @@
|
||||
import type { AsDto } from '../shared/http/http-client.types';
|
||||
import type { Organization } from './organizations.types';
|
||||
import type { Organization, OrganizationInvitation, OrganizationMember, OrganizationMemberRole } from './organizations.types';
|
||||
import { apiClient } from '../shared/http/api-client';
|
||||
import { coerceDates } from '../shared/http/http-client.models';
|
||||
|
||||
export async function inviteOrganizationMember({ organizationId, email, role }: { organizationId: string; email: string; role: OrganizationMemberRole }) {
|
||||
await apiClient({
|
||||
path: `/api/organizations/${organizationId}/members/invitations`,
|
||||
method: 'POST',
|
||||
body: { email, role },
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchOrganizations() {
|
||||
const { organizations } = await apiClient<{ organizations: AsDto<Organization>[] }>({
|
||||
path: '/api/organizations',
|
||||
@@ -55,3 +63,55 @@ export async function deleteOrganization({ organizationId }: { organizationId: s
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchOrganizationMembers({ organizationId }: { organizationId: string }) {
|
||||
const { members } = await apiClient<{ members: AsDto<OrganizationMember>[] }>({
|
||||
path: `/api/organizations/${organizationId}/members`,
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
return {
|
||||
members: members.map(({ user, ...rest }) => coerceDates({ user: coerceDates(user), ...rest })),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchOrganizationInvitations({ organizationId }: { organizationId: string }) {
|
||||
const { invitations } = await apiClient<{ invitations: AsDto<OrganizationInvitation>[] }>({
|
||||
path: `/api/organizations/${organizationId}/members/invitations`,
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
return {
|
||||
invitations: invitations.map(coerceDates),
|
||||
};
|
||||
}
|
||||
|
||||
export async function removeOrganizationMember({ organizationId, memberId }: { organizationId: string; memberId: string }) {
|
||||
await apiClient({
|
||||
path: `/api/organizations/${organizationId}/members/${memberId}`,
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMembership({ organizationId }: { organizationId: string }) {
|
||||
const { member } = await apiClient<{ member: AsDto<OrganizationMember> }>({
|
||||
path: `/api/organizations/${organizationId}/members/me`,
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
return {
|
||||
member: coerceDates(member),
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateOrganizationMemberRole({ organizationId, memberId, role }: { organizationId: string; memberId: string; role: OrganizationMemberRole }) {
|
||||
const { member } = await apiClient<{ member: AsDto<OrganizationMember> }>({
|
||||
path: `/api/organizations/${organizationId}/members/${memberId}`,
|
||||
method: 'PATCH',
|
||||
body: { role },
|
||||
});
|
||||
|
||||
return {
|
||||
member: coerceDates(member),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
import type { User } from 'better-auth/types';
|
||||
import type { ORGANIZATION_INVITATION_STATUS_LIST } from './organizations.constants';
|
||||
|
||||
export type Organization = {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export type OrganizationMember = {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
user: User;
|
||||
role: OrganizationMemberRole;
|
||||
};
|
||||
|
||||
export type OrganizationMemberRole = 'owner' | 'admin' | 'member';
|
||||
|
||||
export type OrganizationInvitationStatus = typeof ORGANIZATION_INVITATION_STATUS_LIST[number];
|
||||
|
||||
export type OrganizationInvitation = {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
email: string;
|
||||
status: OrganizationInvitationStatus;
|
||||
role: OrganizationMemberRole;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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 { useQuery } from '@tanstack/solid-query';
|
||||
import { createEffect, on } from 'solid-js';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { useCurrentUser } from '@/modules/users/composables/useCurrentUser';
|
||||
import { CreateOrganizationForm } from '../components/create-organization-form.component';
|
||||
import { useCreateOrganization } from '../organizations.composables';
|
||||
import { fetchOrganizations } from '../organizations.services';
|
||||
@@ -11,24 +12,25 @@ export const CreateFirstOrganizationPage: Component = () => {
|
||||
const { createOrganization } = useCreateOrganization();
|
||||
const { user } = useCurrentUser();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
|
||||
const getOrganizationName = () => {
|
||||
const { name } = user;
|
||||
|
||||
if (name && name.length > 0) {
|
||||
return `${name}'s organization`;
|
||||
return t('organizations.create-first.user-name', { name });
|
||||
}
|
||||
|
||||
return `My organization`;
|
||||
return t('organizations.create-first.default-name');
|
||||
};
|
||||
|
||||
const queries = createQuery(() => ({
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['organizations'],
|
||||
queryFn: fetchOrganizations,
|
||||
}));
|
||||
|
||||
createEffect(on(
|
||||
() => queries.data?.organizations,
|
||||
() => query.data?.organizations,
|
||||
(orgs) => {
|
||||
if (orgs && orgs.length > 0) {
|
||||
navigate('/organizations/create');
|
||||
@@ -40,11 +42,11 @@ export const CreateFirstOrganizationPage: Component = () => {
|
||||
<div>
|
||||
<div class="max-w-md mx-auto pt-12 sm:pt-24 px-6">
|
||||
<h1 class="text-xl font-bold">
|
||||
Create your organization
|
||||
{t('organizations.create-first.title')}
|
||||
</h1>
|
||||
|
||||
<p class="text-muted-foreground mb-6">
|
||||
Your documents will be grouped by organization. You can create multiple organizations to separate your documents, for example, for personal and work documents.
|
||||
{t('organizations.create-first.description')}
|
||||
</p>
|
||||
|
||||
<CreateOrganizationForm onSubmit={createOrganization} initialOrganizationName={getOrganizationName()} />
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { A } from '@solidjs/router';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { CreateOrganizationForm } from '../components/create-organization-form.component';
|
||||
import { useCreateOrganization } from '../organizations.composables';
|
||||
|
||||
export const CreateOrganizationPage: Component = () => {
|
||||
const { t } = useI18n();
|
||||
const { createOrganization } = useCreateOrganization();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div class="max-w-md mx-auto pt-12 sm:pt-24 px-6">
|
||||
|
||||
<Button as={A} href="/" class="mb-4" variant="outline">
|
||||
<div class="i-tabler-arrow-left mr-2"></div>
|
||||
Back
|
||||
{t('organizations.create.back')}
|
||||
</Button>
|
||||
|
||||
<h1 class="text-xl font-bold">
|
||||
Create a new organization
|
||||
{t('organizations.create.title')}
|
||||
</h1>
|
||||
|
||||
<p class="text-muted-foreground mb-6">
|
||||
Your documents will be grouped by organization. You can create multiple organizations to separate your documents, for example, for personal and work documents.
|
||||
{t('organizations.create.description')}
|
||||
</p>
|
||||
|
||||
<CreateOrganizationForm onSubmit={createOrganization} />
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import type { OrganizationInvitation, OrganizationInvitationStatus, OrganizationMemberRole } from '../organizations.types';
|
||||
import { A, useNavigate, useParams } from '@solidjs/router';
|
||||
import { useMutation, useQuery } from '@tanstack/solid-query';
|
||||
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
|
||||
import { For, Match, onMount, Show, Switch } from 'solid-js';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { cancelInvitation, resendInvitation } from '@/modules/invitations/invitations.services';
|
||||
import { useConfirmModal } from '@/modules/shared/confirm';
|
||||
import { timeAgo } from '@/modules/shared/date/time-ago';
|
||||
import { queryClient } from '@/modules/shared/query/query-client';
|
||||
import { Badge } from '@/modules/ui/components/badge';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { EmptyState } from '@/modules/ui/components/empty';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/modules/ui/components/table';
|
||||
import { useCurrentUserRole } from '../organizations.composables';
|
||||
import { ORGANIZATION_INVITATION_STATUS } from '../organizations.constants';
|
||||
import { fetchOrganizationInvitations } from '../organizations.services';
|
||||
|
||||
const InvitationStatusBadge: Component<{ status: OrganizationInvitationStatus }> = (props) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const getStatus = () => t(`organizations.invitations.status.${props.status}`);
|
||||
const getVariant = () => ({
|
||||
[ORGANIZATION_INVITATION_STATUS.PENDING]: 'default',
|
||||
[ORGANIZATION_INVITATION_STATUS.ACCEPTED]: 'default',
|
||||
[ORGANIZATION_INVITATION_STATUS.REJECTED]: 'destructive',
|
||||
[ORGANIZATION_INVITATION_STATUS.EXPIRED]: 'destructive',
|
||||
[ORGANIZATION_INVITATION_STATUS.CANCELLED]: 'destructive',
|
||||
} as const)[props.status] ?? 'default';
|
||||
|
||||
return <Badge variant={getVariant()}>{getStatus()}</Badge>;
|
||||
};
|
||||
|
||||
const InvitationActions: Component<{ invitation: OrganizationInvitation }> = (props) => {
|
||||
const { t } = useI18n();
|
||||
const { confirm } = useConfirmModal();
|
||||
|
||||
const cancelMutation = useMutation(() => ({
|
||||
mutationFn: (invitationId: string) => cancelInvitation({ invitationId }),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['organizations', props.invitation.organizationId, 'invitations'] });
|
||||
},
|
||||
}));
|
||||
|
||||
const resendMutation = useMutation(() => ({
|
||||
mutationFn: (invitationId: string) => resendInvitation({ invitationId }),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['organizations', props.invitation.organizationId, 'invitations'] });
|
||||
},
|
||||
}));
|
||||
|
||||
const handleCancel = async () => {
|
||||
const isConfirmed = await confirm({
|
||||
title: t('organizations.invitations.cancel.title'),
|
||||
message: t('organizations.invitations.cancel.description'),
|
||||
confirmButton: {
|
||||
text: t('organizations.invitations.cancel.confirm'),
|
||||
variant: 'destructive',
|
||||
},
|
||||
cancelButton: {
|
||||
text: t('organizations.invitations.cancel.cancel'),
|
||||
},
|
||||
});
|
||||
|
||||
if (!isConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
cancelMutation.mutate(props.invitation.id);
|
||||
};
|
||||
|
||||
const handleResend = async () => {
|
||||
const isConfirmed = await confirm({
|
||||
title: t('organizations.invitations.resend.title'),
|
||||
message: t('organizations.invitations.resend.description'),
|
||||
confirmButton: {
|
||||
text: t('organizations.invitations.resend.confirm'),
|
||||
variant: 'default',
|
||||
},
|
||||
cancelButton: {
|
||||
text: t('organizations.invitations.resend.cancel'),
|
||||
},
|
||||
});
|
||||
|
||||
if (!isConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
resendMutation.mutate(props.invitation.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.invitation.status === ORGANIZATION_INVITATION_STATUS.PENDING}>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleCancel}
|
||||
disabled={cancelMutation.isPending}
|
||||
>
|
||||
<div class="i-tabler-x size-4 mr-2" />
|
||||
{t('organizations.invitations.cancel.confirm')}
|
||||
</Button>
|
||||
</Match>
|
||||
|
||||
<Match when={([
|
||||
ORGANIZATION_INVITATION_STATUS.REJECTED,
|
||||
ORGANIZATION_INVITATION_STATUS.EXPIRED,
|
||||
ORGANIZATION_INVITATION_STATUS.CANCELLED,
|
||||
] as OrganizationInvitationStatus[]).includes(props.invitation.status)}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleResend}
|
||||
disabled={resendMutation.isPending}
|
||||
>
|
||||
<div class="i-tabler-refresh size-4 mr-2" />
|
||||
{t('organizations.invitations.resend')}
|
||||
</Button>
|
||||
</Match>
|
||||
|
||||
</Switch>
|
||||
);
|
||||
};
|
||||
|
||||
const InvitationsList: Component = () => {
|
||||
const params = useParams();
|
||||
const { t } = useI18n();
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'invitations'],
|
||||
queryFn: () => fetchOrganizationInvitations({ organizationId: params.organizationId }),
|
||||
}));
|
||||
|
||||
const table = createSolidTable({
|
||||
get data() {
|
||||
return query.data?.invitations.filter(invitation => !([ORGANIZATION_INVITATION_STATUS.ACCEPTED] as OrganizationInvitationStatus[]).includes(invitation.status)) ?? [];
|
||||
},
|
||||
columns: [
|
||||
{ header: t('organizations.members.table.headers.email'), accessorKey: 'email' },
|
||||
{ header: t('organizations.members.table.headers.role'), accessorKey: 'role', cell: data => t(`organizations.members.roles.${data.getValue<OrganizationMemberRole>()}`) },
|
||||
{
|
||||
header: t('invitations.list.headers.status'),
|
||||
accessorKey: 'status',
|
||||
cell: data => <InvitationStatusBadge status={data.getValue()} />,
|
||||
},
|
||||
{
|
||||
header: t('organizations.members.table.headers.created'),
|
||||
accessorKey: 'createdAt',
|
||||
cell: data => <span title={data.getValue<Date>().toLocaleString()} class="text-muted-foreground">{timeAgo({ date: data.getValue<Date>() })}</span>,
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
id: 'actions',
|
||||
cell: data => (
|
||||
<div class="flex items-center justify-end">
|
||||
<InvitationActions invitation={data.row.original} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
<Show when={query.data?.invitations.length === 0}>
|
||||
<EmptyState
|
||||
title={t('organizations.invitations.list.empty.title')}
|
||||
description={t('organizations.invitations.list.empty.description')}
|
||||
icon="i-tabler-mail"
|
||||
cta={(
|
||||
<Button as={A} href={`/organizations/${params.organizationId}/invite`} variant="outline">
|
||||
<div class="i-tabler-plus size-4 mr-2" />
|
||||
{t('organizations.invitations.list.cta')}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={query.data?.invitations.length}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<For each={table.getHeaderGroups()}>
|
||||
{headerGroup => (
|
||||
<TableRow>
|
||||
<For each={headerGroup.headers}>{header => <TableHead>{flexRender(header.column.columnDef.header, header.getContext())}</TableHead>}</For>
|
||||
</TableRow>
|
||||
)}
|
||||
</For>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<For each={table.getRowModel().rows}>
|
||||
{row => <TableRow>{row.getVisibleCells().map(cell => <TableCell>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>)}</TableRow>}
|
||||
</For>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const InvitationsListPage: Component = () => {
|
||||
const { t } = useI18n();
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { getIsAtLeastAdmin } = useCurrentUserRole({ organizationId: params.organizationId });
|
||||
|
||||
onMount(() => {
|
||||
if (!getIsAtLeastAdmin()) {
|
||||
navigate(`/organizations/${params.organizationId}/members`);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="p-6 max-w-screen-md mx-auto mt-4 ">
|
||||
<div class="border-b mb-6 pb-4">
|
||||
|
||||
<div>
|
||||
<Button as={A} href={`/organizations/${params.organizationId}/members`} variant="ghost" class="ml--4 text-muted-foreground">
|
||||
<div class="i-tabler-arrow-left size-4 mr-2" />
|
||||
{t('organizations.members.title')}
|
||||
</Button>
|
||||
</div>
|
||||
<h1 class="text-xl font-bold">
|
||||
{t('organizations.invitations.title')}
|
||||
</h1>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{t('organizations.invitations.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<InvitationsList />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { setValue } from '@modular-forms/solid';
|
||||
import { useNavigate, useParams } from '@solidjs/router';
|
||||
import { useMutation } from '@tanstack/solid-query';
|
||||
import { onMount, Show } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { createForm } from '@/modules/shared/form/form';
|
||||
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
|
||||
import { queryClient } from '@/modules/shared/query/query-client';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/modules/ui/components/select';
|
||||
import { createToast } from '@/modules/ui/components/sonner';
|
||||
import {
|
||||
TextField,
|
||||
TextFieldLabel,
|
||||
TextFieldRoot,
|
||||
} from '@/modules/ui/components/textfield';
|
||||
import { useCurrentUserRole } from '../organizations.composables';
|
||||
import { ORGANIZATION_ROLES } from '../organizations.constants';
|
||||
import { inviteOrganizationMember } from '../organizations.services';
|
||||
|
||||
type InvitableRole = 'member' | 'admin';
|
||||
|
||||
export const InviteMemberPage: Component = () => {
|
||||
const { t } = useI18n();
|
||||
const params = useParams();
|
||||
const { getErrorMessage } = useI18nApiErrors({ t });
|
||||
const { getIsAtLeastAdmin } = useCurrentUserRole({ organizationId: params.organizationId });
|
||||
const navigate = useNavigate();
|
||||
|
||||
onMount(() => {
|
||||
if (!getIsAtLeastAdmin()) {
|
||||
navigate(`/organizations/${params.organizationId}`);
|
||||
}
|
||||
});
|
||||
|
||||
const tRole = (role: InvitableRole) => t(`organizations.members.roles.${role}`);
|
||||
|
||||
const inviteMemberMutation = useMutation(() => ({
|
||||
mutationFn: ({ email, role }: { email: string; role: InvitableRole }) =>
|
||||
inviteOrganizationMember({
|
||||
organizationId: params.organizationId,
|
||||
email,
|
||||
role,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['organizations', params.organizationId, 'invitations'],
|
||||
});
|
||||
createToast({
|
||||
message: t('organizations.invite-member.success.message'),
|
||||
description: t('organizations.invite-member.success.description'),
|
||||
type: 'success',
|
||||
});
|
||||
navigate(`/organizations/${params.organizationId}/members`);
|
||||
},
|
||||
onError: (error) => {
|
||||
createToast({
|
||||
message: t('organizations.invite-member.error.message'),
|
||||
description: getErrorMessage({ error }),
|
||||
type: 'error',
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const { Form, Field, form } = createForm({
|
||||
schema: v.object({
|
||||
email: v.pipe(
|
||||
v.string(),
|
||||
v.trim(),
|
||||
v.email(t('organizations.invite-member.form.email.required')),
|
||||
v.toLowerCase(),
|
||||
),
|
||||
role: v.picklist([ORGANIZATION_ROLES.MEMBER, ORGANIZATION_ROLES.ADMIN]),
|
||||
}),
|
||||
initialValues: {
|
||||
role: ORGANIZATION_ROLES.MEMBER,
|
||||
},
|
||||
onSubmit: async ({ email, role }) => {
|
||||
inviteMemberMutation.mutate({ email, role });
|
||||
},
|
||||
});
|
||||
|
||||
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('organizations.invite-member.title')}
|
||||
</h1>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{t('organizations.invite-member.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-10 max-w-xs mx-auto">
|
||||
<Form>
|
||||
<Field name="email">
|
||||
{(field, inputProps) => (
|
||||
<TextFieldRoot class="flex flex-col mb-6">
|
||||
<TextFieldLabel for="email">
|
||||
{t('organizations.invite-member.form.email.label')}
|
||||
</TextFieldLabel>
|
||||
<TextField
|
||||
type="email"
|
||||
id="email"
|
||||
placeholder={t(
|
||||
'organizations.invite-member.form.email.placeholder',
|
||||
)}
|
||||
{...inputProps}
|
||||
/>
|
||||
{field.error && (
|
||||
<div class="text-red-500 text-sm">{field.error}</div>
|
||||
)}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field name="role">
|
||||
{field => (
|
||||
<div>
|
||||
<label for="role" class="text-sm font-medium mb-1 block">
|
||||
{t('organizations.invite-member.form.role.label')}
|
||||
</label>
|
||||
<Select
|
||||
id="role"
|
||||
options={[
|
||||
ORGANIZATION_ROLES.MEMBER,
|
||||
ORGANIZATION_ROLES.ADMIN,
|
||||
]}
|
||||
itemComponent={props => (
|
||||
<SelectItem item={props.item}>
|
||||
{tRole(props.item.rawValue)}
|
||||
</SelectItem>
|
||||
)}
|
||||
value={field.value}
|
||||
onChange={value =>
|
||||
setValue(form, 'role', value as InvitableRole)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue<string>>
|
||||
{state =>
|
||||
tRole(state.selectedOption() as InvitableRole)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Button type="submit" class="w-full mt-6" isLoading={inviteMemberMutation.isPending}>
|
||||
{t('organizations.invite-member.form.submit')}
|
||||
<div class="i-tabler-send size-4 ml-1" />
|
||||
</Button>
|
||||
|
||||
<Show when={inviteMemberMutation.isError}>
|
||||
<div class="text-red-500 text-sm">
|
||||
{getErrorMessage({ error: inviteMemberMutation.error })}
|
||||
</div>
|
||||
</Show>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import type { OrganizationMemberRole } from '../organizations.types';
|
||||
import { A, useParams } from '@solidjs/router';
|
||||
import { useMutation, useQuery } from '@tanstack/solid-query';
|
||||
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
|
||||
import { For, Show } from 'solid-js';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { useConfirmModal } from '@/modules/shared/confirm';
|
||||
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
|
||||
import { queryClient } from '@/modules/shared/query/query-client';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuGroupLabel, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/modules/ui/components/dropdown-menu';
|
||||
import { createToast } from '@/modules/ui/components/sonner';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/modules/ui/components/table';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/modules/ui/components/tooltip';
|
||||
import { useCurrentUserRole } from '../organizations.composables';
|
||||
import { ORGANIZATION_ROLES } from '../organizations.constants';
|
||||
import { getIsMemberRoleDisabled } from '../organizations.models';
|
||||
import { fetchOrganizationMembers, removeOrganizationMember, updateOrganizationMemberRole } from '../organizations.services';
|
||||
|
||||
const MemberList: Component = () => {
|
||||
const params = useParams();
|
||||
const { t } = useI18n();
|
||||
const { confirm } = useConfirmModal();
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'members'],
|
||||
queryFn: () => fetchOrganizationMembers({ organizationId: params.organizationId }),
|
||||
}));
|
||||
const { getErrorMessage } = useI18nApiErrors({ t });
|
||||
|
||||
const { getIsAtLeastAdmin, getRole } = useCurrentUserRole({ organizationId: params.organizationId });
|
||||
|
||||
const removeMemberMutation = useMutation(() => ({
|
||||
mutationFn: ({ memberId }: { memberId: string }) => removeOrganizationMember({ organizationId: params.organizationId, memberId }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['organizations', params.organizationId, 'members'] });
|
||||
|
||||
createToast({
|
||||
message: t('organizations.members.delete.success'),
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const updateMemberRoleMutation = useMutation(() => ({
|
||||
mutationFn: ({ memberId, role }: { memberId: string; role: OrganizationMemberRole }) => updateOrganizationMemberRole({ organizationId: params.organizationId, memberId, role }),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['organizations', params.organizationId, 'members'] });
|
||||
|
||||
createToast({
|
||||
message: t('organizations.members.update-role.success'),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
createToast({
|
||||
message: getErrorMessage({ error }),
|
||||
type: 'error',
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const handleDelete = async ({ memberId }: { memberId: string }) => {
|
||||
const confirmed = await confirm({
|
||||
title: t('organizations.members.delete.confirm.title'),
|
||||
message: t('organizations.members.delete.confirm.message'),
|
||||
confirmButton: {
|
||||
text: t('organizations.members.delete.confirm.confirm-button'),
|
||||
variant: 'destructive',
|
||||
},
|
||||
cancelButton: {
|
||||
text: t('organizations.members.delete.confirm.cancel-button'),
|
||||
},
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
removeMemberMutation.mutate({ memberId });
|
||||
};
|
||||
|
||||
const handleUpdateMemberRole = async ({ memberId, role }: { memberId: string; role: OrganizationMemberRole }) => {
|
||||
await updateMemberRoleMutation.mutateAsync({ memberId, role });
|
||||
};
|
||||
|
||||
const table = createSolidTable({
|
||||
get data() {
|
||||
return query.data?.members ?? [];
|
||||
},
|
||||
columns: [
|
||||
{ header: t('organizations.members.table.headers.name'), accessorKey: 'user.name' },
|
||||
{ header: t('organizations.members.table.headers.email'), accessorKey: 'user.email' },
|
||||
{ header: t('organizations.members.table.headers.role'), accessorKey: 'role', cell: data => t(`organizations.members.roles.${data.getValue<OrganizationMemberRole>()}`) },
|
||||
{ header: t('organizations.members.table.headers.actions'), id: 'actions', cell: data => (
|
||||
<div class="flex items-center justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as={Button} variant="ghost" size="icon">
|
||||
<div class="i-tabler-dots-vertical size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDelete({ memberId: data.row.original.id })}
|
||||
disabled={data.row.original.role === ORGANIZATION_ROLES.OWNER || !getIsAtLeastAdmin()}
|
||||
>
|
||||
<div class="i-tabler-user-x size-4 mr-2" />
|
||||
{t('organizations.members.remove-from-organization')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuGroupLabel class="font-normal">{t('organizations.members.role')}</DropdownMenuGroupLabel>
|
||||
<DropdownMenuRadioGroup value={data.row.original.role} onChange={role => handleUpdateMemberRole({ memberId: data.row.original.id, role: role as OrganizationMemberRole })}>
|
||||
<DropdownMenuRadioItem
|
||||
value={ORGANIZATION_ROLES.OWNER}
|
||||
disabled={getIsMemberRoleDisabled({ currentUserRole: getRole(), memberRole: data.row.original.role, targetRole: ORGANIZATION_ROLES.OWNER })}
|
||||
>
|
||||
{t(`organizations.members.roles.owner`)}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem
|
||||
value={ORGANIZATION_ROLES.ADMIN}
|
||||
disabled={getIsMemberRoleDisabled({ currentUserRole: getRole(), memberRole: data.row.original.role, targetRole: ORGANIZATION_ROLES.ADMIN })}
|
||||
>
|
||||
{t(`organizations.members.roles.admin`)}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem
|
||||
value={ORGANIZATION_ROLES.MEMBER}
|
||||
disabled={getIsMemberRoleDisabled({ currentUserRole: getRole(), memberRole: data.row.original.role, targetRole: ORGANIZATION_ROLES.MEMBER })}
|
||||
>
|
||||
{t(`organizations.members.roles.member`)}
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
) },
|
||||
],
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<For each={table.getHeaderGroups()}>
|
||||
{headerGroup => (
|
||||
<TableRow>
|
||||
<For each={headerGroup.headers}>{header => <TableHead>{flexRender(header.column.columnDef.header, header.getContext())}</TableHead>}</For>
|
||||
</TableRow>
|
||||
)}
|
||||
</For>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<For each={table.getRowModel().rows}>
|
||||
{row => <TableRow>{row.getVisibleCells().map(cell => <TableCell>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>)}</TableRow>}
|
||||
</For>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const MembersPage: Component = () => {
|
||||
const { t } = useI18n();
|
||||
const params = useParams();
|
||||
const { getIsAtLeastAdmin } = useCurrentUserRole({ organizationId: params.organizationId });
|
||||
|
||||
return (
|
||||
<div class="p-6 max-w-screen-md mx-auto mt-4">
|
||||
<div class="border-b mb-6 pb-4 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold">
|
||||
{t('organizations.members.title')}
|
||||
</h1>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{t('organizations.members.description')}
|
||||
</p>
|
||||
</div>
|
||||
<Show
|
||||
when={getIsAtLeastAdmin()}
|
||||
fallback={(
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button disabled>
|
||||
<div class="i-tabler-plus size-4 mr-2" />
|
||||
{t('organizations.members.invite-member')}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('organizations.members.invite-member-disabled-tooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button as={A} href={`/organizations/${params.organizationId}/invitations`} variant="outline">
|
||||
<div class="i-tabler-mail size-4 mr-2" />
|
||||
{t('organizations.invitations.title')}
|
||||
</Button>
|
||||
|
||||
<Button as={A} href={`/organizations/${params.organizationId}/invite`}>
|
||||
<div class="i-tabler-plus size-4 mr-2" />
|
||||
{t('organizations.members.invite-member')}
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<MemberList />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +1,18 @@
|
||||
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';
|
||||
import { fetchOrganizationDocuments, getOrganizationDocumentsStats } from '@/modules/documents/documents.services';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { formatBytes } from '@corentinth/chisels';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { createQueries, keepPreviousData } from '@tanstack/solid-query';
|
||||
import { createSignal, Show, Suspense } 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';
|
||||
import { fetchOrganizationDocuments, getOrganizationDocumentsStats } from '@/modules/documents/documents.services';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
|
||||
export const OrganizationPage: Component = () => {
|
||||
const params = useParams();
|
||||
const { t } = useI18n();
|
||||
const [getPagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 100 });
|
||||
|
||||
const query = createQueries(() => ({
|
||||
@@ -39,11 +41,11 @@ export const OrganizationPage: Component = () => {
|
||||
? (
|
||||
<>
|
||||
<h2 class="text-xl font-bold ">
|
||||
No documents
|
||||
{t('organizations.details.no-documents.title')}
|
||||
</h2>
|
||||
|
||||
<p class="text-muted-foreground mt-1 mb-6">
|
||||
There are no documents in this organization yet. Start by uploading some documents.
|
||||
{t('organizations.details.no-documents.description')}
|
||||
</p>
|
||||
|
||||
<DocumentUploadArea />
|
||||
@@ -57,7 +59,7 @@ export const OrganizationPage: Component = () => {
|
||||
<Button onClick={promptImport} class="h-auto items-start flex-col gap-4 py-4 px-6">
|
||||
<div class="i-tabler-upload size-6"></div>
|
||||
|
||||
Upload documents
|
||||
{t('organizations.details.upload-documents')}
|
||||
</Button>
|
||||
|
||||
<Show when={query[1].data?.organizationStats}>
|
||||
@@ -69,7 +71,7 @@ export const OrganizationPage: Component = () => {
|
||||
{organizationStats().documentsCount}
|
||||
</span>
|
||||
<span class="text-muted-foreground">
|
||||
documents in total
|
||||
{t('organizations.details.documents-count')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,7 +82,7 @@ export const OrganizationPage: Component = () => {
|
||||
{formatBytes({ bytes: organizationStats().documentsSize, base: 1000 })}
|
||||
</span>
|
||||
<span class="text-muted-foreground">
|
||||
total size
|
||||
{t('organizations.details.total-size')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,7 +92,7 @@ export const OrganizationPage: Component = () => {
|
||||
</div>
|
||||
|
||||
<h2 class="text-lg font-semibold mb-4">
|
||||
Latest imported documents
|
||||
{t('organizations.details.latest-documents')}
|
||||
</h2>
|
||||
|
||||
<DocumentsPaginatedList
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import type { Organization } from '../organizations.types';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { useQuery } from '@tanstack/solid-query';
|
||||
import { createSignal, Show, Suspense } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { buildTimeConfig } from '@/modules/config/config';
|
||||
import { useConfig } from '@/modules/config/config.provider';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { useConfirmModal } from '@/modules/shared/confirm';
|
||||
import { createForm } from '@/modules/shared/form/form';
|
||||
import { getCustomerPortalUrl } from '@/modules/subscriptions/subscriptions.services';
|
||||
@@ -8,11 +15,6 @@ import { Button } from '@/modules/ui/components/button';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/modules/ui/components/card';
|
||||
import { createToast } from '@/modules/ui/components/sonner';
|
||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { createQuery } from '@tanstack/solid-query';
|
||||
import { createSignal, Show, Suspense } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { useDeleteOrganization, useUpdateOrganization } from '../organizations.composables';
|
||||
import { organizationNameSchema } from '../organizations.schemas';
|
||||
import { fetchOrganization } from '../organizations.services';
|
||||
@@ -20,24 +22,25 @@ import { fetchOrganization } from '../organizations.services';
|
||||
const DeleteOrganizationCard: Component<{ organization: Organization }> = (props) => {
|
||||
const { deleteOrganization } = useDeleteOrganization();
|
||||
const { confirm } = useConfirmModal();
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleDelete = async () => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Delete organization',
|
||||
message: 'Are you sure you want to delete this organization? This action cannot be undone, and all data associated with this organization will be permanently removed.',
|
||||
title: t('organization.settings.delete.confirm.title'),
|
||||
message: t('organization.settings.delete.confirm.message'),
|
||||
confirmButton: {
|
||||
text: 'Delete organization',
|
||||
text: t('organization.settings.delete.confirm.confirm-button'),
|
||||
variant: 'destructive',
|
||||
},
|
||||
cancelButton: {
|
||||
text: 'Cancel',
|
||||
text: t('organization.settings.delete.confirm.cancel-button'),
|
||||
},
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
await deleteOrganization({ organizationId: props.organization.id });
|
||||
|
||||
createToast({ type: 'success', message: 'Organization deleted' });
|
||||
createToast({ type: 'success', message: t('organization.settings.delete.success') });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -45,15 +48,15 @@ const DeleteOrganizationCard: Component<{ organization: Organization }> = (props
|
||||
<div>
|
||||
<Card class="border-destructive">
|
||||
<CardHeader class="border-b">
|
||||
<CardTitle>Delete organization</CardTitle>
|
||||
<CardTitle>{t('organization.settings.delete.title')}</CardTitle>
|
||||
<CardDescription>
|
||||
Deleting this organization will permanently remove all data associated with it.
|
||||
{t('organization.settings.delete.description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardFooter class="pt-6">
|
||||
<Button onClick={handleDelete} variant="destructive">
|
||||
Delete organization
|
||||
{t('organization.settings.delete.confirm.confirm-button')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
@@ -62,7 +65,14 @@ const DeleteOrganizationCard: Component<{ organization: Organization }> = (props
|
||||
};
|
||||
|
||||
export const SubscriptionCard: Component<{ organization: Organization }> = (props) => {
|
||||
const { config } = useConfig();
|
||||
|
||||
if (!config.isSubscriptionsEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [getIsLoading, setIsLoading] = createSignal(false);
|
||||
const { t } = useI18n();
|
||||
|
||||
const goToCustomerPortal = async () => {
|
||||
setIsLoading(true);
|
||||
@@ -70,7 +80,7 @@ export const SubscriptionCard: Component<{ organization: Organization }> = (prop
|
||||
const [result, error] = await safely(getCustomerPortalUrl({ organizationId: props.organization.id }));
|
||||
|
||||
if (error) {
|
||||
createToast({ type: 'error', message: 'Failed to get customer portal URL' });
|
||||
createToast({ type: 'error', message: t('organization.settings.subscription.error') });
|
||||
setIsLoading(false);
|
||||
|
||||
return;
|
||||
@@ -86,13 +96,13 @@ export const SubscriptionCard: Component<{ organization: Organization }> = (prop
|
||||
return (
|
||||
<Card class="flex flex-col sm:flex-row justify-between gap-4 sm:items-center p-6 ">
|
||||
<div>
|
||||
<div class="font-semibold">Subscription</div>
|
||||
<div class="font-semibold">{t('organization.settings.subscription.title')}</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
Manage your billing, invoices and payment methods.
|
||||
{t('organization.settings.subscription.description')}
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={goToCustomerPortal} isLoading={getIsLoading()} class="flex-shrink-0" disabled={buildTimeConfig.isDemoMode}>
|
||||
Manage subscription
|
||||
{t('organization.settings.subscription.manage')}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
@@ -100,6 +110,7 @@ export const SubscriptionCard: Component<{ organization: Organization }> = (prop
|
||||
|
||||
const UpdateOrganizationNameCard: Component<{ organization: Organization }> = (props) => {
|
||||
const { updateOrganization } = useUpdateOrganization();
|
||||
const { t } = useI18n();
|
||||
|
||||
const { form, Form, Field } = createForm({
|
||||
schema: v.object({
|
||||
@@ -114,7 +125,7 @@ const UpdateOrganizationNameCard: Component<{ organization: Organization }> = (p
|
||||
organizationName: organizationName.trim(),
|
||||
});
|
||||
|
||||
createToast({ type: 'success', message: 'Organization name updated' });
|
||||
createToast({ type: 'success', message: t('organization.settings.name.updated') });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -122,24 +133,22 @@ const UpdateOrganizationNameCard: Component<{ organization: Organization }> = (p
|
||||
<div>
|
||||
<Card>
|
||||
<CardHeader class="border-b">
|
||||
<CardTitle>Organization name</CardTitle>
|
||||
|
||||
<CardTitle>{t('organization.settings.name.title')}</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<Form>
|
||||
<CardContent class="pt-6 ">
|
||||
|
||||
<Field name="organizationName">
|
||||
{(field, inputProps) => (
|
||||
<TextFieldRoot class="flex flex-col gap-1">
|
||||
<TextFieldLabel for="organizationName" class="sr-only">
|
||||
Organization name
|
||||
{t('organization.settings.name.title')}
|
||||
</TextFieldLabel>
|
||||
<div class="flex gap-2 flex-col sm:flex-row">
|
||||
<TextField type="text" id="organizationName" placeholder="Eg. Acme Inc." {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
|
||||
<TextField type="text" id="organizationName" placeholder={t('organization.settings.name.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
|
||||
|
||||
<Button type="submit" isLoading={form.submitting} class="flex-shrink-0" disabled={field.value?.trim() === props.organization.name}>
|
||||
Update name
|
||||
{t('organization.settings.name.update')}
|
||||
</Button>
|
||||
</div>
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
@@ -149,7 +158,6 @@ const UpdateOrganizationNameCard: Component<{ organization: Organization }> = (p
|
||||
|
||||
<div class="text-red-500 text-sm">{form.response.message}</div>
|
||||
</CardContent>
|
||||
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -158,8 +166,9 @@ const UpdateOrganizationNameCard: Component<{ organization: Organization }> = (p
|
||||
|
||||
export const OrganizationsSettingsPage: Component = () => {
|
||||
const params = useParams();
|
||||
const { t } = useI18n();
|
||||
|
||||
const query = createQuery(() => ({
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId],
|
||||
queryFn: () => fetchOrganization({ organizationId: params.organizationId }),
|
||||
}));
|
||||
@@ -171,11 +180,11 @@ export const OrganizationsSettingsPage: Component = () => {
|
||||
{ getOrganization => (
|
||||
<>
|
||||
<h1 class="text-xl font-semibold mb-2">
|
||||
Organization settings
|
||||
{t('organization.settings.page.title')}
|
||||
</h1>
|
||||
|
||||
<p class="text-muted-foreground">
|
||||
Manage your organization settings here.
|
||||
{t('organization.settings.page.description')}
|
||||
</p>
|
||||
|
||||
<div class="mt-6 flex flex-col gap-6">
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { A, useNavigate } from '@solidjs/router';
|
||||
import { createQuery } from '@tanstack/solid-query';
|
||||
import { useQuery } from '@tanstack/solid-query';
|
||||
import { createEffect, For, on } from 'solid-js';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { fetchOrganizations } from '../organizations.services';
|
||||
|
||||
export const OrganizationsPage: Component = () => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
|
||||
const queries = createQuery(() => ({
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['organizations'],
|
||||
queryFn: fetchOrganizations,
|
||||
}));
|
||||
|
||||
createEffect(on(
|
||||
() => queries.data?.organizations,
|
||||
() => query.data?.organizations,
|
||||
(orgs) => {
|
||||
if (orgs && orgs.length === 0) {
|
||||
navigate('/organizations/first');
|
||||
@@ -24,15 +26,15 @@ export const OrganizationsPage: Component = () => {
|
||||
return (
|
||||
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">
|
||||
<h2 class="text-xl font-bold mb-2">
|
||||
Your organizations
|
||||
{t('organizations.list.title')}
|
||||
</h2>
|
||||
|
||||
<p class="text-muted-foreground mb-6">
|
||||
Organizations are a way to group your documents and manage access to them. You can create multiple organizations and invite your team members to collaborate.
|
||||
{t('organizations.list.description')}
|
||||
</p>
|
||||
|
||||
<div class="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<For each={queries.data?.organizations}>
|
||||
<For each={query.data?.organizations}>
|
||||
{organization => (
|
||||
<A
|
||||
href={`/organizations/${organization.id}`}
|
||||
@@ -43,7 +45,6 @@ export const OrganizationsPage: Component = () => {
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
|
||||
<div class="w-full text-left font-bold truncate block">
|
||||
{organization.name}
|
||||
</div>
|
||||
@@ -56,7 +57,7 @@ export const OrganizationsPage: Component = () => {
|
||||
<div class="i-tabler-plus size-16 text-muted-foreground op-50 group-hover:(text-primary op-100) transition" />
|
||||
|
||||
<div class="font-bold block text-muted-foreground">
|
||||
Create new organization
|
||||
{t('organizations.list.create-new')}
|
||||
</div>
|
||||
</A>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { HttpClientOptions, ResponseType } from './http-client';
|
||||
import { buildTimeConfig } from '@/modules/config/config';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { buildTimeConfig } from '@/modules/config/config';
|
||||
import { httpClient } from './http-client';
|
||||
import { isHttpErrorWithStatusCode } from './http-errors';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { LocaleKeys } from '@/modules/i18n/locales.types';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { get } from 'lodash-es';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
|
||||
export function useI18nApiErrors({ t = useI18n().t }: { t?: ReturnType<typeof useI18n>['t'] } = {}) {
|
||||
const getTranslationFromApiErrorCode = ({ code }: { code: string }) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { FetchOptions, ResponseType } from 'ofetch';
|
||||
import { ofetch } from 'ofetch';
|
||||
import { buildTimeConfig } from '@/modules/config/config';
|
||||
import { demoHttpClient } from '@/modules/demo/demo-http-client';
|
||||
import { ofetch } from 'ofetch';
|
||||
|
||||
export { ResponseType };
|
||||
export type HttpClientOptions<R extends ResponseType = 'json'> = Omit<FetchOptions<R>, 'baseURL'> & { url: string; baseUrl?: string };
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { A } from '@solidjs/router';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
|
||||
export const NotFoundPage: Component = () => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div class="h-screen flex flex-col items-center justify-center p-6">
|
||||
|
||||
<div class="flex items-center flex-row sm:gap-24">
|
||||
<div class="max-w-350px">
|
||||
<h1 class="text-xl mr-4 py-2">404 - Not Found</h1>
|
||||
<h1 class="text-xl mr-4 py-2">{t('not-found.title')}</h1>
|
||||
<p class="text-muted-foreground">
|
||||
Sorry, the page you are looking for does seem to exist. Please check the URL and try again.
|
||||
{t('not-found.description')}
|
||||
</p>
|
||||
<Button as={A} href="/" class="mt-4" variant="default">
|
||||
<div class="i-tabler-arrow-left mr-2"></div>
|
||||
Go back to home
|
||||
{t('not-found.back-to-home')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ComponentProps, ParentComponent } from 'solid-js';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
|
||||
export function useCopy() {
|
||||
const [getIsJustCopied, setIsJustCopied] = createSignal(false);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { buildTimeConfig } from '@/modules/config/config';
|
||||
import { buildUrl } from '@corentinth/chisels';
|
||||
import { buildTimeConfig } from '@/modules/config/config';
|
||||
|
||||
export function createVitrineUrl({ path, baseUrl = buildTimeConfig.vitrineBaseUrl }: { path: string; baseUrl?: string }): string {
|
||||
return buildUrl({ path, baseUrl });
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import type { TaggingRule, TaggingRuleForCreation } from '../tagging-rules.types';
|
||||
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 { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { useConfirmModal } from '@/modules/shared/confirm';
|
||||
import { createForm } from '@/modules/shared/form/form';
|
||||
@@ -10,10 +14,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
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<{
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import type { TaggingRuleForCreation } from '../tagging-rules.types';
|
||||
import { useNavigate, useParams } from '@solidjs/router';
|
||||
import { useMutation } from '@tanstack/solid-query';
|
||||
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';
|
||||
|
||||
@@ -12,7 +12,7 @@ export const CreateTaggingRulePage: Component = () => {
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const createTaggingRuleMutation = createMutation(() => ({
|
||||
const createTaggingRuleMutation = useMutation(() => ({
|
||||
mutationFn: async ({ taggingRule }: { taggingRule: TaggingRuleForCreation }) => {
|
||||
await createTaggingRule({ taggingRule, organizationId: params.organizationId });
|
||||
},
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import type { TaggingRule } from '../tagging-rules.types';
|
||||
import { A, useParams } from '@solidjs/router';
|
||||
import { useMutation, useQuery } from '@tanstack/solid-query';
|
||||
import { For, Match, Show, Switch } from 'solid-js';
|
||||
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) => {
|
||||
@@ -28,7 +28,7 @@ const TaggingRuleCard: Component<{ taggingRule: TaggingRule }> = (props) => {
|
||||
return t('tagging-rules.list.card.conditions', { count });
|
||||
};
|
||||
|
||||
const deleteTaggingRuleMutation = createMutation(() => ({
|
||||
const deleteTaggingRuleMutation = useMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
await deleteTaggingRule({ organizationId: props.taggingRule.organizationId, taggingRuleId: props.taggingRule.id });
|
||||
},
|
||||
@@ -82,7 +82,7 @@ export const TaggingRulesPage: Component = () => {
|
||||
const { config } = useConfig();
|
||||
const params = useParams();
|
||||
|
||||
const query = createQuery(() => ({
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'tagging-rules'],
|
||||
queryFn: () => fetchTaggingRules({ organizationId: params.organizationId }),
|
||||
}));
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import type { TaggingRuleForCreation } from '../tagging-rules.types';
|
||||
import { useNavigate, useParams } from '@solidjs/router';
|
||||
import { useMutation, useQuery } from '@tanstack/solid-query';
|
||||
import { Show } from 'solid-js';
|
||||
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';
|
||||
|
||||
@@ -14,12 +14,12 @@ export const UpdateTaggingRulePage: Component = () => {
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const query = createQuery(() => ({
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'tagging-rules', params.taggingRuleId],
|
||||
queryFn: () => getTaggingRule({ organizationId: params.organizationId, taggingRuleId: params.taggingRuleId }),
|
||||
}));
|
||||
|
||||
const updateTaggingRuleMutation = createMutation(() => ({
|
||||
const updateTaggingRuleMutation = useMutation(() => ({
|
||||
mutationFn: async ({ taggingRule }: { taggingRule: TaggingRuleForCreation }) => {
|
||||
await updateTaggingRule({ organizationId: params.organizationId, taggingRuleId: params.taggingRuleId, taggingRule });
|
||||
},
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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 { useQuery } from '@tanstack/solid-query';
|
||||
import { createSignal, For } from 'solid-js';
|
||||
import { Combobox, ComboboxContent, ComboboxInput, ComboboxItem, ComboboxTrigger } from '@/modules/ui/components/combobox';
|
||||
import { fetchTags } from '../tags.services';
|
||||
import { Tag as TagComponent } from './tag.component';
|
||||
|
||||
@@ -15,7 +15,7 @@ export const DocumentTagPicker: Component<{
|
||||
}> = (props) => {
|
||||
const [getSelectedTagIds, setSelectedTagIds] = createSignal<string[]>(props.tagIds);
|
||||
|
||||
const query = createQuery(() => ({
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['organizations', props.organizationId, 'tags'],
|
||||
queryFn: () => fetchTags({ organizationId: props.organizationId }),
|
||||
}));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Component, ComponentProps } from 'solid-js';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { A } from '@solidjs/router';
|
||||
import { splitProps } from 'solid-js';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
|
||||
type TagProps = {
|
||||
name?: string;
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import type { DialogTriggerProps } from '@kobalte/core/dialog';
|
||||
import type { Component, JSX } from 'solid-js';
|
||||
import type { Tag as TagType } from '../tags.types';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { getValues } from '@modular-forms/solid';
|
||||
import { A, useParams } from '@solidjs/router';
|
||||
import { useQuery } from '@tanstack/solid-query';
|
||||
import { createSignal, For, Show, Suspense } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { useConfirmModal } from '@/modules/shared/confirm';
|
||||
import { timeAgo } from '@/modules/shared/date/time-ago';
|
||||
import { createForm } from '@/modules/shared/form/form';
|
||||
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
|
||||
import { queryClient } from '@/modules/shared/query/query-client';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/modules/ui/components/dialog';
|
||||
@@ -13,11 +20,6 @@ import { createToast } from '@/modules/ui/components/sonner';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/modules/ui/components/table';
|
||||
import { TextArea } from '@/modules/ui/components/textarea';
|
||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { getValues } from '@modular-forms/solid';
|
||||
import { A, useParams } from '@solidjs/router';
|
||||
import { createQuery } from '@tanstack/solid-query';
|
||||
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';
|
||||
|
||||
@@ -26,25 +28,26 @@ const TagForm: Component<{
|
||||
initialValues?: { name?: string; color?: string; description?: string | null };
|
||||
submitLabel?: string;
|
||||
}> = (props) => {
|
||||
const { t } = useI18n();
|
||||
const { form, Form, Field } = createForm({
|
||||
onSubmit: props.onSubmit,
|
||||
schema: v.object({
|
||||
name: v.pipe(
|
||||
v.string(),
|
||||
v.trim(),
|
||||
v.nonEmpty('Please enter a tag name'),
|
||||
v.maxLength(64, 'Tag name must be less than 64 characters'),
|
||||
v.nonEmpty(t('tags.form.name.required')),
|
||||
v.maxLength(64, t('tags.form.name.max-length')),
|
||||
),
|
||||
color: v.pipe(
|
||||
v.string(),
|
||||
v.trim(),
|
||||
v.nonEmpty('Please enter a color'),
|
||||
v.hexColor('The hex color is badly formatted.'),
|
||||
v.nonEmpty(t('tags.form.color.required')),
|
||||
v.hexColor(t('tags.form.color.invalid')),
|
||||
),
|
||||
description: v.pipe(
|
||||
v.string(),
|
||||
v.trim(),
|
||||
v.maxLength(256, 'Description must be less than 256 characters'),
|
||||
v.maxLength(256, t('tags.form.description.max-length')),
|
||||
),
|
||||
}),
|
||||
initialValues: {
|
||||
@@ -60,8 +63,8 @@ const TagForm: Component<{
|
||||
<Field name="name">
|
||||
{(field, inputProps) => (
|
||||
<TextFieldRoot class="flex flex-col gap-1 mb-4">
|
||||
<TextFieldLabel for="name">Name</TextFieldLabel>
|
||||
<TextField type="text" id="name" {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} placeholder="Eg. Contracts" />
|
||||
<TextFieldLabel for="name">{t('tags.form.name.label')}</TextFieldLabel>
|
||||
<TextField type="text" id="name" {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} placeholder={t('tags.form.name.placeholder')} />
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
@@ -70,8 +73,8 @@ const TagForm: Component<{
|
||||
<Field name="color">
|
||||
{(field, inputProps) => (
|
||||
<TextFieldRoot class="flex flex-col gap-1 mb-4">
|
||||
<TextFieldLabel for="color">Color</TextFieldLabel>
|
||||
<TextField id="color" {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} placeholder="Eg. #FF0000" />
|
||||
<TextFieldLabel for="color">{t('tags.form.color.label')}</TextFieldLabel>
|
||||
<TextField id="color" {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} placeholder={t('tags.form.color.placeholder')} />
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
@@ -81,10 +84,10 @@ const TagForm: Component<{
|
||||
{(field, inputProps) => (
|
||||
<TextFieldRoot class="flex flex-col gap-1 mb-4">
|
||||
<TextFieldLabel for="description">
|
||||
Description
|
||||
<span class="font-normal ml-1 text-muted-foreground">(optional)</span>
|
||||
{t('tags.form.description.label')}
|
||||
<span class="font-normal ml-1 text-muted-foreground">{t('tags.form.description.optional')}</span>
|
||||
</TextFieldLabel>
|
||||
<TextArea id="description" {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} placeholder="Eg. All the contracts signed by the company" />
|
||||
<TextArea id="description" {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} placeholder={t('tags.form.description.placeholder')} />
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
@@ -92,7 +95,7 @@ const TagForm: Component<{
|
||||
|
||||
<div class="flex flex-row-reverse justify-between items-center mt-6">
|
||||
<Button type="submit">
|
||||
{props.submitLabel ?? 'Create tag'}
|
||||
{props.submitLabel ?? t('tags.create')}
|
||||
</Button>
|
||||
|
||||
{getFormValues().name && (
|
||||
@@ -110,14 +113,24 @@ export const CreateTagModal: Component<{
|
||||
organizationId: string;
|
||||
}> = (props) => {
|
||||
const [getIsModalOpen, setIsModalOpen] = createSignal(false);
|
||||
const { t } = useI18n();
|
||||
const { getErrorMessage } = useI18nApiErrors({ t });
|
||||
|
||||
const onSubmit = async ({ name, color, description }: { name: string; color: string; description: string }) => {
|
||||
await createTag({
|
||||
const [,error] = await safely(createTag({
|
||||
name,
|
||||
color,
|
||||
description,
|
||||
organizationId: props.organizationId,
|
||||
});
|
||||
}));
|
||||
|
||||
if (error) {
|
||||
createToast({
|
||||
message: getErrorMessage({ error }),
|
||||
type: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['organizations', props.organizationId],
|
||||
@@ -125,7 +138,7 @@ export const CreateTagModal: Component<{
|
||||
});
|
||||
|
||||
createToast({
|
||||
message: `Tag "${name}" created successfully.`,
|
||||
message: t('tags.create.success', { name }),
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
@@ -137,7 +150,7 @@ export const CreateTagModal: Component<{
|
||||
<DialogTrigger as={props.children} />
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a new tag</DialogTitle>
|
||||
<DialogTitle>{t('tags.create')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<TagForm onSubmit={onSubmit} initialValues={{ color: '#d8ff75' }} />
|
||||
@@ -152,6 +165,7 @@ const UpdateTagModal: Component<{
|
||||
tag: TagType;
|
||||
}> = (props) => {
|
||||
const [getIsModalOpen, setIsModalOpen] = createSignal(false);
|
||||
const { t } = useI18n();
|
||||
|
||||
const onSubmit = async ({ name, color, description }: { name: string; color: string; description: string }) => {
|
||||
await updateTag({
|
||||
@@ -168,7 +182,7 @@ const UpdateTagModal: Component<{
|
||||
});
|
||||
|
||||
createToast({
|
||||
message: `Tag "${name}" updated successfully.`,
|
||||
message: t('tags.update.success', { name }),
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
@@ -180,10 +194,10 @@ const UpdateTagModal: Component<{
|
||||
<DialogTrigger as={props.children} />
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update tag</DialogTitle>
|
||||
<DialogTitle>{t('tags.update')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<TagForm onSubmit={onSubmit} initialValues={props.tag} submitLabel="Update tag" />
|
||||
<TagForm onSubmit={onSubmit} initialValues={props.tag} submitLabel={t('tags.update')} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -194,21 +208,21 @@ export const TagsPage: Component = () => {
|
||||
const { confirm } = useConfirmModal();
|
||||
const { t } = useI18n();
|
||||
|
||||
const query = createQuery(() => ({
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'tags'],
|
||||
queryFn: () => fetchTags({ organizationId: params.organizationId }),
|
||||
}));
|
||||
|
||||
const del = async ({ tag }: { tag: TagType }) => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Delete tag',
|
||||
message: 'Are you sure you want to delete this tag? Deleting a tag will remove it from all documents.',
|
||||
title: t('tags.delete.confirm.title'),
|
||||
message: t('tags.delete.confirm.message'),
|
||||
cancelButton: {
|
||||
text: 'Cancel',
|
||||
text: t('tags.delete.confirm.cancel-button'),
|
||||
variant: 'secondary',
|
||||
},
|
||||
confirmButton: {
|
||||
text: 'Delete',
|
||||
text: t('tags.delete.confirm.confirm-button'),
|
||||
variant: 'destructive',
|
||||
},
|
||||
});
|
||||
@@ -228,7 +242,7 @@ export const TagsPage: Component = () => {
|
||||
});
|
||||
|
||||
createToast({
|
||||
message: `Tag deleted successfully.`,
|
||||
message: t('tags.delete.success'),
|
||||
type: 'success',
|
||||
});
|
||||
};
|
||||
@@ -261,11 +275,11 @@ export const TagsPage: Component = () => {
|
||||
<div class="flex justify-between sm:items-center pb-6 gap-4 flex-col sm:flex-row">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold ">
|
||||
Documents Tags
|
||||
{t('tags.title')}
|
||||
</h2>
|
||||
|
||||
<p class="text-muted-foreground mt-1">
|
||||
Tags are used to categorize documents. You can add tags to your documents to make them easier to find and organize.
|
||||
{t('tags.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -274,7 +288,7 @@ export const TagsPage: Component = () => {
|
||||
{props => (
|
||||
<Button class="w-full" {...props}>
|
||||
<div class="i-tabler-plus size-4 mr-2" />
|
||||
Create tag
|
||||
{t('tags.create')}
|
||||
</Button>
|
||||
)}
|
||||
</CreateTagModal>
|
||||
@@ -284,12 +298,12 @@ export const TagsPage: Component = () => {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Tag</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Documents</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>{t('tags.table.headers.tag')}</TableHead>
|
||||
<TableHead>{t('tags.table.headers.description')}</TableHead>
|
||||
<TableHead>{t('tags.table.headers.documents')}</TableHead>
|
||||
<TableHead>{t('tags.table.headers.created')}</TableHead>
|
||||
<TableHead class="text-right">
|
||||
Actions
|
||||
{t('tags.table.headers.actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -302,7 +316,7 @@ export const TagsPage: Component = () => {
|
||||
<Tag name={tag.name} color={tag.color} />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{tag.description || <span class="text-muted-foreground">No description</span>}</TableCell>
|
||||
<TableCell>{tag.description || <span class="text-muted-foreground">{t('tags.form.no-description')}</span>}</TableCell>
|
||||
<TableCell>
|
||||
<A href={`/organizations/${params.organizationId}/documents?tags=${tag.id}`} class="inline-flex items-center gap-1 hover:underline">
|
||||
<div class="i-tabler-file-text size-5 text-muted-foreground" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { createEffect } from 'solid-js';
|
||||
import { useSession } from '@/modules/auth/auth.services';
|
||||
import { buildTimeConfig } from '@/modules/config/config';
|
||||
import { createEffect } from 'solid-js';
|
||||
import { trackingServices } from '../tracking.services';
|
||||
|
||||
export const IdentifyUser: Component = () => {
|
||||
|
||||
@@ -2,10 +2,10 @@ import type { AlertRootProps } from '@kobalte/core/alert';
|
||||
import type { PolymorphicProps } from '@kobalte/core/polymorphic';
|
||||
import type { VariantProps } from 'class-variance-authority';
|
||||
import type { ComponentProps, ValidComponent } from 'solid-js';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { Alert as AlertPrimitive } from '@kobalte/core/alert';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { splitProps } from 'solid-js';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
|
||||
export const alertVariants = cva(
|
||||
'relative w-full rounded-lg border px-4 py-3 text-sm [&:has(svg)]:pl-11 [&>svg+div]:translate-y-[-3px] [&>svg]:(absolute left-4 top-4 text-foreground)',
|
||||
|
||||
@@ -1,8 +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 { splitProps } from 'solid-js';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
|
||||
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)',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user