mirror of
https://github.com/papra-hq/papra.git
synced 2025-12-18 03:52:20 -06:00
Compare commits
67 Commits
dev-tools
...
@papra/doc
| 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 | ||
|
|
249b3bcfd2 | ||
|
|
d7838b5d57 | ||
|
|
f170ddd817 | ||
|
|
4f53c70854 | ||
|
|
85fa5c4342 | ||
|
|
c5d984a3a0 | ||
|
|
565bd8d7fd | ||
|
|
9b72aa886c | ||
|
|
7410455093 | ||
|
|
dd8f194fd0 | ||
|
|
803c39cbc8 | ||
|
|
096331a4ee | ||
|
|
59ba9465f6 | ||
|
|
a1056702af | ||
|
|
fd44897bca | ||
|
|
332d836d11 | ||
|
|
f613198cbd | ||
|
|
80491a5a58 | ||
|
|
605e21a410 | ||
|
|
dec589b6ed |
8
.changeset/README.md
Normal file
8
.changeset/README.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Changesets
|
||||
|
||||
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
|
||||
with multi-package repos, or single-package repos to help you version and publish your code. You can
|
||||
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
|
||||
|
||||
We have a quick list of common questions to get you started engaging with this project in
|
||||
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
|
||||
19
.changeset/config.json
Normal file
19
.changeset/config.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
|
||||
"changelog": [
|
||||
"@changesets/changelog-github",
|
||||
{ "repo": "papra-hq/papra"}
|
||||
],
|
||||
"commit": false,
|
||||
"fixed": [
|
||||
["@papra/app-client", "@papra/app-server"]
|
||||
],
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": [],
|
||||
"privatePackages": {
|
||||
"tag": true,
|
||||
"version": true
|
||||
}
|
||||
}
|
||||
4
.github/workflows/ci-apps-papra-server.yaml
vendored
4
.github/workflows/ci-apps-papra-server.yaml
vendored
@@ -26,7 +26,9 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i --frozen-lockfile
|
||||
run: |
|
||||
pnpm i --frozen-lockfile
|
||||
pnpm --filter "@papra/app-server^..." build
|
||||
|
||||
- name: Run linters
|
||||
run: pnpm lint
|
||||
|
||||
41
.github/workflows/ci-packages-api-sdk.yaml
vendored
Normal file
41
.github/workflows/ci-packages-api-sdk.yaml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
name: CI - Api SDK
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
ci-packages-api-sdk:
|
||||
name: CI - Api SDK
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/api-sdk
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
|
||||
- name: Run linters
|
||||
run: pnpm lint
|
||||
|
||||
- name: Type check
|
||||
run: pnpm typecheck
|
||||
|
||||
# - name: Run unit test
|
||||
# run: pnpm test
|
||||
|
||||
- name: Build the app
|
||||
run: pnpm build
|
||||
44
.github/workflows/ci-packages-cli.yaml
vendored
Normal file
44
.github/workflows/ci-packages-cli.yaml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: CI - CLI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
ci-packages-cli:
|
||||
name: CI - CLI
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/cli
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
|
||||
- name: Build related packages
|
||||
run: cd ../api-sdk && pnpm build
|
||||
|
||||
- name: Run linters
|
||||
run: pnpm lint
|
||||
|
||||
- name: Type check
|
||||
run: pnpm typecheck
|
||||
|
||||
# - name: Run unit test
|
||||
# run: pnpm test
|
||||
|
||||
- name: Build the app
|
||||
run: pnpm build
|
||||
41
.github/workflows/ci-packages-webhook.yaml
vendored
Normal file
41
.github/workflows/ci-packages-webhook.yaml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
name: CI - Webhooks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
ci-packages-webhooks:
|
||||
name: CI - Webhooks
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/webhooks
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
|
||||
- name: Run linters
|
||||
run: pnpm lint
|
||||
|
||||
- name: Type check
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Run unit test
|
||||
run: pnpm test
|
||||
|
||||
- name: Build the app
|
||||
run: pnpm build
|
||||
25
.github/workflows/release-docker.yaml
vendored
25
.github/workflows/release-docker.yaml
vendored
@@ -1,9 +1,12 @@
|
||||
name: Release new versions
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to release'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -14,14 +17,6 @@ jobs:
|
||||
name: Release Docker images
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get release version from tag
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
|
||||
|
||||
- name: Get release version from input
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
run: echo "RELEASE_VERSION=${{ github.event.inputs.release_version }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
@@ -53,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
|
||||
@@ -67,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
|
||||
53
.github/workflows/release.yml
vendored
Normal file
53
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
actions: write
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
|
||||
- name: Create Release Pull Request
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
# Note: pnpm install after versioning is necessary to refresh lockfile
|
||||
version: pnpm run version
|
||||
publish: pnpm exec changeset publish
|
||||
commit: "chore(release): update versions"
|
||||
title: "chore(release): update versions"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.CHANGESET_GITHUB_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- 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
|
||||
|
||||
|
||||
23
README.md
23
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
|
||||
|
||||
@@ -59,15 +56,21 @@ Papra is currently in **beta**. The core functionality is stable and usable, but
|
||||
- **Tags**: Organize your documents with tags.
|
||||
- **Email ingestion**: Send/forward emails to a generated address to automatically import documents.
|
||||
- **Content extraction**: Automatically extract text from images or scanned documents for search.
|
||||
- *In progress:* **i18n**: Support for multiple languages.
|
||||
- *Coming soon:* **Tagging Rules**: Automatically tag documents based on custom rules.
|
||||
- *Coming soon:* **Folder ingestion**: Automatically import documents from a folder.
|
||||
- *Coming soon:* **SDK and API**: Build your own applications on top of Papra.
|
||||
- *Coming soon:* **CLI**: Manage your documents from the command line.
|
||||
- **Tagging Rules**: Automatically tag documents based on custom rules.
|
||||
- **Folder ingestion**: Automatically import documents from a folder.
|
||||
- **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
|
||||
|
||||
|
||||
43
apps/docs/CHANGELOG.md
Normal file
43
apps/docs/CHANGELOG.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# @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
|
||||
|
||||
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix broken lint and added auto link check
|
||||
@@ -1,8 +1,11 @@
|
||||
import { env } from 'node:process';
|
||||
import starlight from '@astrojs/starlight';
|
||||
import { defineConfig } from 'astro/config';
|
||||
import starlightLinksValidator from 'starlight-links-validator';
|
||||
import starlightThemeRapide from 'starlight-theme-rapide';
|
||||
import 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;
|
||||
@@ -15,19 +18,20 @@ const posthogScript = posthogRawScript.replace('[POSTHOG-API-KEY]', posthogApiKe
|
||||
export default defineConfig({
|
||||
site: 'https://docs.papra.app',
|
||||
integrations: [
|
||||
UnoCSS(),
|
||||
starlight({
|
||||
plugins: [starlightThemeRapide()],
|
||||
plugins: [starlightThemeRapide(), starlightLinksValidator({ exclude: ['http://localhost:1221'] })],
|
||||
title: 'Papra Docs',
|
||||
logo: {
|
||||
dark: './src/assets/logo-dark.svg',
|
||||
light: './src/assets/logo-light.svg',
|
||||
alt: 'Papra Logo',
|
||||
},
|
||||
social: {
|
||||
github: 'https://github.com/papra-hq/papra',
|
||||
blueSky: 'https://bsky.app/profile/papra.app',
|
||||
discord: 'https://papra.app/discord',
|
||||
},
|
||||
social: [
|
||||
{ href: 'https://github.com/papra-hq/papra', icon: 'github', label: 'GitHub' },
|
||||
{ href: 'https://bsky.app/profile/papra.app', icon: 'blueSky', label: 'BlueSky' },
|
||||
{ href: 'https://papra.app/discord', icon: 'discord', label: 'Discord' },
|
||||
],
|
||||
expressiveCode: {
|
||||
themes: ['vitesse-black', 'vitesse-light'],
|
||||
},
|
||||
@@ -37,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,8 +1,9 @@
|
||||
{
|
||||
"name": "@papra/docs",
|
||||
"type": "module",
|
||||
"version": "0.3.0",
|
||||
"packageManager": "pnpm@9.15.4",
|
||||
"version": "0.5.1",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.9.0",
|
||||
"description": "Papra documentation website",
|
||||
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
@@ -17,10 +18,16 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/starlight": "^0.31.0",
|
||||
"astro": "^5.1.5",
|
||||
"@astrojs/solid-js": "^5.1.0",
|
||||
"@astrojs/starlight": "^0.34.3",
|
||||
"astro": "^5.8.0",
|
||||
"sharp": "^0.32.5",
|
||||
"starlight-theme-rapide": "^0.3.0",
|
||||
"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": {
|
||||
@@ -33,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';
|
||||
|
||||
@@ -12,7 +12,7 @@ This guide will show you how to setup a Cloudflare Email worker to receive email
|
||||
<Aside type="note">
|
||||
Setting up a Papra Intake Emails with a Cloudflare Email worker requires bit of knowledge about how to setup a CF Email worker and how to configure your Papra instance to receive emails.
|
||||
|
||||
For a simpler solution, you can use the official [OwlRelay integration](/docs/guides/intake-emails-with-papra-email-intake) guide.
|
||||
For a simpler solution, you can use the official [OwlRelay integration](/guides/intake-emails-with-owlrelay) guide.
|
||||
</Aside>
|
||||
|
||||
## Prerequisites
|
||||
@@ -23,7 +23,7 @@ In order to follow this guide, you need:
|
||||
- a publicly accessible Papra instance
|
||||
- basic development skills (git and node.js to setup the Email Worker)
|
||||
|
||||
If you prefer a simpler solution, you can use the official [OwlRelay integration](/docs/guides/intake-emails-with-papra-email-intake) guide.
|
||||
If you prefer a simpler solution, you can use the official [OwlRelay integration](/guides/intake-emails-with-owlrelay) guide.
|
||||
|
||||
## How it works
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ environment:
|
||||
|
||||
## Configuration
|
||||
|
||||
You can find the list of all configuration options in the [configuration reference](/docs/configuration-reference), the related variables are prefixed with `INGESTION_FOLDER_`.
|
||||
You can find the list of all configuration options in the [configuration reference](/self-hosting/configuration), the related variables are prefixed with `INGESTION_FOLDER_`.
|
||||
|
||||
|
||||
## Edge cases and behaviors
|
||||
|
||||
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)
|
||||
|
||||
|
||||
|
||||
89
apps/docs/src/content/docs/04-resources/02-cli.mdx
Normal file
89
apps/docs/src/content/docs/04-resources/02-cli.mdx
Normal file
@@ -0,0 +1,89 @@
|
||||
---
|
||||
title: CLI Documentation
|
||||
description: Learn how to use the Papra CLI to interact with your Papra instance from the command line.
|
||||
slug: resources/cli
|
||||
---
|
||||
|
||||
The Papra CLI is a command-line interface tool that helps you interact with the Papra platform from your terminal.
|
||||
|
||||
## Installation
|
||||
|
||||
For the moment, the CLI is only available as an NPM package.
|
||||
|
||||
```bash
|
||||
# using pnpm
|
||||
pnpm i -g @papra/cli
|
||||
|
||||
# or using npm
|
||||
npm i -g @papra/cli
|
||||
|
||||
# or using yarn
|
||||
yarn add -g @papra/cli
|
||||
```
|
||||
|
||||
The CLI will be installed globally, so you can use it from anywhere in your system with the `papra` command.
|
||||
|
||||
## Configuration
|
||||
|
||||
Before using the CLI, you need to configure it with your API credentials.
|
||||
|
||||
### Initial Setup
|
||||
|
||||
To initialize the configuration, run:
|
||||
|
||||
```bash
|
||||
papra config init
|
||||
```
|
||||
|
||||
This command will prompt you for:
|
||||
- **Instance URL**: Your Papra instance URL (e.g., `https://api.papra.app`)
|
||||
- **API Key**: Your personal API key (can be created in your User Settings)
|
||||
|
||||
### Managing Configuration
|
||||
|
||||
You can manage your configuration using the following commands:
|
||||
|
||||
- `papra config list`: View your current configuration
|
||||
- `papra config set api-key`: Set or update your API key
|
||||
- `papra config set api-url`: Set or update your instance URL
|
||||
- `papra config set default-org-id`: Set a default organization ID
|
||||
|
||||
### Organization IDs
|
||||
|
||||
Since Papra supports multiple organizations, you may need to specify the organization ID when importing documents for example. If want, you can set a default organization ID in your configuration.
|
||||
|
||||
```bash
|
||||
papra config set default-org-id <organization-id>
|
||||
papra documents import <file-path>
|
||||
|
||||
# or
|
||||
papra documents import -o <organization-id> <file-path>
|
||||
```
|
||||
|
||||
## Available Commands
|
||||
|
||||
### Importing documents
|
||||
|
||||
The `import` command allows you to import a document into your Papra organization.
|
||||
|
||||
```bash
|
||||
papra documents import -o <organization-id> <file-path>
|
||||
```
|
||||
|
||||
## Getting Help
|
||||
|
||||
For more information about any command, you can use the `--help` flag:
|
||||
|
||||
```bash
|
||||
papra --help
|
||||
papra config --help
|
||||
papra documents --help
|
||||
```
|
||||
|
||||
|
||||
## About the CLI
|
||||
|
||||
The CLI is built using the [citty](https://github.com/unjs/citty) framework and the [Papra TS SDK](https://github.com/papra-hq/papra/tree/main/packages/api-sdk).
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -51,11 +51,11 @@ In today's digital world, managing countless important documents efficiently and
|
||||
- **Tags**: Organize your documents with tags.
|
||||
- **Email ingestion**: Send/forward emails to a generated address to automatically import documents.
|
||||
- **Content extraction**: Automatically extract text from images or scanned documents for search.
|
||||
- *In progress:* **i18n**: Support for multiple languages.
|
||||
- *Coming soon:* **Tagging Rules**: Automatically tag documents based on custom rules.
|
||||
- *Coming soon:* **Folder ingestion**: Automatically import documents from a folder.
|
||||
- *Coming soon:* **SDK and API**: Build your own applications on top of Papra.
|
||||
- *Coming soon:* **CLI**: Manage your documents from the command line.
|
||||
- **Tagging Rules**: Automatically tag documents based on custom rules.
|
||||
- **Folder ingestion**: Automatically import documents from a folder.
|
||||
- **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,23 @@ 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',
|
||||
},
|
||||
{
|
||||
label: 'Security Policy',
|
||||
link: 'https://github.com/papra-hq/papra/blob/main/SECURITY.md',
|
||||
@@ -42,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',
|
||||
},
|
||||
});
|
||||
69
apps/papra-client/CHANGELOG.md
Normal file
69
apps/papra-client/CHANGELOG.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# @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
|
||||
|
||||
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added webhook management
|
||||
|
||||
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added API keys support
|
||||
|
||||
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added document searchable content edit
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added tag creation button in document page
|
||||
|
||||
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Improved tag selector input wrapping
|
||||
|
||||
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Properly handle file names without extensions
|
||||
|
||||
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Wrap text in document preview
|
||||
|
||||
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Excluded deleted documents from doc count
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"name": "@papra/papra-app-client",
|
||||
"name": "@papra/app-client",
|
||||
"type": "module",
|
||||
"version": "0.3.0",
|
||||
"packageManager": "pnpm@9.15.4",
|
||||
"version": "0.6.3",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.9.0",
|
||||
"description": "Papra frontend client",
|
||||
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
@@ -25,50 +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",
|
||||
"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,7 +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 { DevTools } from './modules/dev-tools/components/dev-tools.components';
|
||||
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';
|
||||
@@ -34,7 +34,6 @@ render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PageViewTracker />
|
||||
<IdentifyUser />
|
||||
<DevTools />
|
||||
|
||||
<Suspense>
|
||||
<I18nProvider>
|
||||
@@ -46,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,20 +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.integrations: Integrations
|
||||
layout.menu.deleted-documents: Deleted documents
|
||||
layout.menu.organization-settings: Organization settings
|
||||
layout.menu.api-keys: API keys
|
||||
layout.menu.settings: Settings
|
||||
layout.menu.account: Account
|
||||
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
|
||||
@@ -121,9 +359,6 @@ tagging-rules.form.conditions.no-conditions.title: No conditions
|
||||
tagging-rules.form.conditions.no-conditions.description: You didn't add any conditions to this rule. This rule will apply its tags to all documents.
|
||||
tagging-rules.form.conditions.no-conditions.confirm: Apply rule without conditions
|
||||
tagging-rules.form.conditions.no-conditions.cancel: Cancel
|
||||
tagging-rules.form.conditions.field.label: Field
|
||||
tagging-rules.form.conditions.operator.label: Operator
|
||||
tagging-rules.form.conditions.value.label: Value
|
||||
tagging-rules.form.conditions.value.placeholder: 'Example: invoice'
|
||||
tagging-rules.form.conditions.value.min-length: Please enter a value for the condition
|
||||
tagging-rules.form.tags.label: Tags
|
||||
@@ -132,41 +367,46 @@ tagging-rules.form.tags.min-length: At least one tag to apply is required
|
||||
tagging-rules.form.tags.add-tag: Create tag
|
||||
tagging-rules.form.submit: Create rule
|
||||
tagging-rules.update.title: Update tagging rule
|
||||
tagging-rules.update.success: Tagging rule updated successfully
|
||||
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
|
||||
@@ -186,23 +426,127 @@ api-keys.create.form.name.label: Name
|
||||
api-keys.create.form.name.placeholder: 'Example: My API key'
|
||||
api-keys.create.form.name.required: Please enter a name for the API key
|
||||
api-keys.create.form.permissions.label: Permissions
|
||||
api-keys.create.form.permissions.description: Select the permissions for the API key.
|
||||
api-keys.create.form.permissions.required: Please select at least one permission
|
||||
api-keys.create.form.submit: Create API key
|
||||
api-keys.create.created.title: API key created
|
||||
api-keys.create.created.description: The API key has been created successfully. Save it in a secure location as it will not be displayed again.
|
||||
api-keys.list.title: API keys
|
||||
api-keys.list.description: Manage your API keys here.
|
||||
api-keys.list.delete: Delete
|
||||
api-keys.list.create: Create API key
|
||||
api-keys.list.empty.title: No API keys
|
||||
api-keys.list.empty.description: Create an API key to access the Papra API.
|
||||
api-keys.list.card.last-used: Last used
|
||||
api-keys.list.card.never: Never
|
||||
api-keys.list.card.created: Created
|
||||
api-keys.list.card.delete: Delete
|
||||
api-keys.delete.success: The API key has been deleted successfully
|
||||
api-keys.delete.confirm.title: Delete API key
|
||||
api-keys.delete.confirm.message: Are you sure you want to delete this API key? This action cannot be undone.
|
||||
api-keys.delete.confirm.confirm-button: Delete
|
||||
api-keys.delete.confirm.cancel-button: Cancel
|
||||
|
||||
# Webhooks
|
||||
|
||||
webhooks.list.title: Webhooks
|
||||
webhooks.list.description: Manage your organization webhooks
|
||||
webhooks.list.empty.title: No webhooks
|
||||
webhooks.list.empty.description: Create your first webhook to start receiving events
|
||||
webhooks.list.create: Create webhook
|
||||
webhooks.list.card.last-triggered: Last triggered
|
||||
webhooks.list.card.never: Never
|
||||
webhooks.list.card.created: Created
|
||||
webhooks.create.title: Create webhook
|
||||
webhooks.create.description: Create a new webhook to receive events
|
||||
webhooks.create.success: Webhook created successfully
|
||||
webhooks.create.back: Back
|
||||
webhooks.create.form.submit: Create webhook
|
||||
webhooks.create.form.name.label: Webhook name
|
||||
webhooks.create.form.name.placeholder: Enter webhook name
|
||||
webhooks.create.form.name.required: Name is required
|
||||
webhooks.create.form.url.label: Webhook URL
|
||||
webhooks.create.form.url.placeholder: Enter webhook URL
|
||||
webhooks.create.form.url.required: URL is required
|
||||
webhooks.create.form.url.invalid: URL is invalid
|
||||
webhooks.create.form.secret.label: Secret
|
||||
webhooks.create.form.secret.placeholder: Enter webhook secret
|
||||
webhooks.create.form.events.label: Events
|
||||
webhooks.create.form.events.required: At least one event is required
|
||||
webhooks.update.title: Edit webhook
|
||||
webhooks.update.description: Update your webhook details
|
||||
webhooks.update.success: Webhook updated successfully
|
||||
webhooks.update.submit: Update webhook
|
||||
webhooks.update.cancel: Cancel
|
||||
webhooks.update.form.secret.placeholder: Enter new secret
|
||||
webhooks.update.form.secret.placeholder-redacted: '[Redacted secret]'
|
||||
webhooks.update.form.rotate-secret.button: Rotate secret
|
||||
webhooks.delete.success: Webhook deleted successfully
|
||||
webhooks.delete.confirm.title: Delete webhook
|
||||
webhooks.delete.confirm.message: Are you sure you want to delete this webhook?
|
||||
webhooks.delete.confirm.confirm-button: Delete
|
||||
webhooks.delete.confirm.cancel-button: Cancel
|
||||
|
||||
webhooks.events.documents.document:created.description: Document created
|
||||
webhooks.events.documents.document:deleted.description: Document deleted
|
||||
|
||||
# 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,31 +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.integrations: Intégrations
|
||||
layout.menu.deleted-documents: Documents supprimés
|
||||
layout.menu.organization-settings: Paramètres de l'organisation
|
||||
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.'
|
||||
@@ -123,9 +359,6 @@ tagging-rules.form.conditions.no-conditions.title: Aucune condition
|
||||
tagging-rules.form.conditions.no-conditions.description: Vous n'avez pas ajouté de conditions à cette règle. Cette règle appliquera ses tags à tous les documents.
|
||||
tagging-rules.form.conditions.no-conditions.confirm: Appliquer la règle sans conditions
|
||||
tagging-rules.form.conditions.no-conditions.cancel: Annuler
|
||||
tagging-rules.form.conditions.field.label: Champ
|
||||
tagging-rules.form.conditions.operator.label: Opérateur
|
||||
tagging-rules.form.conditions.value.label: Valeur
|
||||
tagging-rules.form.conditions.value.placeholder: 'Exemple: facture'
|
||||
tagging-rules.form.conditions.value.min-length: Veuillez entrer une valeur pour la condition
|
||||
tagging-rules.form.tags.label: Tags
|
||||
@@ -134,41 +367,46 @@ tagging-rules.form.tags.min-length: Au moins un tag à appliquer est requis
|
||||
tagging-rules.form.tags.add-tag: Créer un tag
|
||||
tagging-rules.form.submit: Créer la règle
|
||||
tagging-rules.update.title: Mettre à jour la règle de catégorisation
|
||||
tagging-rules.update.success: Règle de catégorisation mise à jour avec succès
|
||||
tagging-rules.update.error: Échec de la mise à jour de la règle de catégorisation
|
||||
tagging-rules.update.submit: Mettre à jour la règle
|
||||
tagging-rules.update.cancel: Annuler
|
||||
|
||||
demo.popup.description: Cette instance est une démo, toutes les données sont sauvegardées dans le stockage local de votre navigateur.
|
||||
demo.popup.discord: Rejoignez le {{ discordLink }} pour obtenir de l'aide, proposer des fonctionnalités ou simplement discuter.
|
||||
demo.popup.discord-link-label: Serveur Discord
|
||||
demo.popup.reset: Réinitialiser les données de la démo
|
||||
demo.popup.hide: Masquer
|
||||
# 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
|
||||
@@ -188,23 +426,127 @@ api-keys.create.form.name.label: Nom
|
||||
api-keys.create.form.name.placeholder: 'Exemple: Ma clé API'
|
||||
api-keys.create.form.name.required: Veuillez entrer un nom pour la clé API
|
||||
api-keys.create.form.permissions.label: Permissions
|
||||
api-keys.create.form.permissions.description: Sélectionnez les permissions pour la clé API.
|
||||
api-keys.create.form.permissions.required: Veuillez sélectionner au moins une permission
|
||||
api-keys.create.form.submit: Créer la clé API
|
||||
api-keys.create.created.title: Clé API créée
|
||||
api-keys.create.created.description: La clé API a été créée avec succès. Enregistrez-la dans un endroit sûr car elle ne sera plus affichée.
|
||||
api-keys.list.title: Clés API
|
||||
api-keys.list.description: Gérez vos clés API ici.
|
||||
api-keys.list.delete: Supprimer
|
||||
api-keys.list.create: Créer une clé API
|
||||
api-keys.list.empty.title: Aucune clé API
|
||||
api-keys.list.empty.description: Créez une clé API pour accéder à l'API de Papra.
|
||||
api-keys.list.card.last-used: Dernière utilisation
|
||||
api-keys.list.card.never: Jamais
|
||||
api-keys.list.card.created: Créée
|
||||
api-keys.list.card.delete: Supprimer
|
||||
api-keys.delete.success: La clé API a été supprimée avec succès
|
||||
api-keys.delete.confirm.title: Supprimer la clé API
|
||||
api-keys.delete.confirm.message: Êtes-vous sûr de vouloir supprimer cette clé API ? Cette action est irréversible.
|
||||
api-keys.delete.confirm.confirm-button: Supprimer
|
||||
api-keys.delete.confirm.cancel-button: Annuler
|
||||
|
||||
# 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,7 +1,8 @@
|
||||
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 { type Component, 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,21 +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 { type Component, 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'] });
|
||||
@@ -84,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,3 +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';
|
||||
@@ -5,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 { type Component, 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,7 +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 { type Component, type ComponentProps, 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,6 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { Navigate } from '@solidjs/router';
|
||||
import { type Component, Suspense } from 'solid-js';
|
||||
import { Suspense } from 'solid-js';
|
||||
import { Dynamic } from 'solid-js/web';
|
||||
import { match } from 'ts-pattern';
|
||||
import { useSession } from '../auth.services';
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { SsoProviderKey } from '../auth.types';
|
||||
import type { Component } from 'solid-js';
|
||||
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';
|
||||
@@ -6,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 { type Component, 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';
|
||||
|
||||
@@ -85,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>
|
||||
@@ -104,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,16 +1,17 @@
|
||||
import type { ssoProviders } from '../auth.constants';
|
||||
import type { Component } from 'solid-js';
|
||||
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 { type Component, createSignal, For, Show } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
|
||||
import { getEnabledSsoProviderConfigs } from '../auth.models';
|
||||
import { signIn, signUp } from '../auth.services';
|
||||
import { authWithProvider, signUp } from '../auth.services';
|
||||
import { AuthLegalLinks } from '../components/legal-links.component';
|
||||
import { SsoProviderButton } from '../components/sso-provider-button.component';
|
||||
|
||||
@@ -132,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;
|
||||
@@ -168,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,12 +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 { type Component, createSignal, onMount } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
|
||||
import { forgetPassword } from '../auth.services';
|
||||
import { OpenEmailProvider } from '../components/open-email-provider.component';
|
||||
@@ -57,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 { type Component, createSignal } from 'solid-js';
|
||||
import { onMount } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
|
||||
import { resetPassword } from '../auth.services';
|
||||
|
||||
@@ -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,11 +1,12 @@
|
||||
import type { ParentComponent } from 'solid-js';
|
||||
import { createQuery } from '@tanstack/solid-query';
|
||||
import type { Config, RuntimePublicConfig } from './config';
|
||||
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';
|
||||
import { EmptyState } from '../ui/components/empty';
|
||||
import { createToast } from '../ui/components/sonner';
|
||||
import { buildTimeConfig, type Config, type RuntimePublicConfig } from './config';
|
||||
import { buildTimeConfig } from './config';
|
||||
import { fetchPublicConfig } from './config.services';
|
||||
|
||||
const ConfigContext = createContext<{
|
||||
@@ -23,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 });
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { HttpClientOptions, ResponseType } from '../shared/http/http-client
|
||||
import { joinUrlPaths } from '@corentinth/chisels';
|
||||
import { router } from './demo-api-mock';
|
||||
|
||||
export async function demoHttpClient<A, R extends ResponseType = 'json'>(options: HttpClientOptions<R>): Promise<MappedResponseType< R, A>> {
|
||||
export async function demoHttpClient<A, R extends ResponseType = 'json'>(options: HttpClientOptions<R>): Promise<MappedResponseType<R, A>> {
|
||||
const path = `/${joinUrlPaths(options.method ?? 'GET', options.url)}`;
|
||||
const matchedRoute = router.lookup(path);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { A, useNavigate } from '@solidjs/router';
|
||||
import { type Component, createSignal } from 'solid-js';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { Portal } from 'solid-js/web';
|
||||
import { buildTimeConfig } from '../config/config';
|
||||
import { useI18n } from '../i18n/i18n.provider';
|
||||
|
||||
@@ -8,7 +8,7 @@ export async function getValues<T extends StorageValue>(storage: Storage<T>): Pr
|
||||
return values;
|
||||
}
|
||||
|
||||
export async function findOne<T extends StorageValue>(storage: Storage<T>, predicate: (value: T) => boolean): Promise< T | null> {
|
||||
export async function findOne<T extends StorageValue>(storage: Storage<T>, predicate: (value: T) => boolean): Promise<T | null> {
|
||||
const values = await getValues(storage);
|
||||
const found = values.find(predicate);
|
||||
|
||||
|
||||
@@ -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,43 +0,0 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { makePersisted } from '@solid-primitives/storage';
|
||||
import { createSignal, Show } from 'solid-js';
|
||||
import { Portal } from 'solid-js/web';
|
||||
|
||||
export const DevToolsOverlayComponent: Component = () => {
|
||||
const [isCollapsed, setIsCollapsed] = makePersisted(createSignal<boolean>(true), { name: 'papra-dev-tools-collapsed', storage: localStorage });
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Show
|
||||
when={!isCollapsed()}
|
||||
fallback={(
|
||||
<Button
|
||||
onClick={() => setIsCollapsed(false)}
|
||||
variant="secondary"
|
||||
class="fixed bottom-0 left-50% -translate-x-1/2 z-50 rounded-b-none shadow"
|
||||
>
|
||||
<div class="i-tabler-chevron-up size-5" />
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
<div class="fixed bottom-0 left-50% -translate-x-1/2 z-50 bg-card rounded-t-xl shadow w-full max-w-500px border border-b-none">
|
||||
<div class="flex items-center justify-between py-2 px-5">
|
||||
<span class="text-sm font-medium">
|
||||
Dev Tools
|
||||
</span>
|
||||
|
||||
<Button
|
||||
onClick={() => setIsCollapsed(true)}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<div class="i-tabler-chevron-down size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Show>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { lazy, Show } from 'solid-js';
|
||||
|
||||
const DevToolsOverlay = lazy(() => import('./dev-tools-overlay.component').then(m => ({ default: m.DevToolsOverlayComponent })));
|
||||
|
||||
export const DevTools: Component = () => {
|
||||
return (
|
||||
<Show when={import.meta.env.DEV}>
|
||||
<DevToolsOverlay />
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
@@ -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() });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -50,7 +50,7 @@ type TaskError = {
|
||||
|
||||
type Task = TaskSuccess | TaskError | {
|
||||
file: File;
|
||||
status: 'pending' | 'uploading' ;
|
||||
status: 'pending' | 'uploading';
|
||||
};
|
||||
|
||||
export const DocumentUploadProvider: ParentComponent = (props) => {
|
||||
|
||||
@@ -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,7 +1,8 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import type { Document } from '../documents.types';
|
||||
import { useQuery } from '@tanstack/solid-query';
|
||||
import { createResource, Match, Suspense, Switch } from 'solid-js';
|
||||
import { Card } from '@/modules/ui/components/card';
|
||||
import { createQuery } from '@tanstack/solid-query';
|
||||
import { type Component, createResource, Match, Suspense, Switch } from 'solid-js';
|
||||
import { fetchDocumentFile } from '../documents.services';
|
||||
import { PdfViewer } from './pdf-viewer.component';
|
||||
|
||||
@@ -34,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,7 +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';
|
||||
@@ -9,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 { type Accessor, type Component, For, Match, type Setter, 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,4 +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';
|
||||
@@ -7,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 { type Component, 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
|
||||
@@ -25,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>
|
||||
@@ -40,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 });
|
||||
},
|
||||
@@ -81,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" />
|
||||
@@ -96,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 });
|
||||
},
|
||||
@@ -132,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" />
|
||||
@@ -147,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,
|
||||
@@ -159,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>
|
||||
|
||||
@@ -176,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>
|
||||
@@ -202,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,9 +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';
|
||||
@@ -13,15 +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 { type Component, For, type JSX, Show, Suspense } from 'solid-js';
|
||||
import { createSignal } 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" value="content">
|
||||
<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,16 +1,19 @@
|
||||
import { fetchOrganization } from '@/modules/organizations/organizations.services';
|
||||
import { Tag } from '@/modules/tags/components/tag.component';
|
||||
import { fetchTags } from '@/modules/tags/tags.services';
|
||||
import type { Component } from 'solid-js';
|
||||
import { useParams, useSearchParams } from '@solidjs/router';
|
||||
import { createQueries, keepPreviousData } from '@tanstack/solid-query';
|
||||
import { castArray } from 'lodash-es';
|
||||
import { type Component, createSignal, For, Show, Suspense } from 'solid-js';
|
||||
import { createSignal, For, Show, Suspense } from 'solid-js';
|
||||
import { 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 });
|
||||
|
||||
@@ -50,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 />
|
||||
@@ -64,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">
|
||||
@@ -82,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;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { glob } from 'tinyglobby';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
const rawLocales = import.meta.glob('../../locales/*.yml', { eager: true });
|
||||
@@ -28,4 +30,41 @@ describe('locales', () => {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('all keys in en.yml must be used in the app (dynamic keys are manually excluded)', async () => {
|
||||
const srcFileNames = await glob(['src/**/*.{ts,tsx}', '!src/**/*.test.*', '!src/modules/i18n/locales.types.ts'], { cwd: process.cwd() });
|
||||
|
||||
// Exclude keys that are used in dynamic contexts
|
||||
const dynamicKeysMatchers = [
|
||||
/^api-errors\./, // api-errors.document.already_exists
|
||||
/^auth\.register\.providers\.[a-z0-9:]+$/, // auth.register.providers.google
|
||||
/^webhooks\.events\.documents\.[a-z0-9:]+.description$/, // webhooks.events.organization.organization:created
|
||||
/^api-keys\.permissions\.[a-z0-9:]+\.[a-z0-9:]+$/, // api-keys.permissions.documents.documents:delete
|
||||
/^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(
|
||||
Object
|
||||
.keys(defaultLocal)
|
||||
.filter(key => !dynamicKeysMatchers.some(matcher => matcher.test(key))),
|
||||
);
|
||||
|
||||
for (const srcFileName of srcFileNames) {
|
||||
const fileContent = await readFile(srcFileName, 'utf-8');
|
||||
|
||||
for (const key of keys) {
|
||||
if (fileContent.includes(key)) {
|
||||
keys.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (keys.size === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect([...keys]).to.eql([], 'Unused keys found in en.yml, please remove them (or add them to the dynamic keys matchers in locales.test.ts if they are used in dynamic contexts)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,19 +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.integrations'
|
||||
| 'layout.menu.deleted-documents'
|
||||
| 'layout.menu.organization-settings'
|
||||
| 'layout.menu.api-keys'
|
||||
| 'layout.menu.settings'
|
||||
| 'layout.menu.account'
|
||||
| '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'
|
||||
@@ -118,9 +322,6 @@ export type LocaleKeys =
|
||||
| 'tagging-rules.form.conditions.no-conditions.description'
|
||||
| 'tagging-rules.form.conditions.no-conditions.confirm'
|
||||
| 'tagging-rules.form.conditions.no-conditions.cancel'
|
||||
| 'tagging-rules.form.conditions.field.label'
|
||||
| 'tagging-rules.form.conditions.operator.label'
|
||||
| 'tagging-rules.form.conditions.value.label'
|
||||
| 'tagging-rules.form.conditions.value.placeholder'
|
||||
| 'tagging-rules.form.conditions.value.min-length'
|
||||
| 'tagging-rules.form.tags.label'
|
||||
@@ -129,37 +330,41 @@ export type LocaleKeys =
|
||||
| 'tagging-rules.form.tags.add-tag'
|
||||
| 'tagging-rules.form.submit'
|
||||
| 'tagging-rules.update.title'
|
||||
| 'tagging-rules.update.success'
|
||||
| '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'
|
||||
@@ -178,23 +383,105 @@ export type LocaleKeys =
|
||||
| 'api-keys.create.form.name.placeholder'
|
||||
| 'api-keys.create.form.name.required'
|
||||
| 'api-keys.create.form.permissions.label'
|
||||
| 'api-keys.create.form.permissions.description'
|
||||
| 'api-keys.create.form.permissions.required'
|
||||
| 'api-keys.create.form.submit'
|
||||
| 'api-keys.create.created.title'
|
||||
| 'api-keys.create.created.description'
|
||||
| 'api-keys.list.title'
|
||||
| 'api-keys.list.description'
|
||||
| 'api-keys.list.delete'
|
||||
| 'api-keys.list.create'
|
||||
| 'api-keys.list.empty.title'
|
||||
| 'api-keys.list.empty.description'
|
||||
| 'api-keys.list.card.last-used'
|
||||
| 'api-keys.list.card.never'
|
||||
| 'api-keys.list.card.created'
|
||||
| 'api-keys.list.card.delete'
|
||||
| 'api-keys.delete.success'
|
||||
| 'api-keys.delete.confirm.title'
|
||||
| 'api-keys.delete.confirm.message'
|
||||
| 'api-keys.delete.confirm.confirm-button'
|
||||
| 'api-keys.delete.confirm.cancel-button';
|
||||
| 'api-keys.delete.confirm.cancel-button'
|
||||
| 'webhooks.list.title'
|
||||
| 'webhooks.list.description'
|
||||
| 'webhooks.list.empty.title'
|
||||
| 'webhooks.list.empty.description'
|
||||
| 'webhooks.list.create'
|
||||
| 'webhooks.list.card.last-triggered'
|
||||
| 'webhooks.list.card.never'
|
||||
| 'webhooks.list.card.created'
|
||||
| 'webhooks.create.title'
|
||||
| 'webhooks.create.description'
|
||||
| 'webhooks.create.success'
|
||||
| 'webhooks.create.back'
|
||||
| 'webhooks.create.form.submit'
|
||||
| 'webhooks.create.form.name.label'
|
||||
| 'webhooks.create.form.name.placeholder'
|
||||
| 'webhooks.create.form.name.required'
|
||||
| 'webhooks.create.form.url.label'
|
||||
| 'webhooks.create.form.url.placeholder'
|
||||
| 'webhooks.create.form.url.required'
|
||||
| 'webhooks.create.form.url.invalid'
|
||||
| 'webhooks.create.form.secret.label'
|
||||
| 'webhooks.create.form.secret.placeholder'
|
||||
| 'webhooks.create.form.events.label'
|
||||
| 'webhooks.create.form.events.required'
|
||||
| 'webhooks.update.title'
|
||||
| 'webhooks.update.description'
|
||||
| 'webhooks.update.success'
|
||||
| 'webhooks.update.submit'
|
||||
| 'webhooks.update.cancel'
|
||||
| 'webhooks.update.form.secret.placeholder'
|
||||
| 'webhooks.update.form.secret.placeholder-redacted'
|
||||
| 'webhooks.update.form.rotate-secret.button'
|
||||
| 'webhooks.delete.success'
|
||||
| 'webhooks.delete.confirm.title'
|
||||
| 'webhooks.delete.confirm.message'
|
||||
| 'webhooks.delete.confirm.confirm-button'
|
||||
| 'webhooks.delete.confirm.cancel-button'
|
||||
| 'webhooks.events.documents.document:created.description'
|
||||
| 'webhooks.events.documents.document:deleted.description'
|
||||
| '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,6 +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';
|
||||
@@ -13,16 +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 { type Component, For, type JSX, Show, Suspense } from 'solid-js';
|
||||
import { createSignal } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { createIntakeEmail, deleteIntakeEmail, fetchIntakeEmails, updateIntakeEmail } from '../intake-emails.services';
|
||||
|
||||
const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) => JSX.Element; intakeEmails: IntakeEmail }> = (props) => {
|
||||
const [getAllowedOrigins, setAllowedOrigins] = createSignal([...props.intakeEmails.allowedOrigins]);
|
||||
const { 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 (
|
||||
<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 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,19 +265,22 @@ 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>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<For each={intakeEmails()}>
|
||||
{intakeEmail => (
|
||||
<div class="flex items-center justify-between border rounded-lg p-4">
|
||||
<div class="flex items-center justify-between border rounded-lg p-4 bg-card">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="bg-muted size-9 rounded-lg flex items-center justify-center">
|
||||
<div class={cn('i-tabler-mail size-5', intakeEmail.isEnabled ? 'text-primary' : 'text-muted-foreground')} />
|
||||
@@ -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,21 +344,17 @@ 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>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,7 +1,9 @@
|
||||
import { useCurrentUser } from '@/modules/users/composables/useCurrentUser';
|
||||
import type { Component } from 'solid-js';
|
||||
import { useNavigate } from '@solidjs/router';
|
||||
import { createQuery } from '@tanstack/solid-query';
|
||||
import { type Component, createEffect, on } from 'solid-js';
|
||||
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';
|
||||
@@ -10,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');
|
||||
@@ -39,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,15 +1,18 @@
|
||||
import type { Component } from 'solid-js';
|
||||
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';
|
||||
import { formatBytes } from '@corentinth/chisels';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { createQueries, keepPreviousData } from '@tanstack/solid-query';
|
||||
import { type Component, createSignal, Show, Suspense } from 'solid-js';
|
||||
|
||||
export const OrganizationPage: Component = () => {
|
||||
const params = useParams();
|
||||
const { t } = useI18n();
|
||||
const [getPagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 100 });
|
||||
|
||||
const query = createQueries(() => ({
|
||||
@@ -38,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 />
|
||||
@@ -56,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}>
|
||||
@@ -68,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>
|
||||
@@ -79,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>
|
||||
@@ -89,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,5 +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';
|
||||
@@ -7,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 { type Component, 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';
|
||||
@@ -19,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') });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -44,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>
|
||||
@@ -61,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);
|
||||
@@ -69,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;
|
||||
@@ -85,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>
|
||||
);
|
||||
@@ -99,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({
|
||||
@@ -113,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') });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -121,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>}
|
||||
@@ -148,7 +158,6 @@ const UpdateOrganizationNameCard: Component<{ organization: Organization }> = (p
|
||||
|
||||
<div class="text-red-500 text-sm">{form.response.message}</div>
|
||||
</CardContent>
|
||||
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -157,24 +166,25 @@ 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 }),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div class="p-6 mt-4 pb-32 mx-auto max-w-xl">
|
||||
<div class="p-6 mt-10 pb-32 mx-auto max-w-screen-md w-full">
|
||||
<Suspense>
|
||||
<Show when={query.data?.organization}>
|
||||
{ getOrganization => (
|
||||
<>
|
||||
<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,18 +1,21 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { A, useNavigate } from '@solidjs/router';
|
||||
import { createQuery } from '@tanstack/solid-query';
|
||||
import { type Component, createEffect, For, on } from 'solid-js';
|
||||
import { 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');
|
||||
@@ -23,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}`}
|
||||
@@ -42,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>
|
||||
@@ -55,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,7 @@
|
||||
import { buildTimeConfig } from '@/modules/config/config';
|
||||
import type { HttpClientOptions, ResponseType } from './http-client';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { httpClient, type HttpClientOptions, type ResponseType } from './http-client';
|
||||
import { buildTimeConfig } from '@/modules/config/config';
|
||||
import { httpClient } from './http-client';
|
||||
import { isHttpErrorWithStatusCode } from './http-errors';
|
||||
|
||||
export async function apiClient<T, R extends ResponseType = 'json'>({
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user